DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user