DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+912
View File
@@ -0,0 +1,912 @@
// Package services hosts the wails3-exposed application services. All
// services share one core that owns the loaded pvf archive and its derived
// indexes (directory tree, sorted path list for search).
package services
import (
"context"
"errors"
"fmt"
"runtime"
"runtime/debug"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
annotationrules "pvfine/internal/annotations"
"pvfine/internal/pvf"
renderingrules "pvfine/internal/rendering"
"pvfine/internal/ruleconfig"
pvfversion "pvfine/internal/version"
)
// emitEvent 安全地发事件:脱离 wails 运行时(如单元测试)时为 no-op。
func emitEvent(name string, data ...any) {
var payload any
if len(data) == 1 {
payload = data[0]
}
recordLogEvent(name, payload)
if app := application.Get(); app != nil {
app.Event.Emit(name, data...)
}
}
var (
ErrNoArchive = errors.New("尚未打开归档文件")
ErrSearchIndexing = errors.New("搜索索引正在构建")
ErrBatchPlanStale = errors.New("批处理预览已过期,请重新预览")
)
const (
ChangeKindAdded = "added"
ChangeKindModified = "modified"
)
// TreeNode is one entry in the explorer tree: either a directory or a file.
type TreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int32 `json:"size"`
DataType int32 `json:"dataType"`
ChildCount int32 `json:"childCount"`
FileIndex int32 `json:"fileIndex"` // -1 for directories
ChangeKind string `json:"changeKind,omitempty"`
Tags []TreeTag `json:"tags,omitempty"`
Annotations []TreeAnnotation `json:"annotations,omitempty"`
Icon *ImageReference `json:"icon,omitempty"`
FieldImage *ImageReference `json:"fieldImage,omitempty"`
}
// pathEntry feeds the search scanner.
type pathEntry struct {
path string
lower string
idx int32
size int32
typ int32
changeKind string
}
// core owns the loaded archive plus derived indexes. Guarded by mu; all
// services take it per call.
type core struct {
annotationSnapshot *ruleconfig.Snapshot
annotationDocumentation *annotationCatalog
annotationDocumentationEngine *annotationrules.Engine
annotationDocumentationSnapshot *ruleconfig.Snapshot
mcpSaveSequence uint64
mcpWorkspaceID string
mcpEpoch uint64
mcpSourceHash string
mcpScriptPlan string
mcpImportPlan string
archiveDiff *ArchiveDiffService
ruleMu sync.Mutex
ruleStore *ruleconfig.Store
ruleWarnings []string
settings *SettingsService
mu sync.RWMutex
archive *pvf.Archive
diskIndex *sqliteArchiveIndex
archiveGeneration uint64
fileIndexCancel context.CancelFunc
annotationEngine *annotationrules.Engine
annotationErr error
renderingEngine *renderingrules.Engine
renderingErr error
annotationRelations map[string]map[string]*relationTarget
editorText map[int32]string
editorAnnotation editorAnnotationCache
pathAnnotations map[string][]TreeAnnotation
dirChildren map[string][]*TreeNode // dirPath -> ordered children ("" = root)
directories []string
sortedPaths []pathEntry
searchRecords []searchRecord
searchByFile map[int32][]int
// searchMetadata is the canonical semantic snapshot used to rebuild the
// derived search records after a path/list delta. It intentionally keeps
// only list-backed records; ordinary file records are derived from
// sortedPaths and are therefore not duplicated here.
searchMetadata []indexedMetadata
searchSpecFingerprint string
treeTagsByFile map[int32][]TreeTag
visualsByFile map[int32]fileVisuals
indexStatus IndexStatus
indexStartedAt time.Time
indexCancel context.CancelFunc
// indexRefreshPending coalesces mutations that arrive while a semantic
// build is already running. The active build is allowed to publish its
// candidate, then one follow-up build consumes the accumulated dirty state.
// Keeping this separate from indexGen avoids cancelling a useful build and
// avoids losing a mutation in the cancel/publish race.
indexRefreshPending bool
indexRefreshPendingForce bool
indexDirty map[int32]struct{}
indexGen uint64
searchIndexDeltaPending bool
searchIndexListPending map[int32]struct{}
// searchIndexCachePath is only set by tests. Production cache files are
// resolved from os.UserCacheDir by search_index_cache.go.
searchIndexCachePath string
batchRevision uint64
batchPlan *batchPlan
scriptPlan *scriptPlan
scriptCancel context.CancelFunc
versionRepo *pvfversion.Repository
versionHead pvfversion.Commit
versionHeadSnapshot pvfversion.Snapshot
versionWorking pvfversion.Snapshot
versionChanges []pvfversion.FileChange
versionChangeMap map[string]pvfversion.FileChange
versionUndo []versionUndoRecord
versionSavedSnapshot pvfversion.Snapshot
versionArtifactDirty map[string]struct{}
versionSavedTree string
versionSavedPVF string
versionSavedCommit string
versionViewCommit string
versionBaseArchive *pvf.Archive
versionLoadID uint64
versionLoading bool
versionLoadError string
advancedDisk *advancedSQLite
advancedStatus AdvancedSearchIndexStatus
advancedCancel context.CancelFunc
binaryCache map[binarySearchKey][]advancedFileMatch
unpackCancel atomic.Bool
unpackRunning atomic.Bool
archiveTasks archiveTaskGate
// autosave mirrors unsaved edits into a backup cache. It is attached once
// during startup (nil in tests) so save/close handlers can drop a backup
// that no longer protects anything.
autosave *AutosaveService
}
// attachAutosave wires the backup service into the shared core. It is called
// before the application starts serving requests.
func (c *core) attachAutosave(service *AutosaveService) {
c.autosave = service
}
type editorAnnotationCache struct {
editableByteLimit int32
valid bool
fileIndex int32
text string
annotations []EditorAnnotation
}
func newCore() *core { return makeCore() }
// NewCore creates the shared service state (one per application).
func NewCore(settings ...*SettingsService) *core {
c := makeCore()
c.initializeRuleStore()
if len(settings) > 0 {
c.settings = settings[0]
}
return c
}
func (c *core) editableByteLimit() int32 {
return editableByteLimit(c.settings)
}
func makeCore() *core {
annotationEngine, annotationErr := annotationrules.LoadDefault()
renderingEngine, renderingErr := renderingrules.LoadDefault()
return &core{
annotationEngine: annotationEngine,
annotationErr: annotationErr,
renderingEngine: renderingEngine,
renderingErr: renderingErr,
visualsByFile: make(map[int32]fileVisuals),
archiveTasks: newArchiveTaskGate(),
}
}
func archiveChangeKind(a *pvf.Archive, index int32) string {
if a == nil || index < 0 || index >= a.FileCount() || !a.IsModified(index) {
return ""
}
if a.File(index).ChunkIndex < 0 {
return ChangeKindAdded
}
return ChangeKindModified
}
// setArchive loads an archive and builds derived indexes. Large archives use a
// disk-backed projection so the in-memory tree is never materialized.
func (c *core) setArchive(a *pvf.Archive) error {
if c.annotationErr != nil {
return c.annotationErr
}
if c.renderingErr != nil {
return c.renderingErr
}
c.cancelPendingFileIndexBuild()
// Archives constructed in tests/import pipelines may have mutations from
// their preparation phase. They are the clean baseline once installed.
a.ClearMutations()
if a.FileCount() >= largeArchiveIndexThreshold {
index, _, err := openSQLiteArchiveIndex(a)
if err != nil {
return err
}
c.mu.Lock()
c.detachVersionLocked()
c.invalidateAdvancedSearchLocked()
if c.diskIndex != nil {
c.diskIndex.close()
}
c.diskIndex = index
c.installDiskArchiveIndexesLocked(a)
c.mu.Unlock()
return nil
}
children, paths, err := buildIndex(a)
if err != nil {
return err
}
c.mu.Lock()
c.detachVersionLocked()
c.invalidateAdvancedSearchLocked()
if c.diskIndex != nil {
c.diskIndex.close()
c.diskIndex = nil
}
c.installArchiveIndexesLocked(a, children, paths)
c.mu.Unlock()
return nil
}
func (c *core) setArchiveWithDeferredDiskIndex(a *pvf.Archive) (uint64, context.Context, error) {
if c.annotationErr != nil {
return 0, nil, c.annotationErr
}
if c.renderingErr != nil {
return 0, nil, c.renderingErr
}
if a == nil || a.FileCount() < largeArchiveIndexThreshold {
return 0, nil, errors.New("异步磁盘索引仅适用于大型归档")
}
c.cancelPendingFileIndexBuild()
a.ClearMutations()
c.mu.Lock()
c.detachVersionLocked()
c.invalidateAdvancedSearchLocked()
if c.fileIndexCancel != nil {
c.fileIndexCancel()
c.fileIndexCancel = nil
}
if c.diskIndex != nil {
c.diskIndex.close()
c.diskIndex = nil
}
c.installDiskArchiveIndexesLocked(a)
ctx, cancel := context.WithCancel(context.Background())
c.fileIndexCancel = cancel
c.indexStatus = IndexStatus{State: IndexStateBuilding, Stage: "file-index", Total: int(a.FileCount())}
generation := c.archiveGeneration
c.mu.Unlock()
return generation, ctx, nil
}
func (c *core) cancelPendingFileIndexBuild() {
c.mu.RLock()
cancel := c.fileIndexCancel
c.mu.RUnlock()
if cancel != nil {
cancel()
}
}
func (c *core) buildInitialDiskIndexAsync(a *pvf.Archive, generation uint64, ctx context.Context) {
finishTask := c.archiveTasks.begin()
go func() {
taskFinished := false
defer func() {
if !taskFinished {
finishTask()
}
}()
startedAt := time.Now()
finishIndex := debugPhase("file-index.open-or-build", fmt.Sprintf("files=%d", a.FileCount()))
defer finishIndex()
c.mu.RLock()
debugLockAcquired("file-index.open-or-build", startedAt)
if c.archive != a || c.archiveGeneration != generation || ctx.Err() != nil {
c.mu.RUnlock()
return
}
finishRead := debugPhase("file-index.read-lock-held", "")
index, _, err := openSQLiteArchiveIndexContext(ctx, a)
c.mu.RUnlock()
finishRead()
c.mu.Lock()
if c.archive != a || c.archiveGeneration != generation || ctx.Err() != nil {
c.mu.Unlock()
if index != nil {
index.close()
}
return
}
c.fileIndexCancel = nil
if err != nil {
c.indexStatus = IndexStatus{
State: IndexStateError, Stage: "file-index", Error: err.Error(),
OpenDurationMs: c.indexStatus.OpenDurationMs,
BuildDurationMs: elapsedMilliseconds(startedAt),
}
status := c.indexStatus
c.mu.Unlock()
emitEvent("archive:index-error", status)
return
}
c.diskIndex = index
c.indexStatus = IndexStatus{
State: IndexStateBuilding, Stage: "sqlite",
OpenDurationMs: c.indexStatus.OpenDurationMs,
}
c.mu.Unlock()
emitEvent("archive:file-index-ready")
finishTask()
taskFinished = true
c.startSearchIndex()
}()
}
func (c *core) installDiskArchiveIndexesLocked(a *pvf.Archive) {
if c.indexCancel != nil {
c.indexCancel()
c.indexCancel = nil
}
if c.fileIndexCancel != nil {
c.fileIndexCancel()
c.fileIndexCancel = nil
}
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
c.markAdvancedSearchStaleLocked(a)
c.indexGen++
c.archiveGeneration++
if c.archiveDiff != nil && c.archive != a {
c.archiveDiff.Release()
}
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
c.bindRenderingEngineLocked(a)
c.archive = a
c.annotationEngine = c.annotationEngine.ForVersion(a.ClientVersion())
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.editorText = make(map[int32]string)
c.editorAnnotation = editorAnnotationCache{}
c.pathAnnotations = nil
c.dirChildren = nil
c.directories = nil
c.sortedPaths = nil
c.searchRecords = nil
c.searchByFile = nil
c.searchMetadata = nil
c.searchSpecFingerprint = ""
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
c.treeTagsByFile = nil
c.visualsByFile = nil
c.indexStartedAt = time.Time{}
c.indexDirty = make(map[int32]struct{})
c.indexStatus = IndexStatus{State: IndexStateIdle}
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle}
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
c.unpackCancel.Store(false)
c.unpackRunning.Store(false)
}
func (c *core) recordOpenDuration(duration time.Duration) {
c.mu.Lock()
if c.archive != nil {
c.indexStatus.OpenDurationMs = durationMilliseconds(duration)
}
c.mu.Unlock()
}
// replaceArchiveLocked installs a newly materialized archive while retaining
// the current version repository session. The caller must hold c.mu.
func (c *core) replaceArchiveLocked(a *pvf.Archive) error {
if c.diskIndex != nil && c.diskIndex.entries != nil {
return c.rebuildArchiveIndexesLocked(a)
}
oldArchive := c.archive
revision := c.batchRevision
c.batchRevision++
c.mu.Unlock()
prepared, err := prepareArchiveDerivedIndexes(a)
c.mu.Lock()
if err != nil {
closePreparedArchiveIndexes(prepared)
return err
}
if c.archive != oldArchive || c.batchRevision != revision+1 {
closePreparedArchiveIndexes(prepared)
// The replacement was materialized from the previous working snapshot;
// never install it over a concurrent mutation.
return ErrBatchPlanStale
}
return c.installPreparedArchiveIndexesLocked(a, prepared, true)
}
// replaceArchivePayloadLocked installs an archive whose path and data-type
// structure is unchanged. Reusing the existing tree/path indexes avoids a
// second full directory walk when checkout or discard only changes payloads.
// The caller must hold c.mu.
func (c *core) replaceArchivePayloadLocked(a *pvf.Archive, changedIndexes map[int32]struct{}) error {
if a == nil || c.archive == nil {
return ErrNoArchive
}
if c.indexCancel != nil {
c.indexCancel()
c.indexCancel = nil
}
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
c.markAdvancedSearchStaleLocked(a)
preserveSearch := c.indexStatus.State == IndexStateReady && c.searchRecords != nil
c.indexGen++
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
c.bindRenderingEngineLocked(a)
if c.archiveDiff != nil && c.archive != a {
c.archiveDiff.Release()
}
c.archive = a
previousAnnotations := c.annotationEngine
c.annotationEngine = c.annotationEngine.ForVersion(a.ClientVersion())
if c.annotationEngine != previousAnnotations {
c.pathAnnotations = buildPathAnnotations(c.annotationEngine, c.dirChildren)
}
if c.diskIndex != nil {
if err := c.diskIndex.refreshFileMetadata(a, changedIndexes); err != nil {
return err
}
} else {
refreshArchiveIndexMetadataLocked(c, changedIndexes)
}
if preserveSearch {
// Keep the old semantic snapshot until the mutation classifier proves
// that a registered record actually changed.
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
} else {
c.searchRecords = nil
c.searchByFile = make(map[int32][]int)
c.searchMetadata = nil
c.searchSpecFingerprint = ""
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
c.treeTagsByFile = make(map[int32][]TreeTag)
c.visualsByFile = make(map[int32]fileVisuals)
c.indexStatus = IndexStatus{State: IndexStateIdle}
}
c.indexStartedAt = time.Time{}
c.indexDirty = make(map[int32]struct{})
c.editorText = make(map[int32]string)
c.editorAnnotation = editorAnnotationCache{}
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle}
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
c.unpackCancel.Store(false)
c.unpackRunning.Store(false)
return nil
}
// rebuildArchiveIndexesLocked refreshes every derived view after the archive's
// file table changes. The caller must hold c.mu. SQLite also accepts a detached
// staged archive and installs it only after the projection transaction commits.
func (c *core) rebuildArchiveIndexesLocked(a *pvf.Archive) error {
if c.diskIndex != nil && c.diskIndex.entries != nil {
if err := c.diskIndex.applyArchiveDelta(a); err != nil {
// A live structural mutation cannot serve the old projection after
// rollback. Staged mutations leave the previous archive usable.
if a == c.archive {
c.failSQLiteProjectionLocked(err)
}
return err
}
c.installDiskArchiveIndexesLocked(a)
return nil
}
for {
if c.archive != a {
return ErrNoArchive
}
// A read lock protects the live archive from writers while still
// allowing resource-tree readers to run alongside the expensive build.
revision := c.batchRevision
c.batchRevision++
c.mu.Unlock()
c.mu.RLock()
prepared, err := prepareArchiveDerivedIndexes(a)
c.mu.RUnlock()
c.mu.Lock()
if err != nil {
closePreparedArchiveIndexes(prepared)
return err
}
if c.archive != a || c.batchRevision != revision+1 {
closePreparedArchiveIndexes(prepared)
continue
}
return c.installPreparedArchiveIndexesLocked(a, prepared, true)
}
}
type preparedArchiveIndexes struct {
children map[string][]*TreeNode
paths []pathEntry
disk *sqliteArchiveIndex
}
func prepareArchiveDerivedIndexes(a *pvf.Archive) (*preparedArchiveIndexes, error) {
if a == nil {
return nil, ErrNoArchive
}
if a.FileCount() >= largeArchiveIndexThreshold {
index, _, err := openSQLiteArchiveIndex(a)
if err != nil {
return nil, err
}
return &preparedArchiveIndexes{disk: index}, nil
}
children, paths, err := buildIndex(a)
if err != nil {
return nil, err
}
return &preparedArchiveIndexes{children: children, paths: paths}, nil
}
func closePreparedArchiveIndexes(prepared *preparedArchiveIndexes) {
if prepared != nil && prepared.disk != nil {
prepared.disk.close()
}
}
func (c *core) installPreparedArchiveIndexesLocked(a *pvf.Archive, prepared *preparedArchiveIndexes, preserveSearch bool) error {
if prepared == nil {
return ErrNoArchive
}
if prepared.disk != nil {
if c.diskIndex != nil && c.diskIndex != prepared.disk {
c.diskIndex.close()
}
c.diskIndex = prepared.disk
c.installDiskArchiveIndexesLocked(a)
return nil
}
if c.diskIndex != nil {
c.diskIndex.close()
c.diskIndex = nil
}
c.installArchiveIndexesLockedWithSearch(a, prepared.children, prepared.paths, preserveSearch)
return nil
}
// installArchiveIndexesLocked installs a complete set of derived indexes.
// The caller must hold c.mu.
func (c *core) installArchiveIndexesLocked(a *pvf.Archive, children map[string][]*TreeNode, paths []pathEntry) {
c.installArchiveIndexesLockedWithSearch(a, children, paths, false)
}
func (c *core) installArchiveIndexesPreservingSearchLocked(a *pvf.Archive, children map[string][]*TreeNode, paths []pathEntry) {
c.installArchiveIndexesLockedWithSearch(a, children, paths, true)
}
func (c *core) installArchiveIndexesLockedWithSearch(a *pvf.Archive, children map[string][]*TreeNode, paths []pathEntry, preserveSearch bool) {
if c.diskIndex != nil || a.FileCount() >= largeArchiveIndexThreshold {
if c.diskIndex != nil {
c.diskIndex.close()
c.diskIndex = nil
}
index, _, err := openSQLiteArchiveIndex(a)
if err != nil {
c.indexStatus = IndexStatus{State: IndexStateError, Stage: "sqlite", Error: err.Error()}
return
}
c.diskIndex = index
c.installDiskArchiveIndexesLocked(a)
return
}
preserveSearch = preserveSearch && c.indexStatus.State == IndexStateReady && c.searchRecords != nil
if c.indexCancel != nil {
c.indexCancel()
c.indexCancel = nil
}
if c.fileIndexCancel != nil {
c.fileIndexCancel()
c.fileIndexCancel = nil
}
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
c.markAdvancedSearchStaleLocked(a)
c.indexGen++
c.archiveGeneration++
if c.archiveDiff != nil && c.archive != a {
c.archiveDiff.Release()
}
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
directories := make([]string, 0, len(children))
for path := range children {
if path != "" {
directories = append(directories, path)
}
}
sort.Strings(directories)
c.bindRenderingEngineLocked(a)
c.archive = a
c.annotationEngine = c.annotationEngine.ForVersion(a.ClientVersion())
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.editorText = make(map[int32]string)
c.editorAnnotation = editorAnnotationCache{}
c.pathAnnotations = buildPathAnnotations(c.annotationEngine, children)
c.dirChildren = children
c.directories = directories
c.sortedPaths = paths
if preserveSearch {
// The old semantic snapshot remains readable until the caller starts a
// path-delta refresh. This keeps structural edits asynchronous.
c.searchIndexDeltaPending = true
c.searchIndexListPending = nil
} else {
c.searchRecords = nil
c.searchByFile = make(map[int32][]int)
c.searchMetadata = nil
c.searchSpecFingerprint = ""
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
c.treeTagsByFile = make(map[int32][]TreeTag)
c.visualsByFile = make(map[int32]fileVisuals)
c.indexStatus = IndexStatus{State: IndexStateIdle}
}
c.indexStartedAt = time.Time{}
c.indexDirty = make(map[int32]struct{})
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle}
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
c.unpackCancel.Store(false)
c.unpackRunning.Store(false)
}
// bindRenderingEngineLocked applies the current user-facing renderer to an
// archive that is about to become active. The caller must hold c.mu.
func (c *core) bindRenderingEngineLocked(a *pvf.Archive) {
if a == nil {
return
}
a.SetScriptRenderer(c.renderingEngine)
a.SetStringTableIndexObserver(func(event pvf.StringTableIndexEvent) {
emitEvent("archive:string-table-index", event)
})
}
func (c *core) closeArchive() {
c.mu.Lock()
c.mcpWorkspaceID = ""
c.mcpEpoch++
c.mcpSourceHash = ""
c.mcpScriptPlan = ""
c.mcpImportPlan = ""
c.mu.Unlock()
defer requestCacheMaintenance()
waitFor := c.archiveTasks.beginClose()
c.mu.RLock()
fileIndexCancel := c.fileIndexCancel
c.mu.RUnlock()
if fileIndexCancel != nil {
fileIndexCancel()
}
archives := make(map[*pvf.Archive]struct{})
addArchive := func(a *pvf.Archive) {
if a != nil {
archives[a] = struct{}{}
}
}
var diskIndex *sqliteArchiveIndex
var advancedDisk *advancedSQLite
c.mu.Lock()
addArchive(c.archive)
addArchive(c.versionBaseArchive)
if c.batchPlan != nil {
addArchive(c.batchPlan.archive)
addArchive(c.batchPlan.staged)
}
if c.scriptPlan != nil {
addArchive(c.scriptPlan.archive)
}
c.detachVersionLocked()
if c.indexCancel != nil {
c.indexCancel()
c.indexCancel = nil
}
if c.fileIndexCancel != nil {
c.fileIndexCancel()
c.fileIndexCancel = nil
}
advancedDisk = c.detachAdvancedSearchLocked()
c.indexGen++
c.archiveGeneration++
if c.archiveDiff != nil {
c.archiveDiff.Release()
}
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
c.archive = nil
if c.diskIndex != nil {
diskIndex = c.diskIndex
c.diskIndex = nil
}
c.annotationRelations = nil
c.editorText = nil
c.editorAnnotation = editorAnnotationCache{}
c.pathAnnotations = nil
c.dirChildren = nil
c.directories = nil
c.sortedPaths = nil
c.searchRecords = nil
c.searchByFile = nil
c.searchMetadata = nil
c.searchSpecFingerprint = ""
c.searchIndexDeltaPending = false
c.searchIndexListPending = nil
c.treeTagsByFile = nil
c.visualsByFile = nil
c.indexStatus = IndexStatus{State: IndexStateIdle}
c.indexStartedAt = time.Time{}
c.indexDirty = nil
c.indexRefreshPending = false
c.indexRefreshPendingForce = false
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle}
c.binaryCache = nil
c.unpackCancel.Store(true)
c.unpackRunning.Store(false)
c.mu.Unlock()
for _, done := range waitFor {
<-done
}
if diskIndex != nil {
diskIndex.close()
}
if advancedDisk != nil {
advancedDisk.close()
}
released := int64(0)
for a := range archives {
released += a.Release()
}
if released >= 8<<20 {
runtime.GC()
debug.FreeOSMemory()
}
c.archiveTasks.endClose()
}
// withArchive runs fn with the loaded archive under read lock.
func (c *core) withArchive(fn func(a *pvf.Archive) error) error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.archive == nil {
return ErrNoArchive
}
return fn(c.archive)
}
// withArchiveWrite runs fn with the loaded archive under the write lock.
// Archive edits and saves must exclude background index reads because the
// archive overlay and rebuilt tables are mutable.
func (c *core) withArchiveWrite(fn func(a *pvf.Archive) error) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.archive == nil {
return ErrNoArchive
}
return fn(c.archive)
}
func (c *core) archiveInfo() (pvf.ArchiveInfoView, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.archive == nil {
return pvf.ArchiveInfoView{}, false
}
return c.archive.Info(), true
}
// buildIndex creates the lazy-tree directory index and the search path list.
// Duplicate paths are preserved (each maps to its own file index).
func buildIndex(a *pvf.Archive) (map[string][]*TreeNode, []pathEntry, error) {
n := a.FileCount()
dirChildren := make(map[string][]*TreeNode)
dirSet := make(map[string]bool)
paths := make([]pathEntry, 0, n)
// registerDir adds p and all missing ancestors as directory nodes.
// Invariant: registering p also registers every ancestor, so hitting an
// already-registered dir means the whole ancestor chain is present.
registerDir := func(p string) {
for p != "" {
if dirSet[p] {
return
}
dirSet[p] = true
parent, name := splitParent(p)
dirChildren[parent] = append(dirChildren[parent], &TreeNode{
Name: name, Path: p, IsDir: true, FileIndex: -1,
})
p = parent
}
}
for i := int32(0); i < n; i++ {
p := a.Path(i)
if p == "" {
continue
}
f := a.File(i)
changeKind := archiveChangeKind(a, i)
paths = append(paths, pathEntry{
path: p,
lower: strings.ToLower(p),
idx: i,
size: f.DataSize,
typ: f.DataType,
changeKind: changeKind,
})
parent, name := splitParent(p)
dirChildren[parent] = append(dirChildren[parent], &TreeNode{
Name: name, Path: p, IsDir: false,
Size: f.DataSize, DataType: f.DataType, FileIndex: i,
ChangeKind: changeKind,
})
registerDir(parent)
}
for _, list := range dirChildren {
sort.Slice(list, func(x, y int) bool {
if list[x].IsDir != list[y].IsDir {
return list[x].IsDir
}
return list[x].Name < list[y].Name
})
for _, t := range list {
if t.IsDir {
t.ChildCount = int32(len(dirChildren[t.Path]))
}
}
}
sort.Slice(paths, func(x, y int) bool { return paths[x].path < paths[y].path })
return dirChildren, paths, nil
}
func splitParent(p string) (parent, name string) {
if slash := strings.LastIndexByte(p, '/'); slash >= 0 {
return p[:slash], p[slash+1:]
}
return "", p
}