服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1776 lines
51 KiB
Go
1776 lines
51 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"pvfine/internal/pvf"
|
|
pvfversion "pvfine/internal/version"
|
|
)
|
|
|
|
var (
|
|
ErrVersionNotEnabled = errors.New("当前归档尚未初始化版本库")
|
|
ErrVersionLoading = errors.New("版本控制正在后台加载,请稍候")
|
|
ErrVersionArtifactChanged = errors.New("版本库关联的 PVF 产物已被外部修改,请先处理后再继续")
|
|
)
|
|
|
|
const (
|
|
VersionOperationAdd = string(pvfversion.OperationAdd)
|
|
VersionOperationModify = string(pvfversion.OperationModify)
|
|
VersionOperationDelete = string(pvfversion.OperationDelete)
|
|
)
|
|
|
|
// VersionService exposes local logical-file version control to the Wails
|
|
// frontend.
|
|
type VersionService struct{ c *core }
|
|
|
|
func NewVersionService(c *core) *VersionService { return &VersionService{c: c} }
|
|
|
|
// VersionStatus is the UI-facing version session state.
|
|
type VersionStatus struct {
|
|
Enabled bool `json:"enabled"`
|
|
Loading bool `json:"loading"`
|
|
RepositoryPath string `json:"repositoryPath"`
|
|
Branch string `json:"branch"`
|
|
HeadID string `json:"headId"`
|
|
HeadMessage string `json:"headMessage"`
|
|
ChangedFiles int `json:"changedFiles"`
|
|
PendingChangeSets int `json:"pendingChangeSets"`
|
|
NeedsSave bool `json:"needsSave"`
|
|
ViewCommitID string `json:"viewCommitId"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// VersionChange is a path-level worktree or commit change.
|
|
type VersionChange struct {
|
|
Path string `json:"path"`
|
|
Operation string `json:"operation"`
|
|
BeforeHash string `json:"beforeHash,omitempty"`
|
|
AfterHash string `json:"afterHash,omitempty"`
|
|
BeforeType int32 `json:"beforeType,omitempty"`
|
|
AfterType int32 `json:"afterType,omitempty"`
|
|
}
|
|
|
|
type VersionChangePage struct {
|
|
Changes []*VersionChange `json:"changes"`
|
|
NextCursor int `json:"nextCursor"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type VersionCommit struct {
|
|
ID string `json:"id"`
|
|
ParentID string `json:"parentId"`
|
|
Message string `json:"message"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
ChangeCount int `json:"changeCount"`
|
|
}
|
|
|
|
type VersionHistoryPage struct {
|
|
Commits []*VersionCommit `json:"commits"`
|
|
NextCursor int `json:"nextCursor"`
|
|
}
|
|
|
|
// VersionFileDiff contains one commit change and its logical text values
|
|
// when the underlying PVF type is editable.
|
|
type VersionFileDiff struct {
|
|
Path string `json:"path"`
|
|
Operation string `json:"operation"`
|
|
BeforeHash string `json:"beforeHash,omitempty"`
|
|
AfterHash string `json:"afterHash,omitempty"`
|
|
BeforeType int32 `json:"beforeType,omitempty"`
|
|
AfterType int32 `json:"afterType,omitempty"`
|
|
BeforeText string `json:"beforeText,omitempty"`
|
|
AfterText string `json:"afterText,omitempty"`
|
|
TextAvailable bool `json:"textAvailable"`
|
|
}
|
|
|
|
type versionUndoRecord struct {
|
|
Label string
|
|
Before pvfversion.ContentSnapshot
|
|
After pvfversion.ContentSnapshot
|
|
}
|
|
|
|
// detachVersionLocked closes the repository session. The caller must hold
|
|
// c.mu; it is called when an archive is replaced or closed.
|
|
func (c *core) detachVersionLocked() {
|
|
c.versionLoadID++
|
|
c.versionLoading = false
|
|
c.versionLoadError = ""
|
|
if c.versionRepo != nil {
|
|
_ = c.versionRepo.Close()
|
|
}
|
|
c.versionRepo = nil
|
|
c.versionHead = pvfversion.Commit{}
|
|
c.versionHeadSnapshot = nil
|
|
c.versionWorking = nil
|
|
c.versionChanges = nil
|
|
c.versionChangeMap = nil
|
|
c.versionUndo = nil
|
|
c.versionSavedSnapshot = nil
|
|
c.versionArtifactDirty = nil
|
|
c.versionSavedTree = ""
|
|
c.versionSavedPVF = ""
|
|
c.versionSavedCommit = ""
|
|
c.versionViewCommit = ""
|
|
c.versionBaseArchive = nil
|
|
}
|
|
|
|
type preparedVersionSession struct {
|
|
archive *pvf.Archive
|
|
repo *pvfversion.Repository
|
|
head pvfversion.Commit
|
|
headSnapshot pvfversion.Snapshot
|
|
working pvfversion.Snapshot
|
|
savedSnapshot pvfversion.Snapshot
|
|
meta pvfversion.Meta
|
|
baseArchive *pvf.Archive
|
|
changes []pvfversion.FileChange
|
|
changeMap map[string]pvfversion.FileChange
|
|
artifactDirty map[string]struct{}
|
|
}
|
|
|
|
// attachVersionSessionLocked installs a fully prepared version session. All
|
|
// repository IO, PVF parsing and snapshot diffing must happen before entering
|
|
// this method so opening an archive does not hold c.mu during the slow work.
|
|
func (c *core) attachVersionSessionLocked(session *preparedVersionSession) error {
|
|
if session == nil || session.repo == nil {
|
|
return ErrVersionNotEnabled
|
|
}
|
|
if session.working == nil {
|
|
return errors.New("版本工作区快照为空")
|
|
}
|
|
c.versionRepo = session.repo
|
|
c.versionHead = session.head
|
|
// These snapshots and derived maps are detached values prepared by the
|
|
// background loader; attaching only swaps pointers under the short lock.
|
|
c.versionHeadSnapshot = session.headSnapshot
|
|
c.versionWorking = session.working
|
|
c.versionSavedSnapshot = session.savedSnapshot
|
|
c.versionChanges = session.changes
|
|
c.versionChangeMap = session.changeMap
|
|
c.versionArtifactDirty = session.artifactDirty
|
|
c.versionUndo = nil
|
|
c.versionSavedTree = session.meta.Artifact.TreeHash
|
|
c.versionSavedPVF = session.meta.Artifact.PVFHash
|
|
c.versionSavedCommit = session.meta.Artifact.CommitID
|
|
c.versionViewCommit = ""
|
|
c.versionBaseArchive = session.baseArchive
|
|
return nil
|
|
}
|
|
|
|
func (c *core) currentVersionChangesLocked() []pvfversion.FileChange {
|
|
if c.versionRepo == nil {
|
|
return nil
|
|
}
|
|
return append([]pvfversion.FileChange(nil), c.versionChanges...)
|
|
}
|
|
|
|
func (c *core) versionStatusLocked() VersionStatus {
|
|
status := VersionStatus{Branch: "main", Loading: c.versionLoading, Error: c.versionLoadError}
|
|
if c.versionRepo == nil {
|
|
return status
|
|
}
|
|
status.Enabled = true
|
|
status.RepositoryPath = c.versionRepo.Root()
|
|
status.HeadID = c.versionHead.ID
|
|
status.HeadMessage = c.versionHead.Message
|
|
status.ChangedFiles = len(c.versionChanges)
|
|
status.PendingChangeSets = len(c.versionUndo)
|
|
status.ViewCommitID = c.versionViewCommit
|
|
status.NeedsSave = len(c.versionArtifactDirty) > 0
|
|
status.Error = ""
|
|
return status
|
|
}
|
|
|
|
func (c *core) ensureVersionReadyLocked() error {
|
|
if c.versionLoading {
|
|
return ErrVersionLoading
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameVersionEntry(left, right pvfversion.Entry) bool {
|
|
return left.DataType == right.DataType && left.Hash == right.Hash
|
|
}
|
|
|
|
func versionChangeForPath(before, after pvfversion.Snapshot, key string) (pvfversion.FileChange, bool) {
|
|
oldEntry, hadOld := before[key]
|
|
newEntry, hasNew := after[key]
|
|
switch {
|
|
case !hadOld && hasNew:
|
|
return pvfversion.FileChange{
|
|
Path: key, DisplayPath: newEntry.Path,
|
|
Operation: pvfversion.OperationAdd,
|
|
AfterHash: newEntry.Hash, AfterType: newEntry.DataType,
|
|
}, true
|
|
case hadOld && !hasNew:
|
|
return pvfversion.FileChange{
|
|
Path: key, DisplayPath: oldEntry.Path,
|
|
Operation: pvfversion.OperationDelete,
|
|
BeforeHash: oldEntry.Hash, BeforeType: oldEntry.DataType,
|
|
}, true
|
|
case hadOld && hasNew && !sameVersionEntry(oldEntry, newEntry):
|
|
return pvfversion.FileChange{
|
|
Path: key, DisplayPath: newEntry.Path,
|
|
Operation: pvfversion.OperationModify,
|
|
BeforeHash: oldEntry.Hash, AfterHash: newEntry.Hash,
|
|
BeforeType: oldEntry.DataType, AfterType: newEntry.DataType,
|
|
}, true
|
|
default:
|
|
return pvfversion.FileChange{}, false
|
|
}
|
|
}
|
|
|
|
func (c *core) rebuildVersionChangesLocked() {
|
|
if c.versionRepo == nil {
|
|
c.versionChanges = nil
|
|
c.versionChangeMap = nil
|
|
return
|
|
}
|
|
c.versionChanges, c.versionChangeMap = versionChangesForSnapshots(c.versionHeadSnapshot, c.versionWorking)
|
|
}
|
|
|
|
func versionChangesForSnapshots(head, working pvfversion.Snapshot) ([]pvfversion.FileChange, map[string]pvfversion.FileChange) {
|
|
changes := pvfversion.Diff(head, working)
|
|
changeMap := make(map[string]pvfversion.FileChange, len(changes))
|
|
for _, change := range changes {
|
|
changeMap[change.Path] = change
|
|
}
|
|
return changes, changeMap
|
|
}
|
|
|
|
func (c *core) refreshVersionChangeLocked(key string) {
|
|
if c.versionChangeMap == nil {
|
|
c.versionChangeMap = make(map[string]pvfversion.FileChange)
|
|
}
|
|
if change, ok := versionChangeForPath(c.versionHeadSnapshot, c.versionWorking, key); ok {
|
|
c.versionChangeMap[key] = change
|
|
} else {
|
|
delete(c.versionChangeMap, key)
|
|
}
|
|
}
|
|
|
|
func (c *core) sortVersionChangesLocked() {
|
|
c.versionChanges = c.versionChanges[:0]
|
|
for _, change := range c.versionChangeMap {
|
|
c.versionChanges = append(c.versionChanges, change)
|
|
}
|
|
sort.Slice(c.versionChanges, func(left, right int) bool {
|
|
return c.versionChanges[left].Path < c.versionChanges[right].Path
|
|
})
|
|
}
|
|
|
|
func (c *core) rebuildVersionArtifactDirtyLocked() {
|
|
if c.versionRepo == nil {
|
|
c.versionArtifactDirty = nil
|
|
return
|
|
}
|
|
c.versionArtifactDirty = versionArtifactDirtyForSnapshots(c.versionSavedSnapshot, c.versionWorking)
|
|
}
|
|
|
|
func versionArtifactDirtyForSnapshots(saved, working pvfversion.Snapshot) map[string]struct{} {
|
|
dirty := make(map[string]struct{})
|
|
keys := make(map[string]struct{}, len(saved)+len(working))
|
|
for key := range saved {
|
|
keys[key] = struct{}{}
|
|
}
|
|
for key := range working {
|
|
keys[key] = struct{}{}
|
|
}
|
|
for key := range keys {
|
|
savedEntry, hasSaved := saved[key]
|
|
workingEntry, hasWorking := working[key]
|
|
if hasSaved != hasWorking || (hasSaved && !sameVersionEntry(savedEntry, workingEntry)) {
|
|
dirty[key] = struct{}{}
|
|
}
|
|
}
|
|
return dirty
|
|
}
|
|
|
|
func (c *core) refreshVersionArtifactDirtyLocked(key string) {
|
|
if c.versionArtifactDirty == nil {
|
|
c.versionArtifactDirty = make(map[string]struct{})
|
|
}
|
|
saved, hasSaved := c.versionSavedSnapshot[key]
|
|
working, hasWorking := c.versionWorking[key]
|
|
if hasSaved != hasWorking || (hasSaved && !sameVersionEntry(saved, working)) {
|
|
c.versionArtifactDirty[key] = struct{}{}
|
|
} else {
|
|
delete(c.versionArtifactDirty, key)
|
|
}
|
|
}
|
|
|
|
func sameVersionStructure(before, after pvfversion.Snapshot) bool {
|
|
if len(before) != len(after) {
|
|
return false
|
|
}
|
|
for key, oldEntry := range before {
|
|
newEntry, ok := after[key]
|
|
if !ok || oldEntry.DataType != newEntry.DataType {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func changedVersionIndexesForPaths(c *core, paths []string) map[int32]struct{} {
|
|
indexes := make(map[int32]struct{}, len(paths))
|
|
for _, path := range paths {
|
|
if index, exists := c.archive.Find(path); exists {
|
|
indexes[index] = struct{}{}
|
|
}
|
|
}
|
|
return indexes
|
|
}
|
|
|
|
func payloadOnlyForPaths(before, after pvfversion.Snapshot, paths []string) bool {
|
|
for _, rawPath := range paths {
|
|
key := pvfversion.CanonicalPath(rawPath)
|
|
oldEntry, hadOld := before[key]
|
|
newEntry, hasNew := after[key]
|
|
if !hadOld || !hasNew || oldEntry.DataType != newEntry.DataType {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *VersionService) materializePathsLocked(viewCommit string) ([]string, error) {
|
|
paths := make(map[string]struct{}, len(s.c.versionChanges))
|
|
for _, change := range s.c.versionChanges {
|
|
paths[change.Path] = struct{}{}
|
|
}
|
|
if viewCommit != "" {
|
|
fromCommit := s.c.versionViewCommit
|
|
if fromCommit == "" {
|
|
fromCommit = s.c.versionHead.ID
|
|
}
|
|
if fromCommit != viewCommit {
|
|
changed, err := s.c.versionRepo.ChangedPathsBetween(fromCommit, viewCommit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, path := range changed {
|
|
paths[path] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
result := make([]string, 0, len(paths))
|
|
for path := range paths {
|
|
result = append(result, path)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func (c *core) markVersionArtifactSavedLocked(path string) error {
|
|
if c.versionRepo == nil {
|
|
return nil
|
|
}
|
|
pvfHash := ""
|
|
if c.archive != nil {
|
|
pvfHash = c.archive.SourceHash()
|
|
}
|
|
if pvfHash == "" {
|
|
var err error
|
|
pvfHash, err = pvfversion.HashFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("计算已保存 PVF 的 hash 失败: %w", err)
|
|
}
|
|
}
|
|
treeHash := pvfversion.TreeHash(c.versionWorking)
|
|
state := pvfversion.ArtifactState{PVFHash: pvfHash, TreeHash: treeHash, CommitID: c.versionHead.ID}
|
|
if err := c.versionRepo.SetArtifactState(state); err != nil {
|
|
return fmt.Errorf("更新版本库产物状态失败: %w", err)
|
|
}
|
|
c.versionSavedSnapshot = pvfversion.CloneSnapshot(c.versionWorking)
|
|
c.versionArtifactDirty = make(map[string]struct{})
|
|
c.versionSavedPVF = pvfHash
|
|
c.versionSavedTree = treeHash
|
|
c.versionSavedCommit = c.versionHead.ID
|
|
return nil
|
|
}
|
|
|
|
// recordVersionMutationLocked records one atomic mutation for the worktree
|
|
// undo stack and updates the current logical snapshot. The caller must hold
|
|
// c.mu and must already have applied the mutation to c.archive.
|
|
func (c *core) recordVersionMutationLocked(label string, before, after pvfversion.ContentSnapshot) error {
|
|
if c.versionRepo == nil {
|
|
return nil
|
|
}
|
|
beforeSnapshot := pvfversion.SnapshotFromContent(before)
|
|
afterSnapshot := pvfversion.SnapshotFromContent(after)
|
|
keys := make(map[string]struct{}, len(beforeSnapshot)+len(afterSnapshot))
|
|
for key := range beforeSnapshot {
|
|
keys[key] = struct{}{}
|
|
}
|
|
for key := range afterSnapshot {
|
|
keys[key] = struct{}{}
|
|
}
|
|
changed := false
|
|
for key := range keys {
|
|
beforeEntry, hasBefore := beforeSnapshot[key]
|
|
afterEntry, hasAfter := afterSnapshot[key]
|
|
if hasBefore != hasAfter || (hasBefore && !sameVersionEntry(beforeEntry, afterEntry)) {
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
c.versionUndo = append(c.versionUndo, versionUndoRecord{
|
|
Label: label,
|
|
Before: pvfversion.CloneContentSnapshot(before),
|
|
After: pvfversion.CloneContentSnapshot(after),
|
|
})
|
|
if c.versionWorking == nil {
|
|
c.versionWorking = make(pvfversion.Snapshot)
|
|
}
|
|
for key := range beforeSnapshot {
|
|
if _, exists := afterSnapshot[key]; !exists {
|
|
delete(c.versionWorking, key)
|
|
}
|
|
}
|
|
for key, entry := range afterSnapshot {
|
|
c.versionWorking[key] = entry
|
|
}
|
|
for key := range keys {
|
|
c.refreshVersionChangeLocked(key)
|
|
c.refreshVersionArtifactDirtyLocked(key)
|
|
}
|
|
c.sortVersionChangesLocked()
|
|
return nil
|
|
}
|
|
|
|
// Initialize creates the sidecar repository for the currently opened PVF.
|
|
func (s *VersionService) Initialize() (*VersionStatus, error) {
|
|
finishTask := s.c.archiveTasks.begin()
|
|
defer finishTask()
|
|
s.c.mu.RLock()
|
|
a := s.c.archive
|
|
if a == nil {
|
|
s.c.mu.RUnlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
if s.c.versionLoading {
|
|
s.c.mu.RUnlock()
|
|
return nil, ErrVersionLoading
|
|
}
|
|
if s.c.versionRepo != nil {
|
|
status := s.c.versionStatusLocked()
|
|
s.c.mu.RUnlock()
|
|
return &status, nil
|
|
}
|
|
if a.SourcePath() == "" {
|
|
s.c.mu.RUnlock()
|
|
return nil, errors.New("归档没有源文件,请先保存 PVF")
|
|
}
|
|
if a.Modified() {
|
|
s.c.mu.RUnlock()
|
|
return nil, errors.New("初始化版本库前请先保存或放弃当前修改")
|
|
}
|
|
working, err := pvfversion.SnapshotFromArchive(a)
|
|
if err != nil {
|
|
s.c.mu.RUnlock()
|
|
return nil, err
|
|
}
|
|
path := a.SourcePath()
|
|
archiveHash := a.SourceHash()
|
|
revision := s.c.batchRevision
|
|
s.c.mu.RUnlock()
|
|
|
|
baseHash, err := pvfversion.HashFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取 PVF 基线失败: %w", err)
|
|
}
|
|
if archiveHash != "" && archiveHash != baseHash {
|
|
return nil, ErrVersionArtifactChanged
|
|
}
|
|
repo, err := pvfversion.Init(pvfversion.SidecarPath(path), path, baseHash, working)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
head, headSnapshot, meta, err := loadVersionRepositoryState(repo)
|
|
if err != nil {
|
|
_ = repo.Close()
|
|
return nil, err
|
|
}
|
|
baseArchive, err := pvf.Open(repo.BasePath())
|
|
if err != nil {
|
|
_ = repo.Close()
|
|
return nil, fmt.Errorf("打开版本基线失败: %w", err)
|
|
}
|
|
session, err := buildPreparedVersionSession(repo, a, head, headSnapshot, meta, working, baseArchive)
|
|
if err != nil {
|
|
_ = repo.Close()
|
|
return nil, err
|
|
}
|
|
|
|
s.c.mu.Lock()
|
|
if s.c.archive != a || s.c.batchRevision != revision || s.c.versionRepo != nil {
|
|
s.c.mu.Unlock()
|
|
_ = repo.Close()
|
|
return nil, errors.New("初始化版本库期间归档状态已变化,请重试")
|
|
}
|
|
if err := s.c.attachVersionSessionLocked(session); err != nil {
|
|
s.c.mu.Unlock()
|
|
_ = repo.Close()
|
|
return nil, err
|
|
}
|
|
status := s.c.versionStatusLocked()
|
|
s.c.mu.Unlock()
|
|
s.emitVersionChanged("initialized")
|
|
return &status, nil
|
|
}
|
|
|
|
// Status returns the current version session without modifying disk state.
|
|
func (s *VersionService) Status() VersionStatus {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
return s.c.versionStatusLocked()
|
|
}
|
|
|
|
// ListChanges lists the current working-tree changes relative to HEAD.
|
|
func (s *VersionService) ListChanges(cursor, limit int) (*VersionChangePage, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
changes := s.c.versionChanges
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
if cursor >= len(changes) {
|
|
return &VersionChangePage{Changes: []*VersionChange{}, NextCursor: -1, Total: len(changes)}, nil
|
|
}
|
|
end := cursor + limit
|
|
if end > len(changes) {
|
|
end = len(changes)
|
|
}
|
|
result := &VersionChangePage{Changes: make([]*VersionChange, 0, end-cursor), Total: len(changes), NextCursor: -1}
|
|
for _, change := range changes[cursor:end] {
|
|
result.Changes = append(result.Changes, versionChangeDTO(change))
|
|
}
|
|
if end < len(changes) {
|
|
result.NextCursor = end
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Commit freezes the current working tree as one commit without saving the
|
|
// packed PVF artifact.
|
|
func (s *VersionService) Commit(message string) (*VersionCommit, error) {
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if s.c.archive == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
working := s.c.versionWorking
|
|
changes := s.c.currentVersionChangesLocked()
|
|
if len(changes) == 0 {
|
|
s.c.mu.Unlock()
|
|
return nil, errors.New("没有可提交的变更")
|
|
}
|
|
objects := make(map[string][]byte)
|
|
for _, change := range changes {
|
|
if change.AfterHash == "" {
|
|
continue
|
|
}
|
|
index, ok := s.c.archive.Find(change.Path)
|
|
if !ok {
|
|
s.c.mu.Unlock()
|
|
return nil, fmt.Errorf("提交文件不存在: %s", change.DisplayPath)
|
|
}
|
|
content, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{change.Path})
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
value, ok := content[pvfversion.CanonicalPath(change.Path)]
|
|
if !ok || value.Hash != change.AfterHash || index < 0 {
|
|
s.c.mu.Unlock()
|
|
return nil, fmt.Errorf("提交内容已变化: %s", change.DisplayPath)
|
|
}
|
|
objects[change.AfterHash] = pvfversion.ContentObject(value)
|
|
}
|
|
commit, err := s.c.versionRepo.Commit(message, changes, objects)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
s.c.versionHead = commit
|
|
s.c.versionHeadSnapshot = pvfversion.CloneSnapshot(working)
|
|
// Keep the existing working snapshot. The new HEAD snapshot above is the
|
|
// detached copy that protects it from subsequent in-memory edits.
|
|
s.c.versionWorking = working
|
|
s.c.versionChanges = nil
|
|
s.c.versionChangeMap = make(map[string]pvfversion.FileChange)
|
|
s.c.versionUndo = nil
|
|
s.c.versionViewCommit = ""
|
|
result := versionCommitDTO(commit)
|
|
s.c.mu.Unlock()
|
|
s.emitVersionChanged("committed")
|
|
emitEvent("version:committed", result)
|
|
return result, nil
|
|
}
|
|
|
|
// History returns main-branch commits from newest to oldest.
|
|
func (s *VersionService) History(cursor, limit int) (*VersionHistoryPage, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
commits, next, err := s.c.versionRepo.History(cursor, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &VersionHistoryPage{Commits: make([]*VersionCommit, 0, len(commits)), NextCursor: next}
|
|
for _, commit := range commits {
|
|
result.Commits = append(result.Commits, versionCommitDTO(commit))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListCommitChanges lists the compact path changes belonging to one commit.
|
|
func (s *VersionService) ListCommitChanges(commitID string, cursor, limit int) (*VersionChangePage, error) {
|
|
commitID = strings.TrimSpace(commitID)
|
|
if commitID == "" {
|
|
return nil, errors.New("提交 ID 不能为空")
|
|
}
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if _, err := s.c.versionRepo.GetCommit(commitID); err != nil {
|
|
return nil, err
|
|
}
|
|
changes, err := s.c.versionRepo.Changes(commitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
if cursor >= len(changes) {
|
|
return &VersionChangePage{Changes: []*VersionChange{}, NextCursor: -1, Total: len(changes)}, nil
|
|
}
|
|
end := cursor + limit
|
|
if end > len(changes) {
|
|
end = len(changes)
|
|
}
|
|
result := &VersionChangePage{Changes: make([]*VersionChange, 0, end-cursor), NextCursor: -1, Total: len(changes)}
|
|
for _, change := range changes[cursor:end] {
|
|
result.Changes = append(result.Changes, versionChangeDTO(change))
|
|
}
|
|
if end < len(changes) {
|
|
result.NextCursor = end
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Remove disables version control for the current artifact and deletes only
|
|
// its sidecar repository. The loaded PVF and its in-memory edits are kept.
|
|
func (s *VersionService) Remove() (*VersionStatus, error) {
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if s.c.archive == nil || s.c.archive.SourcePath() == "" {
|
|
s.c.mu.Unlock()
|
|
return nil, errors.New("当前归档没有可关联的源文件")
|
|
}
|
|
repositoryRoot := filepath.Clean(s.c.versionRepo.Root())
|
|
expectedRoot := filepath.Clean(pvfversion.SidecarPath(s.c.archive.SourcePath()))
|
|
if repositoryRoot != expectedRoot || filepath.Base(repositoryRoot) == "." || filepath.Base(repositoryRoot) == string(filepath.Separator) || !strings.HasSuffix(repositoryRoot, ".pvfine") {
|
|
s.c.mu.Unlock()
|
|
return nil, errors.New("版本库路径校验失败,为安全起见未删除任何文件")
|
|
}
|
|
if info, err := os.Lstat(repositoryRoot); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
s.c.mu.Unlock()
|
|
return nil, errors.New("版本库目录是符号链接,为安全起见未删除任何文件")
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
s.c.detachVersionLocked()
|
|
removeErr := os.RemoveAll(repositoryRoot)
|
|
status := s.c.versionStatusLocked()
|
|
s.c.mu.Unlock()
|
|
|
|
s.emitVersionChanged("disabled")
|
|
if removeErr != nil {
|
|
return &status, fmt.Errorf("取消版本控制失败,部分版本文件可能仍然存在: %w", removeErr)
|
|
}
|
|
return &status, nil
|
|
}
|
|
|
|
// ExportCommitFilesDialog exports the files touched by one commit. Add/modify
|
|
// entries use their after-state; deleted entries use their before-state so the
|
|
// exported directory represents every file involved in that version.
|
|
func (s *VersionService) ExportCommitFilesDialog(commitID string) (string, error) {
|
|
commitID = strings.TrimSpace(commitID)
|
|
if commitID == "" {
|
|
return "", errors.New("提交 ID 不能为空")
|
|
}
|
|
s.c.mu.RLock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.RUnlock()
|
|
return "", err
|
|
}
|
|
repo := s.c.versionRepo
|
|
if repo == nil {
|
|
s.c.mu.RUnlock()
|
|
return "", ErrVersionNotEnabled
|
|
}
|
|
if _, err := repo.GetCommit(commitID); err != nil {
|
|
s.c.mu.RUnlock()
|
|
return "", err
|
|
}
|
|
changes, err := repo.Changes(commitID)
|
|
s.c.mu.RUnlock()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(changes) == 0 {
|
|
return "", errors.New("该版本没有可导出的文件")
|
|
}
|
|
|
|
dir, err := application.Get().Dialog.OpenFile().
|
|
CanChooseFiles(false).
|
|
CanChooseDirectories(true).
|
|
CanCreateDirectories(true).
|
|
SetTitle("选择版本文件导出目录").
|
|
PromptForSingleSelection()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if dir == "" {
|
|
return "", nil
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.versionRepo != repo {
|
|
return "", ErrVersionNotEnabled
|
|
}
|
|
base := s.c.versionBaseArchive
|
|
if base == nil {
|
|
base, err = pvf.Open(repo.BasePath())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
seenDestinations := make(map[string]struct{}, len(changes))
|
|
exported := 0
|
|
for _, change := range changes {
|
|
hash := change.AfterHash
|
|
dataType := change.AfterType
|
|
path := change.DisplayPath
|
|
if hash == "" {
|
|
hash = change.BeforeHash
|
|
dataType = change.BeforeType
|
|
if path == "" {
|
|
path = change.OldDisplayPath
|
|
}
|
|
}
|
|
if hash == "" || path == "" {
|
|
continue
|
|
}
|
|
content, err := readVersionContent(repo, base, pvfversion.Entry{
|
|
Path: path, DataType: dataType, Hash: hash,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取版本文件 %q 失败: %w", path, err)
|
|
}
|
|
destination, err := safeExportPath(dir, path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, exists := seenDestinations[destination]; exists {
|
|
return "", fmt.Errorf("版本文件导出路径冲突: %s", path)
|
|
}
|
|
seenDestinations[destination] = struct{}{}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(destination, content.Raw, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
exported++
|
|
}
|
|
if exported == 0 {
|
|
return "", errors.New("该版本没有可导出的文件")
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
// Undo reverses the most recent uncommitted mutation as one atomic operation.
|
|
func (s *VersionService) Undo() (*VersionStatus, error) {
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if len(s.c.versionUndo) == 0 {
|
|
s.c.mu.Unlock()
|
|
return nil, errors.New("没有可撤销的版本变更")
|
|
}
|
|
record := s.c.versionUndo[len(s.c.versionUndo)-1]
|
|
paths := make([]string, 0, len(record.Before)+len(record.After))
|
|
seenPaths := make(map[string]struct{}, len(record.Before)+len(record.After))
|
|
for key := range record.Before {
|
|
seenPaths[key] = struct{}{}
|
|
paths = append(paths, key)
|
|
}
|
|
for key := range record.After {
|
|
if _, exists := seenPaths[key]; exists {
|
|
continue
|
|
}
|
|
paths = append(paths, key)
|
|
}
|
|
a := s.c.archive
|
|
mutationCheckpoint := a.MutationCheckpoint()
|
|
if err := applyVersionContentPathsLocked(s.c, paths, record.Before); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
for key, content := range record.Before {
|
|
s.c.versionWorking[key] = content.Entry
|
|
}
|
|
for key := range record.After {
|
|
if _, exists := record.Before[key]; !exists {
|
|
delete(s.c.versionWorking, key)
|
|
}
|
|
}
|
|
for key := range record.Before {
|
|
s.c.refreshVersionChangeLocked(key)
|
|
s.c.refreshVersionArtifactDirtyLocked(key)
|
|
}
|
|
for key := range record.After {
|
|
s.c.refreshVersionChangeLocked(key)
|
|
s.c.refreshVersionArtifactDirtyLocked(key)
|
|
}
|
|
s.c.sortVersionChangesLocked()
|
|
s.c.versionUndo = s.c.versionUndo[:len(s.c.versionUndo)-1]
|
|
mutationSummary := a.MutationsSince(mutationCheckpoint)
|
|
a.ClearMutations()
|
|
status := s.c.versionStatusLocked()
|
|
info := s.c.archive.Info()
|
|
s.c.mu.Unlock()
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
s.emitVersionChanged("undo")
|
|
return &status, nil
|
|
}
|
|
|
|
// Discard restores the current HEAD into the in-memory working archive.
|
|
func (s *VersionService) Discard() (*VersionStatus, error) {
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
target := pvfversion.CloneSnapshot(s.c.versionHeadSnapshot)
|
|
if err := s.materializeLocked(target, ""); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
a := s.c.archive
|
|
mutationSummary := a.MutationsSince(0)
|
|
a.ClearMutations()
|
|
status := s.c.versionStatusLocked()
|
|
info := s.c.archive.Info()
|
|
s.c.mu.Unlock()
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
s.emitVersionChanged("discarded")
|
|
return &status, nil
|
|
}
|
|
|
|
// Checkout loads a historical logical snapshot into the working tree. V1
|
|
// keeps main HEAD unchanged; committing afterwards creates a new commit on
|
|
// the current main line.
|
|
func (s *VersionService) Checkout(commitID string) (*VersionStatus, error) {
|
|
commitID = strings.TrimSpace(commitID)
|
|
if commitID == "" {
|
|
return nil, errors.New("提交 ID 不能为空")
|
|
}
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
target, err := s.c.versionRepo.Snapshot(commitID)
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if err := s.materializeLocked(target, commitID); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
a := s.c.archive
|
|
mutationSummary := a.MutationsSince(0)
|
|
a.ClearMutations()
|
|
status := s.c.versionStatusLocked()
|
|
info := s.c.archive.Info()
|
|
s.c.mu.Unlock()
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
s.emitVersionChanged("checkout")
|
|
emitEvent("version:checked-out", status)
|
|
return &status, nil
|
|
}
|
|
|
|
func versionFileDiffDTO(change pvfversion.FileChange) *VersionFileDiff {
|
|
return &VersionFileDiff{
|
|
Path: change.DisplayPath,
|
|
Operation: string(change.Operation),
|
|
BeforeHash: change.BeforeHash,
|
|
AfterHash: change.AfterHash,
|
|
BeforeType: change.BeforeType,
|
|
AfterType: change.AfterType,
|
|
}
|
|
}
|
|
|
|
func appendVersionDiffText(result *VersionFileDiff, content pvfversion.Content) {
|
|
if content.DataType != pvf.TypeScript && content.DataType != pvf.TypeUnicode {
|
|
return
|
|
}
|
|
if result.BeforeHash == content.Hash {
|
|
result.BeforeText = string(content.Raw)
|
|
} else if result.AfterHash == content.Hash {
|
|
result.AfterText = string(content.Raw)
|
|
}
|
|
result.TextAvailable = true
|
|
}
|
|
|
|
func (s *VersionService) versionBaseArchiveLocked() (*pvf.Archive, error) {
|
|
if s.c.versionBaseArchive != nil {
|
|
return s.c.versionBaseArchive, nil
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
return pvf.Open(s.c.versionRepo.BasePath())
|
|
}
|
|
|
|
func (s *VersionService) fillVersionCommitDiffLocked(
|
|
result *VersionFileDiff,
|
|
change pvfversion.FileChange,
|
|
base *pvf.Archive,
|
|
) error {
|
|
if change.BeforeHash != "" {
|
|
before, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
|
|
Path: change.DisplayPath, DataType: change.BeforeType, Hash: change.BeforeHash,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appendVersionDiffText(result, before)
|
|
}
|
|
if change.AfterHash != "" {
|
|
after, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
|
|
Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appendVersionDiffText(result, after)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Diff returns a single commit change, including logical text where possible.
|
|
func (s *VersionService) Diff(commitID, path string) (*VersionFileDiff, error) {
|
|
commitID = strings.TrimSpace(commitID)
|
|
path = pvfversion.CanonicalPath(path)
|
|
if commitID == "" || path == "" {
|
|
return nil, errors.New("提交 ID 和文件路径不能为空")
|
|
}
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if _, err := s.c.versionRepo.GetCommit(commitID); err != nil {
|
|
return nil, err
|
|
}
|
|
changes, err := s.c.versionRepo.Changes(commitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var change *pvfversion.FileChange
|
|
for index := range changes {
|
|
if pvfversion.CanonicalPath(changes[index].Path) == path {
|
|
change = &changes[index]
|
|
break
|
|
}
|
|
}
|
|
if change == nil {
|
|
return nil, fmt.Errorf("提交中没有文件变更: %s", path)
|
|
}
|
|
result := versionFileDiffDTO(*change)
|
|
base, err := s.versionBaseArchiveLocked()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.fillVersionCommitDiffLocked(result, *change, base); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DiffWorking returns the current worktree change for one path relative to
|
|
// HEAD. The current after-state is read from the in-memory archive, so the
|
|
// call does not require saving the packed PVF artifact first.
|
|
func (s *VersionService) DiffWorking(path string) (*VersionFileDiff, error) {
|
|
path = pvfversion.CanonicalPath(path)
|
|
if path == "" {
|
|
return nil, errors.New("文件路径不能为空")
|
|
}
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
change, ok := s.c.versionChangeMap[path]
|
|
if !ok {
|
|
return nil, fmt.Errorf("当前工作区没有文件变更: %s", path)
|
|
}
|
|
result := versionFileDiffDTO(change)
|
|
base, err := s.versionBaseArchiveLocked()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if change.BeforeHash != "" {
|
|
before, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
|
|
Path: change.DisplayPath, DataType: change.BeforeType, Hash: change.BeforeHash,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
appendVersionDiffText(result, before)
|
|
}
|
|
if change.AfterHash != "" {
|
|
afterSnapshot, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{path})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
after, ok := afterSnapshot[path]
|
|
if !ok || after.Hash != change.AfterHash || after.DataType != change.AfterType {
|
|
return nil, fmt.Errorf("当前工作区内容已变化,请刷新后重试: %s", path)
|
|
}
|
|
appendVersionDiffText(result, after)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// RestorePath restores one changed path to the current HEAD. It only
|
|
// materializes the selected path and records the operation in the existing
|
|
// in-memory undo stack.
|
|
func (s *VersionService) RestorePath(path string) (*VersionStatus, error) {
|
|
path = pvfversion.CanonicalPath(path)
|
|
if path == "" {
|
|
return nil, errors.New("文件路径不能为空")
|
|
}
|
|
s.c.mu.Lock()
|
|
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.c.versionRepo == nil {
|
|
s.c.mu.Unlock()
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
change, ok := s.c.versionChangeMap[path]
|
|
if !ok {
|
|
s.c.mu.Unlock()
|
|
return nil, fmt.Errorf("当前工作区没有文件变更: %s", path)
|
|
}
|
|
before, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{path})
|
|
if err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
desired := make(pvfversion.ContentSnapshot)
|
|
if entry, exists := s.c.versionHeadSnapshot[path]; exists {
|
|
base, baseErr := s.versionBaseArchiveLocked()
|
|
if baseErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, baseErr
|
|
}
|
|
content, contentErr := readVersionContent(s.c.versionRepo, base, entry)
|
|
if contentErr != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, contentErr
|
|
}
|
|
desired[path] = content
|
|
}
|
|
if len(before) == 0 && len(desired) == 0 {
|
|
s.c.mu.Unlock()
|
|
return nil, fmt.Errorf("文件已经恢复到 HEAD: %s", path)
|
|
}
|
|
if err := applyVersionContentPathsLocked(s.c, []string{path}, desired); err != nil {
|
|
s.c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
a := s.c.archive
|
|
mutationSummary := a.MutationsSince(0)
|
|
a.ClearMutations()
|
|
if content, exists := desired[path]; exists {
|
|
s.c.versionWorking[path] = content.Entry
|
|
} else {
|
|
delete(s.c.versionWorking, path)
|
|
}
|
|
s.c.refreshVersionChangeLocked(path)
|
|
s.c.refreshVersionArtifactDirtyLocked(path)
|
|
s.c.sortVersionChangesLocked()
|
|
s.c.versionUndo = append(s.c.versionUndo, versionUndoRecord{
|
|
Label: fmt.Sprintf("还原 %s", change.DisplayPath),
|
|
Before: pvfversion.CloneContentSnapshot(before),
|
|
After: pvfversion.CloneContentSnapshot(desired),
|
|
})
|
|
status := s.c.versionStatusLocked()
|
|
info := s.c.archive.Info()
|
|
s.c.mu.Unlock()
|
|
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
|
return nil, err
|
|
}
|
|
emitEvent("archive:reloaded", info)
|
|
s.emitVersionChanged("restored")
|
|
return &status, nil
|
|
}
|
|
|
|
func (s *VersionService) materializeLocked(target pvfversion.Snapshot, viewCommit string) error {
|
|
if s.c.archive == nil || s.c.versionRepo == nil {
|
|
return ErrNoArchive
|
|
}
|
|
changePaths, err := s.materializePathsLocked(viewCommit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payloadOnly := payloadOnlyForPaths(s.c.versionWorking, target, changePaths)
|
|
if len(changePaths) == 0 {
|
|
payloadOnly = sameVersionStructure(s.c.versionWorking, target)
|
|
}
|
|
materializePaths := changePaths
|
|
if len(changePaths) == 0 && !payloadOnly {
|
|
// This is only a defensive fallback for a malformed/incomplete change
|
|
// set; a normal main-branch transition always has path identities.
|
|
materializePaths = nil
|
|
}
|
|
materialized, err := materializeVersionArchive(
|
|
s.c.versionRepo.BasePath(), s.c.versionBaseArchive, target,
|
|
s.c.archive, s.c.versionWorking, s.c.versionRepo, materializePaths, s.c.archive.SourcePath(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if payloadOnly {
|
|
changedIndexes := changedVersionIndexesForPaths(s.c, changePaths)
|
|
if err := s.c.replaceArchivePayloadLocked(materialized, changedIndexes); err != nil {
|
|
return err
|
|
}
|
|
} else if err := s.c.replaceArchiveLocked(materialized); err != nil {
|
|
return err
|
|
}
|
|
// target is a detached snapshot owned by this operation; retaining it
|
|
// avoids another full map clone after materialization.
|
|
s.c.versionWorking = target
|
|
if materializePaths == nil {
|
|
s.c.rebuildVersionChangesLocked()
|
|
s.c.rebuildVersionArtifactDirtyLocked()
|
|
} else {
|
|
for _, path := range materializePaths {
|
|
key := pvfversion.CanonicalPath(path)
|
|
s.c.refreshVersionChangeLocked(key)
|
|
s.c.refreshVersionArtifactDirtyLocked(key)
|
|
}
|
|
s.c.sortVersionChangesLocked()
|
|
}
|
|
s.c.versionUndo = nil
|
|
s.c.versionViewCommit = viewCommit
|
|
return nil
|
|
}
|
|
|
|
func (s *VersionService) emitVersionChanged(reason string) {
|
|
emitVersionState(s.c, reason)
|
|
}
|
|
|
|
func emitVersionState(c *core, reason string) {
|
|
c.mu.RLock()
|
|
status := c.versionStatusLocked()
|
|
changes := c.currentVersionChangesLocked()
|
|
changeDTOs := make([]*VersionChange, 0, len(changes))
|
|
for _, change := range changes {
|
|
changeDTOs = append(changeDTOs, versionChangeDTO(change))
|
|
}
|
|
c.mu.RUnlock()
|
|
emitEvent("version:changed", map[string]any{
|
|
"reason": reason, "status": status, "changes": changeDTOs,
|
|
})
|
|
}
|
|
|
|
func (c *core) startVersionLoad(path string, archive *pvf.Archive) {
|
|
if !versionSidecarExists(path) {
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
if c.archive != archive {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.versionLoadID++
|
|
loadID := c.versionLoadID
|
|
c.versionLoading = true
|
|
c.versionLoadError = ""
|
|
c.mu.Unlock()
|
|
|
|
finishTask := c.archiveTasks.begin()
|
|
c.mu.RLock()
|
|
current := c.versionLoadCurrentLocked(archive, loadID)
|
|
c.mu.RUnlock()
|
|
if !current {
|
|
finishTask()
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer finishTask()
|
|
session, err := prepareVersionedSession(path, archive)
|
|
if err != nil {
|
|
c.finishVersionLoad(archive, loadID, err)
|
|
return
|
|
}
|
|
if session == nil {
|
|
c.finishVersionLoad(archive, loadID, nil)
|
|
return
|
|
}
|
|
|
|
var prepared *preparedArchiveIndexes
|
|
if session.archive != archive {
|
|
prepared, err = prepareArchiveDerivedIndexes(session.archive)
|
|
if err != nil {
|
|
closePreparedArchiveIndexes(prepared)
|
|
_ = session.repo.Close()
|
|
c.finishVersionLoad(archive, loadID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
c.mu.Lock()
|
|
if !c.versionLoadCurrentLocked(archive, loadID) {
|
|
c.mu.Unlock()
|
|
_ = session.repo.Close()
|
|
return
|
|
}
|
|
if session.archive != archive {
|
|
if err := c.installPreparedArchiveIndexesLocked(session.archive, prepared, true); err != nil {
|
|
c.mu.Unlock()
|
|
closePreparedArchiveIndexes(prepared)
|
|
_ = session.repo.Close()
|
|
c.finishVersionLoad(archive, loadID, err)
|
|
return
|
|
}
|
|
}
|
|
if err := c.attachVersionSessionLocked(session); err != nil {
|
|
c.versionLoading = false
|
|
c.versionLoadError = err.Error()
|
|
c.mu.Unlock()
|
|
_ = session.repo.Close()
|
|
emitVersionState(c, "load-error")
|
|
return
|
|
}
|
|
c.versionLoading = false
|
|
c.versionLoadError = ""
|
|
info := c.archive.Info()
|
|
reloaded := session.archive != archive
|
|
c.mu.Unlock()
|
|
|
|
// No archive references are read below. Release this task before the
|
|
// index acquires its own task, or closing can wait on this parent
|
|
// while its child waits for closing to finish.
|
|
finishTask()
|
|
if reloaded {
|
|
c.startSearchIndexForced()
|
|
emitEvent("archive:reloaded", info)
|
|
}
|
|
emitVersionState(c, "loaded")
|
|
}()
|
|
}
|
|
|
|
func versionSidecarExists(path string) bool {
|
|
_, err := os.Stat(pvfversion.SidecarPath(path))
|
|
return err == nil
|
|
}
|
|
|
|
func (c *core) versionLoadCurrentLocked(archive *pvf.Archive, loadID uint64) bool {
|
|
return c.archive == archive && c.versionLoading && c.versionLoadID == loadID
|
|
}
|
|
|
|
func (c *core) finishVersionLoad(archive *pvf.Archive, loadID uint64, loadErr error) {
|
|
c.mu.Lock()
|
|
if !c.versionLoadCurrentLocked(archive, loadID) {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.versionLoading = false
|
|
if loadErr != nil {
|
|
c.versionLoadError = loadErr.Error()
|
|
} else {
|
|
c.versionLoadError = ""
|
|
}
|
|
c.mu.Unlock()
|
|
if loadErr != nil {
|
|
emitVersionState(c, "load-error")
|
|
return
|
|
}
|
|
emitVersionState(c, "loaded")
|
|
}
|
|
|
|
func loadVersionRepositoryState(repo *pvfversion.Repository) (pvfversion.Commit, pvfversion.Snapshot, pvfversion.Meta, error) {
|
|
if repo == nil {
|
|
return pvfversion.Commit{}, nil, pvfversion.Meta{}, ErrVersionNotEnabled
|
|
}
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
return pvfversion.Commit{}, nil, pvfversion.Meta{}, err
|
|
}
|
|
headSnapshot, err := repo.Snapshot(head.ID)
|
|
if err != nil {
|
|
return pvfversion.Commit{}, nil, pvfversion.Meta{}, err
|
|
}
|
|
return head, headSnapshot, repo.Meta(), nil
|
|
}
|
|
|
|
func buildPreparedVersionSession(
|
|
repo *pvfversion.Repository,
|
|
archive *pvf.Archive,
|
|
head pvfversion.Commit,
|
|
headSnapshot pvfversion.Snapshot,
|
|
meta pvfversion.Meta,
|
|
working pvfversion.Snapshot,
|
|
baseArchive *pvf.Archive,
|
|
) (*preparedVersionSession, error) {
|
|
if repo == nil {
|
|
return nil, ErrVersionNotEnabled
|
|
}
|
|
if working == nil {
|
|
return nil, errors.New("版本工作区快照为空")
|
|
}
|
|
savedSnapshot := pvfversion.CloneSnapshot(working)
|
|
if meta.Artifact.CommitID != "" {
|
|
var err error
|
|
savedSnapshot, err = repo.Snapshot(meta.Artifact.CommitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
changes, changeMap := versionChangesForSnapshots(headSnapshot, working)
|
|
return &preparedVersionSession{
|
|
archive: archive,
|
|
repo: repo,
|
|
head: head,
|
|
headSnapshot: headSnapshot,
|
|
working: working,
|
|
savedSnapshot: savedSnapshot,
|
|
meta: meta,
|
|
baseArchive: baseArchive,
|
|
changes: changes,
|
|
changeMap: changeMap,
|
|
artifactDirty: versionArtifactDirtyForSnapshots(savedSnapshot, working),
|
|
}, nil
|
|
}
|
|
|
|
// prepareVersionedSession discovers an existing sidecar and restores an
|
|
// unsaved HEAD in memory. It is called only from the background loader; all
|
|
// disk IO, snapshot reconstruction and the baseline PVF parse stay off c.mu.
|
|
func prepareVersionedSession(path string, archive *pvf.Archive) (*preparedVersionSession, error) {
|
|
repo, err := pvfversion.Open(pvfversion.SidecarPath(path))
|
|
if errors.Is(err, pvfversion.ErrRepositoryNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
closeOnError := true
|
|
defer func() {
|
|
if closeOnError {
|
|
_ = repo.Close()
|
|
}
|
|
}()
|
|
head, headSnapshot, meta, err := loadVersionRepositoryState(repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sourceHash := archive.SourceHash()
|
|
if meta.Artifact.PVFHash != "" && sourceHash != meta.Artifact.PVFHash {
|
|
return nil, ErrVersionArtifactChanged
|
|
}
|
|
working := pvfversion.Snapshot(nil)
|
|
preparedArchive := archive
|
|
var baseArchive *pvf.Archive
|
|
if meta.Artifact.CommitID != "" && meta.Artifact.CommitID != head.ID {
|
|
artifactSnapshot, err := repo.Snapshot(meta.Artifact.CommitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
changedPaths, err := repo.ChangedPathsBetween(meta.Artifact.CommitID, head.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseArchive, err = pvf.Open(repo.BasePath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("打开版本基线失败: %w", err)
|
|
}
|
|
preparedArchive, err = materializeVersionArchive(
|
|
repo.BasePath(), baseArchive, headSnapshot, archive, artifactSnapshot, repo,
|
|
changedPaths, path,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
working = pvfversion.CloneSnapshot(headSnapshot)
|
|
} else if meta.Artifact.CommitID == head.ID && sourceHash == meta.Artifact.PVFHash {
|
|
// The packed artifact already represents HEAD, so re-use the restored
|
|
// manifest instead of decoding every logical file again.
|
|
working = pvfversion.CloneSnapshot(headSnapshot)
|
|
} else {
|
|
working, err = pvfversion.SnapshotFromArchive(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta.Artifact.TreeHash != "" && pvfversion.TreeHash(working) != meta.Artifact.TreeHash {
|
|
return nil, ErrVersionArtifactChanged
|
|
}
|
|
}
|
|
if baseArchive == nil {
|
|
baseArchive, err = pvf.Open(repo.BasePath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("打开版本基线失败: %w", err)
|
|
}
|
|
}
|
|
session, err := buildPreparedVersionSession(
|
|
repo, preparedArchive, head, headSnapshot, meta, working, baseArchive,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
closeOnError = false
|
|
return session, nil
|
|
}
|
|
|
|
func materializeVersionArchive(
|
|
basePath string,
|
|
baseArchive *pvf.Archive,
|
|
target pvfversion.Snapshot,
|
|
currentArchive *pvf.Archive,
|
|
currentSnapshot pvfversion.Snapshot,
|
|
repo *pvfversion.Repository,
|
|
changePaths []string,
|
|
outputPath string,
|
|
) (*pvf.Archive, error) {
|
|
base := baseArchive
|
|
if base == nil {
|
|
var err error
|
|
base, err = pvf.Open(basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
archive := base
|
|
if currentArchive != nil {
|
|
archive = currentArchive
|
|
}
|
|
archive = archive.CloneForBatch()
|
|
keys := make([]string, 0)
|
|
if changePaths == nil {
|
|
remove := make([]int32, 0)
|
|
for index := int32(0); index < archive.FileCount(); index++ {
|
|
if _, exists := target[pvfversion.CanonicalPath(archive.Path(index))]; !exists {
|
|
remove = append(remove, index)
|
|
}
|
|
}
|
|
if len(remove) > 0 {
|
|
if _, err := archive.RemoveFiles(remove); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
keys = make([]string, 0, len(target))
|
|
for key := range target {
|
|
keys = append(keys, key)
|
|
}
|
|
} else {
|
|
seen := make(map[string]struct{}, len(changePaths))
|
|
for _, rawPath := range changePaths {
|
|
key := pvfversion.CanonicalPath(rawPath)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
for _, key := range keys {
|
|
entry, targetExists := target[key]
|
|
index, exists := archive.Find(key)
|
|
if !targetExists {
|
|
if exists {
|
|
if _, err := archive.RemoveFiles([]int32{index}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if exists {
|
|
if value, ok := currentSnapshot[key]; ok && sameVersionEntry(value, entry) {
|
|
continue
|
|
}
|
|
}
|
|
content, err := readVersionContent(repo, base, entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exists && archive.File(index).DataType != entry.DataType {
|
|
if _, err := archive.RemoveFiles([]int32{index}); err != nil {
|
|
return nil, err
|
|
}
|
|
exists = false
|
|
}
|
|
if !exists {
|
|
index := archive.AddFile(entry.Path, nil, entry.DataType)
|
|
if err := setVersionContent(archive, index, content); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
if err := setVersionContent(archive, index, content); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
archive.SetSourcePath(outputPath)
|
|
return archive, nil
|
|
}
|
|
|
|
func readVersionContent(repo *pvfversion.Repository, base *pvf.Archive, entry pvfversion.Entry) (pvfversion.Content, error) {
|
|
if object, err := repo.ReadObject(entry.Hash); err == nil {
|
|
if pvfversion.HashBytes(object) != entry.Hash {
|
|
return pvfversion.Content{}, fmt.Errorf("版本对象 hash 校验失败: %s", entry.Path)
|
|
}
|
|
dataType, data, decodeErr := pvfversion.DecodeContent(object)
|
|
if decodeErr != nil {
|
|
return pvfversion.Content{}, decodeErr
|
|
}
|
|
if dataType != entry.DataType {
|
|
return pvfversion.Content{}, fmt.Errorf("版本对象类型不匹配: %s", entry.Path)
|
|
}
|
|
return pvfversion.Content{Entry: entry, Raw: data}, nil
|
|
}
|
|
values, err := pvfversion.ContentSnapshotFromArchive(base, []string{entry.Path})
|
|
if err != nil {
|
|
return pvfversion.Content{}, err
|
|
}
|
|
value, ok := values[pvfversion.CanonicalPath(entry.Path)]
|
|
if !ok || value.Hash != entry.Hash || value.DataType != entry.DataType {
|
|
return pvfversion.Content{}, fmt.Errorf("找不到版本对象: %s", entry.Path)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func setVersionContent(archive *pvf.Archive, index int32, content pvfversion.Content) error {
|
|
switch content.DataType {
|
|
case pvf.TypeScript, pvf.TypeUnicode:
|
|
return archive.SetText(index, string(content.Raw))
|
|
default:
|
|
return archive.SetRawBytes(index, content.Raw)
|
|
}
|
|
}
|
|
|
|
func applyVersionContentPathsLocked(c *core, paths []string, desired pvfversion.ContentSnapshot) error {
|
|
return applyContentPathsLocked(c, paths, desired, true)
|
|
}
|
|
|
|
// applyContentPathsLocked materializes desired onto the live archive. When
|
|
// compareWorking is set, entries the version working snapshot already matches
|
|
// are skipped (undo/checkout). Backup recovery passes false: there the backup is
|
|
// authoritative for every recovered path, so a payload can never be dropped
|
|
// because some other snapshot happens to agree with it.
|
|
func applyContentPathsLocked(c *core, paths []string, desired pvfversion.ContentSnapshot, compareWorking bool) error {
|
|
// Undo is a live archive mutation even when the file table does not change.
|
|
// Advance the shared revision before touching payloads so any script
|
|
// preview or running transaction is rejected/cancelled consistently.
|
|
c.batchRevision++
|
|
c.batchPlan = nil
|
|
c.invalidateScriptLocked()
|
|
remove := make([]int32, 0)
|
|
for _, key := range paths {
|
|
index, exists := c.archive.Find(key)
|
|
want, hasWant := desired[pvfversion.CanonicalPath(key)]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if !hasWant || c.archive.File(index).DataType != want.DataType {
|
|
remove = append(remove, index)
|
|
}
|
|
}
|
|
structural := len(remove) > 0
|
|
if len(remove) > 0 {
|
|
if _, err := c.archive.RemoveFiles(remove); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
touchedIndexes := make(map[int32]struct{}, len(desired))
|
|
for key, content := range desired {
|
|
index, exists := c.archive.Find(key)
|
|
if !exists {
|
|
index := c.archive.AddFile(content.Path, nil, content.DataType)
|
|
if err := setVersionContent(c.archive, index, content); err != nil {
|
|
return err
|
|
}
|
|
structural = true
|
|
continue
|
|
}
|
|
if compareWorking {
|
|
if current, ok := c.versionWorking[key]; ok && sameVersionEntry(current, content.Entry) {
|
|
continue
|
|
}
|
|
}
|
|
if err := setVersionContent(c.archive, index, content); err != nil {
|
|
return err
|
|
}
|
|
touchedIndexes[index] = struct{}{}
|
|
}
|
|
if structural {
|
|
if err := c.rebuildArchiveIndexesLocked(c.archive); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
refreshArchiveIndexMetadataLocked(c, touchedIndexes)
|
|
}
|
|
c.editorText = make(map[int32]string)
|
|
c.editorAnnotation = editorAnnotationCache{}
|
|
c.annotationRelations = make(map[string]map[string]*relationTarget)
|
|
c.markAdvancedSearchStaleLocked(c.archive)
|
|
return nil
|
|
}
|
|
|
|
// refreshArchiveIndexMetadataLocked updates size/type fields after payload-only
|
|
// changes. Paths and directory membership are unchanged, so rebuilding the
|
|
// complete tree index is unnecessary for ordinary text undo operations.
|
|
func refreshArchiveIndexMetadataLocked(c *core, indexes map[int32]struct{}) {
|
|
if len(indexes) == 0 || c.archive == nil {
|
|
return
|
|
}
|
|
if c.diskIndex != nil {
|
|
_ = c.diskIndex.refreshFileMetadata(c.archive, indexes)
|
|
return
|
|
}
|
|
for index := range c.sortedPaths {
|
|
entry := &c.sortedPaths[index]
|
|
if _, ok := indexes[entry.idx]; !ok {
|
|
continue
|
|
}
|
|
file := c.archive.File(entry.idx)
|
|
entry.size = file.DataSize
|
|
entry.typ = file.DataType
|
|
}
|
|
for _, children := range c.dirChildren {
|
|
for _, node := range children {
|
|
if node.IsDir {
|
|
continue
|
|
}
|
|
if _, ok := indexes[node.FileIndex]; !ok {
|
|
continue
|
|
}
|
|
file := c.archive.File(node.FileIndex)
|
|
node.Size = file.DataSize
|
|
node.DataType = file.DataType
|
|
}
|
|
}
|
|
}
|
|
|
|
func versionChangeDTO(change pvfversion.FileChange) *VersionChange {
|
|
return &VersionChange{
|
|
Path: change.DisplayPath, Operation: string(change.Operation),
|
|
BeforeHash: change.BeforeHash, AfterHash: change.AfterHash,
|
|
BeforeType: change.BeforeType, AfterType: change.AfterType,
|
|
}
|
|
}
|
|
|
|
func versionCommitDTO(commit pvfversion.Commit) *VersionCommit {
|
|
return &VersionCommit{
|
|
ID: commit.ID, ParentID: commit.ParentID, Message: commit.Message,
|
|
CreatedAt: commit.CreatedAt, ChangeCount: commit.ChangeCount,
|
|
}
|
|
}
|