dsh-turn-undo 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +14 -20
- package/index.js +248 -94
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -395,7 +395,7 @@ window.__ModuleLoader__.load({
|
|
|
395
395
|
var previewState = useState(null)
|
|
396
396
|
var preview = previewState[0]
|
|
397
397
|
var setPreview = previewState[1]
|
|
398
|
-
var loadingState = useState(
|
|
398
|
+
var loadingState = useState(false)
|
|
399
399
|
var loading = loadingState[0]
|
|
400
400
|
var setLoading = loadingState[1]
|
|
401
401
|
var errorState = useState(null)
|
|
@@ -408,28 +408,17 @@ window.__ModuleLoader__.load({
|
|
|
408
408
|
var done = doneState[0]
|
|
409
409
|
var setDone = doneState[1]
|
|
410
410
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
setError(null)
|
|
415
|
-
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
416
|
-
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
417
|
-
})
|
|
418
|
-
.then(function (res) { return res.json() })
|
|
419
|
-
.then(function (data) { if (!cancelled) setPreview(data) })
|
|
420
|
-
.catch(function (err) { if (!cancelled) setError(err.message || '请求失败') })
|
|
421
|
-
.finally(function () { if (!cancelled) setLoading(false) })
|
|
422
|
-
return function () { cancelled = true }
|
|
423
|
-
}, [])
|
|
411
|
+
// NOTE: preview 只在用户点开弹框时请求(见 show())。挂载时预取毫无
|
|
412
|
+
// 意义——按钮只渲染图标,不消费 preview——却会让每个会话在打开瞬间
|
|
413
|
+
// 为每条用户消息各发一次全工作区扫描请求(实测单次 1.2–2.7 秒)。
|
|
424
414
|
|
|
425
415
|
function show() {
|
|
426
416
|
setOpen(true)
|
|
427
417
|
setPreview(null)
|
|
428
418
|
setDone(false)
|
|
429
419
|
setLoading(true)
|
|
430
|
-
// NOTE:
|
|
431
|
-
//
|
|
432
|
-
// 且未捕获错误可能导致 React 卸下 portal(按钮消失)。必须无条件收尾。
|
|
420
|
+
// NOTE: 必须无条件收尾:任何未捕获错误都会让 setLoading(false) 不执行,
|
|
421
|
+
// 弹框会永远停在"正在检查…",且可能让 React 卸下 portal(按钮消失)。
|
|
433
422
|
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
434
423
|
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
435
424
|
})
|
|
@@ -468,6 +457,7 @@ window.__ModuleLoader__.load({
|
|
|
468
457
|
|
|
469
458
|
var changes = (preview && Array.isArray(preview.changes)) ? preview.changes : []
|
|
470
459
|
var noSnapshot = (preview && preview.noSnapshot) === true
|
|
460
|
+
var noBaseline = (preview && preview.noBaseline) === true
|
|
471
461
|
var previewError = (preview && preview.error) ? preview.error : null
|
|
472
462
|
|
|
473
463
|
return h('div', { className: 'dtu-container' },
|
|
@@ -491,7 +481,8 @@ window.__ModuleLoader__.load({
|
|
|
491
481
|
sessionId: sessionId, messageText: messageText,
|
|
492
482
|
onClose: close, preview: preview, loading: loading, error: error,
|
|
493
483
|
applying: applying, done: done, changes: changes,
|
|
494
|
-
previewError: previewError, noSnapshot: noSnapshot,
|
|
484
|
+
previewError: previewError, noSnapshot: noSnapshot, noBaseline: noBaseline,
|
|
485
|
+
canApply: canApply, applyRestore: applyRestore,
|
|
495
486
|
}) : null,
|
|
496
487
|
)
|
|
497
488
|
}
|
|
@@ -629,6 +620,7 @@ window.__ModuleLoader__.load({
|
|
|
629
620
|
var changes = props.changes
|
|
630
621
|
var previewError = props.previewError
|
|
631
622
|
var noSnapshot = props.noSnapshot
|
|
623
|
+
var noBaseline = props.noBaseline
|
|
632
624
|
var canApply = props.canApply
|
|
633
625
|
var applyRestore = props.applyRestore
|
|
634
626
|
|
|
@@ -662,9 +654,11 @@ window.__ModuleLoader__.load({
|
|
|
662
654
|
previewError ? h('p', { className: 'dtu-error' }, previewError) : null,
|
|
663
655
|
(!loading && noSnapshot)
|
|
664
656
|
? h('p', { className: 'dtu-status' }, '该时点没有可用快照,将仅创建新会话。') : null,
|
|
665
|
-
(!loading && !
|
|
657
|
+
(!loading && !noSnapshot && noBaseline)
|
|
658
|
+
? h('p', { className: 'dtu-status' }, '这条消息之前没有可用快照,无法恢复文件,将仅创建新会话。') : null,
|
|
659
|
+
(!loading && !previewError && !noSnapshot && !noBaseline && changes.length === 0)
|
|
666
660
|
? h('p', { className: 'dtu-status' }, '这条消息之前没有需要恢复的文件。') : null,
|
|
667
|
-
(!loading && !previewError && changes.length > 0)
|
|
661
|
+
(!loading && !previewError && !noSnapshot && !noBaseline && changes.length > 0)
|
|
668
662
|
? h('div', { className: 'dtu-section' },
|
|
669
663
|
h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
|
|
670
664
|
h('div', { className: 'dtu-files' },
|
package/index.js
CHANGED
|
@@ -51,6 +51,12 @@ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
|
51
51
|
const DEFAULT_MAX_FILES_PER_SNAPSHOT = 10000
|
|
52
52
|
const DEFAULT_MAX_SNAPSHOT_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
53
53
|
const DEFAULT_SNAPSHOT_DELAY_MS = 250
|
|
54
|
+
// Object-store GC: unreferenced objects are only reclaimed once they are older
|
|
55
|
+
// than the grace window (a capture writes objects before its manifest, so a
|
|
56
|
+
// young unreferenced object may still be in flight). The sweep itself is
|
|
57
|
+
// throttled because it stats every object and parses every surviving manifest.
|
|
58
|
+
const DEFAULT_OBJECT_GC_GRACE_MS = 60 * 60 * 1000
|
|
59
|
+
const DEFAULT_OBJECT_GC_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
54
60
|
const DEFAULT_EXCLUDES = [
|
|
55
61
|
'node_modules/',
|
|
56
62
|
'.git/',
|
|
@@ -187,6 +193,9 @@ class SnapshotStore {
|
|
|
187
193
|
this.maxBytes = cfg.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES
|
|
188
194
|
this.ttlDays = cfg.snapshotTtlDays ?? DEFAULT_SNAPSHOT_TTL_DAYS
|
|
189
195
|
this.maxSnapshots = cfg.maxSnapshotsPerSession ?? DEFAULT_MAX_SNAPSHOTS_PER_SESSION
|
|
196
|
+
this.objectGcGraceMs = cfg.objectGcGraceMs ?? DEFAULT_OBJECT_GC_GRACE_MS
|
|
197
|
+
this.objectGcIntervalMs = cfg.objectGcIntervalMs ?? DEFAULT_OBJECT_GC_INTERVAL_MS
|
|
198
|
+
this.lastObjectSweep = 0
|
|
190
199
|
this.objDir = join(this.base, 'objects')
|
|
191
200
|
this.snapDir = join(this.base, 'snapshots')
|
|
192
201
|
}
|
|
@@ -511,20 +520,6 @@ class SnapshotStore {
|
|
|
511
520
|
return result
|
|
512
521
|
}
|
|
513
522
|
|
|
514
|
-
/** Read manifest entries into a map of path -> content */
|
|
515
|
-
readManifestContents(manifest) {
|
|
516
|
-
const contents = {}
|
|
517
|
-
for (const [rel, entry] of Object.entries(manifest)) {
|
|
518
|
-
if (entry.kind === 'file' && entry.hash) {
|
|
519
|
-
const content = this.readObjectContent(entry.hash)
|
|
520
|
-
if (content !== null) {
|
|
521
|
-
contents[rel] = content
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return contents
|
|
526
|
-
}
|
|
527
|
-
|
|
528
523
|
/** Load snapshot manifests for a session, oldest first. */
|
|
529
524
|
loadChain(sessionId) {
|
|
530
525
|
const dir = join(this.snapDir, sessionId)
|
|
@@ -649,6 +644,86 @@ class SnapshotStore {
|
|
|
649
644
|
try { unlinkSync(join(dir, remaining[i])) } catch {}
|
|
650
645
|
}
|
|
651
646
|
}
|
|
647
|
+
// Manifests are pruned above; reclaim the objects they no longer reference.
|
|
648
|
+
// Without this the content-addressed store grows forever (an observed store
|
|
649
|
+
// reached 983 MB / 40k files whose manifests had long been deleted).
|
|
650
|
+
try { this.sweepObjects() } catch (e) {
|
|
651
|
+
console.warn('[turn-undo] object sweep failed:', e.message)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Mark-and-sweep the content-addressed object store.
|
|
657
|
+
*
|
|
658
|
+
* Every hash reachable from a surviving manifest is marked; unreferenced
|
|
659
|
+
* objects older than the grace window are deleted. Objects younger than the
|
|
660
|
+
* grace window are kept because capture() writes objects before it writes
|
|
661
|
+
* the manifest that references them — deleting those would corrupt an
|
|
662
|
+
* in-flight snapshot.
|
|
663
|
+
*
|
|
664
|
+
* Throttled to `objectGcIntervalMs` since the mark phase parses every
|
|
665
|
+
* surviving manifest.
|
|
666
|
+
* @param opts - `force` bypasses the throttle (tests / explicit invocation);
|
|
667
|
+
* `dryRun` counts what would be reclaimed without deleting anything.
|
|
668
|
+
* @returns Counts, or null when the throttle skipped this call.
|
|
669
|
+
*/
|
|
670
|
+
sweepObjects(opts = {}) {
|
|
671
|
+
const now = Date.now()
|
|
672
|
+
if (!opts.force && now - this.lastObjectSweep < this.objectGcIntervalMs) return null
|
|
673
|
+
this.lastObjectSweep = now
|
|
674
|
+
|
|
675
|
+
// ── Mark ──────────────────────────────────────────────────────────
|
|
676
|
+
const referenced = new Set()
|
|
677
|
+
let unreadable = 0
|
|
678
|
+
if (existsSync(this.snapDir)) {
|
|
679
|
+
for (const sessionId of readdirSync(this.snapDir)) {
|
|
680
|
+
const dir = join(this.snapDir, sessionId)
|
|
681
|
+
let dirStat
|
|
682
|
+
try { dirStat = statSync(dir) } catch { continue }
|
|
683
|
+
if (!dirStat.isDirectory()) continue
|
|
684
|
+
for (const f of readdirSync(dir)) {
|
|
685
|
+
if (!f.endsWith('.json')) continue
|
|
686
|
+
const data = readJsonFile(join(dir, f))
|
|
687
|
+
if (!data || !data.manifest) { unreadable++; continue }
|
|
688
|
+
for (const entry of Object.values(data.manifest)) {
|
|
689
|
+
if (entry && typeof entry.hash === 'string') referenced.add(entry.hash)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Fail-safe: an unparseable manifest would contribute no marks, so its
|
|
696
|
+
// objects would look unreferenced and be deleted — silently destroying a
|
|
697
|
+
// snapshot that is merely unreadable. Abort rather than sweep on a
|
|
698
|
+
// possibly-incomplete mark set.
|
|
699
|
+
if (unreadable > 0) {
|
|
700
|
+
console.warn(`[turn-undo] object sweep skipped: ${unreadable} manifest(s) unreadable`)
|
|
701
|
+
return { removed: 0, freedBytes: 0, keptYoung: 0, referenced: referenced.size, aborted: 'unreadable-manifest' }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Sweep ─────────────────────────────────────────────────────────
|
|
705
|
+
let removed = 0
|
|
706
|
+
let freedBytes = 0
|
|
707
|
+
let keptYoung = 0
|
|
708
|
+
if (!existsSync(this.objDir)) return { removed, freedBytes, keptYoung, referenced: referenced.size }
|
|
709
|
+
for (const name of readdirSync(this.objDir)) {
|
|
710
|
+
if (referenced.has(name)) continue
|
|
711
|
+
const abs = join(this.objDir, name)
|
|
712
|
+
let st
|
|
713
|
+
try { st = statSync(abs) } catch { continue }
|
|
714
|
+
if (!st.isFile()) continue
|
|
715
|
+
if (now - st.mtimeMs < this.objectGcGraceMs) { keptYoung++; continue }
|
|
716
|
+
if (opts.dryRun) { removed++; freedBytes += st.size; continue }
|
|
717
|
+
try {
|
|
718
|
+
unlinkSync(abs)
|
|
719
|
+
removed++
|
|
720
|
+
freedBytes += st.size
|
|
721
|
+
} catch {}
|
|
722
|
+
}
|
|
723
|
+
if (removed > 0) {
|
|
724
|
+
console.info(`[turn-undo] object sweep reclaimed ${removed} objects (${Math.round(freedBytes / 1048576)} MB), kept ${keptYoung} young, ${referenced.size} referenced`)
|
|
725
|
+
}
|
|
726
|
+
return { removed, freedBytes, keptYoung, referenced: referenced.size }
|
|
652
727
|
}
|
|
653
728
|
}
|
|
654
729
|
|
|
@@ -702,14 +777,20 @@ function resolveForkBoundary(source, messageSeq, promptText) {
|
|
|
702
777
|
}
|
|
703
778
|
|
|
704
779
|
// ── Strategy 2: text-based fallback ────────────────────────────────
|
|
780
|
+
// Only reached for event streams that carry no usable `seq` (persisted
|
|
781
|
+
// sessions). When the stream DOES carry seqs but none is this user message,
|
|
782
|
+
// the requested point genuinely does not exist — guessing a different
|
|
783
|
+
// message here would silently restore the workspace to the wrong turn.
|
|
784
|
+
const seqsAvailable = events.some(e => typeof e.seq === 'number')
|
|
785
|
+
if (seqsAvailable && typeof messageSeq === 'number') {
|
|
786
|
+
return { boundary: null, turn: null, cwd, reason: 'no-matching-message' }
|
|
787
|
+
}
|
|
788
|
+
|
|
705
789
|
// Use promptText to match the user message, avoiding the bug of
|
|
706
790
|
// treating messageSeq as an array index when seq is unavailable.
|
|
707
|
-
//
|
|
708
|
-
// If promptText is not provided, find the latest user message as a
|
|
709
|
-
// safe fallback.
|
|
710
791
|
if (promptText) {
|
|
711
792
|
const normalizedPromptText = promptText.trim().substring(0, 200).toLowerCase()
|
|
712
|
-
|
|
793
|
+
const candidates = events.filter(e => (
|
|
713
794
|
e.type === 'user/message'
|
|
714
795
|
&& e.data?.source && typeof e.data.source === 'object'
|
|
715
796
|
&& e.data.source.kind === 'user'
|
|
@@ -721,20 +802,13 @@ function resolveForkBoundary(source, messageSeq, promptText) {
|
|
|
721
802
|
return text.includes(normalizedPromptText) || normalizedPromptText.includes(text)
|
|
722
803
|
})
|
|
723
804
|
))
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const e = events[i]
|
|
730
|
-
if (e.type === 'turn/start') break
|
|
731
|
-
if (e.type === 'user/message'
|
|
732
|
-
&& e.data?.source && typeof e.data.source === 'object'
|
|
733
|
-
&& e.data.source.kind === 'user') {
|
|
734
|
-
message = e
|
|
735
|
-
break
|
|
736
|
-
}
|
|
805
|
+
// Ambiguous text must not be resolved by position: the first match is
|
|
806
|
+
// routinely an earlier prompt that merely contains the same words
|
|
807
|
+
// ("继续" and the like).
|
|
808
|
+
if (candidates.length > 1) {
|
|
809
|
+
return { boundary: null, turn: null, cwd, reason: 'ambiguous-message' }
|
|
737
810
|
}
|
|
811
|
+
if (candidates.length === 1) message = candidates[0]
|
|
738
812
|
}
|
|
739
813
|
|
|
740
814
|
if (!message) return { boundary: null, turn: null, cwd, reason: 'no-user-message' }
|
|
@@ -1017,21 +1091,39 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1017
1091
|
|
|
1018
1092
|
if (!sessionId) return json(response, 400, { error: 'Missing sessionId' })
|
|
1019
1093
|
|
|
1094
|
+
// 单次 readSession 即可:live 会话的 snapshotEvents() 会重放事件,
|
|
1095
|
+
// 每个请求读两遍是纯浪费。
|
|
1096
|
+
const source = await readSession(ctx, sessionId)
|
|
1097
|
+
if (!source) return json(response, 404, { error: 'Session not found' })
|
|
1098
|
+
|
|
1020
1099
|
let targetTurn = null
|
|
1100
|
+
let unresolved = null
|
|
1021
1101
|
if (messageSeqParam) {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// The preview function will compare this turn's snapshot with the previous one.
|
|
1027
|
-
targetTurn = b.turn !== null && b.turn > 0 ? b.turn : null
|
|
1028
|
-
}
|
|
1102
|
+
const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
|
|
1103
|
+
// For preview, pass the turn that the user wants to undo (b.turn).
|
|
1104
|
+
if (b.turn !== null && b.turn > 0) targetTurn = b.turn
|
|
1105
|
+
else unresolved = b.reason ?? 'no-turn'
|
|
1029
1106
|
}
|
|
1030
1107
|
|
|
1031
1108
|
// Wait for any in-flight snapshot capture so the preview reflects the
|
|
1032
1109
|
// latest committed workspace state (avoids showing a stale turn/end).
|
|
1033
1110
|
try { await runtime.waitForSnapshots() } catch {}
|
|
1034
|
-
|
|
1111
|
+
|
|
1112
|
+
// 定位不到消息时返回明确失败,而不是 targetTurn=null 的"0 变更"
|
|
1113
|
+
// —— 后者会让弹框显示"无变化",掩盖真实的解析失败。
|
|
1114
|
+
if (unresolved !== null) {
|
|
1115
|
+
return json(response, 200, {
|
|
1116
|
+
ok: false,
|
|
1117
|
+
status: 'unresolved',
|
|
1118
|
+
reason: unresolved,
|
|
1119
|
+
error: '定位不到这条消息对应的对话位置,无法撤销',
|
|
1120
|
+
targetTurn: null,
|
|
1121
|
+
totalChanges: 0,
|
|
1122
|
+
changes: [],
|
|
1123
|
+
})
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
|
|
1035
1127
|
return json(response, 200, preview)
|
|
1036
1128
|
}
|
|
1037
1129
|
|
|
@@ -1051,15 +1143,25 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1051
1143
|
const source = await readSession(ctx, sessionId)
|
|
1052
1144
|
if (!source) return json(response, 400, { error: 'Session not found' })
|
|
1053
1145
|
const cwd = getCwd(source)
|
|
1054
|
-
const b = messageSeq !== null
|
|
1146
|
+
const b = messageSeq !== null
|
|
1147
|
+
? resolveForkBoundary(source, messageSeq, promptText)
|
|
1148
|
+
: { boundary: null, turn: null, cwd, reason: 'no-message-seq' }
|
|
1055
1149
|
|
|
1056
1150
|
// Determine restore target: "recover to before this message" means
|
|
1057
1151
|
// the state at turn/start, i.e. turn T - 0.5. That snapshot captures
|
|
1058
1152
|
// the workspace before the user sent this message and before any AI work.
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1153
|
+
//
|
|
1154
|
+
// 解析不到目标消息时必须中止:继续执行会 fork 出新会话却跳过文件恢复,
|
|
1155
|
+
// 却仍返回 ok:true —— 用户会以为撤销成功。
|
|
1156
|
+
if (b.turn === null || b.turn <= 0) {
|
|
1157
|
+
return json(response, 200, {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
status: 'unresolved',
|
|
1160
|
+
reason: b.reason ?? 'no-turn',
|
|
1161
|
+
error: '定位不到这条消息对应的对话位置,未做任何修改',
|
|
1162
|
+
})
|
|
1062
1163
|
}
|
|
1164
|
+
const restoreTurn = b.turn - 0.5
|
|
1063
1165
|
|
|
1064
1166
|
// 3. Wait for any pending snapshot to settle so the manifest is on disk.
|
|
1065
1167
|
// Without this, a restore issued right after turn/end may read an
|
|
@@ -1079,12 +1181,7 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1079
1181
|
}
|
|
1080
1182
|
|
|
1081
1183
|
// 4. Restore files to the best snapshot at/before restoreTurn.
|
|
1082
|
-
|
|
1083
|
-
if (restoreTurn !== null) {
|
|
1084
|
-
restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
|
|
1085
|
-
} else {
|
|
1086
|
-
restoreResult = { ok: false, error: 'NO_SNAPSHOT' }
|
|
1087
|
-
}
|
|
1184
|
+
const restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
|
|
1088
1185
|
|
|
1089
1186
|
// 5. Fork 新会话(DSH 原生,web 的在此分叉按钮同款:ctx.agents.create),
|
|
1090
1187
|
// 并把旧会话标题加上 (已撤销) 前缀后保留。
|
|
@@ -1139,6 +1236,8 @@ export function apply(ctx, config = {}) {
|
|
|
1139
1236
|
maxSnapshotBytes: config.maxSnapshotBytes,
|
|
1140
1237
|
snapshotTtlDays: config.snapshotTtlDays,
|
|
1141
1238
|
maxSnapshotsPerSession: config.maxSnapshotsPerSession,
|
|
1239
|
+
objectGcGraceMs: config.objectGcGraceMs,
|
|
1240
|
+
objectGcIntervalMs: config.objectGcIntervalMs,
|
|
1142
1241
|
})
|
|
1143
1242
|
const runtime = new SnapshotRuntime({
|
|
1144
1243
|
store,
|
|
@@ -1174,77 +1273,132 @@ export function apply(ctx, config = {}) {
|
|
|
1174
1273
|
})
|
|
1175
1274
|
}
|
|
1176
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* Build a manifest from the current workspace state without persisting it.
|
|
1278
|
+
*
|
|
1279
|
+
* Entries whose size+mtime+mode already match `referenceManifest` reuse the
|
|
1280
|
+
* recorded hash instead of re-reading the bytes — the same short-circuit
|
|
1281
|
+
* capture() uses. Without it every preview re-hashes the whole workspace.
|
|
1282
|
+
* @param cwd - workspace root being scanned.
|
|
1283
|
+
* @param referenceManifest - a prior manifest (usually the latest snapshot).
|
|
1284
|
+
*/
|
|
1285
|
+
SnapshotStore.prototype.buildLiveManifest = function (cwd, referenceManifest) {
|
|
1286
|
+
const ignore = makeIgnore(cwd, this.excludes)
|
|
1287
|
+
const files = this.scan(cwd, ignore)
|
|
1288
|
+
if (!files) return null
|
|
1289
|
+
const manifest = {}
|
|
1290
|
+
for (const abs of files) {
|
|
1291
|
+
let st
|
|
1292
|
+
try { st = lstatSync(abs) } catch { continue }
|
|
1293
|
+
if (!st.isFile()) continue
|
|
1294
|
+
const rel = relative(cwd, abs).split('\\').join('/')
|
|
1295
|
+
const mode = st.mode.toString(8).padStart(4, '0')
|
|
1296
|
+
const prev = referenceManifest && referenceManifest[rel]
|
|
1297
|
+
if (prev && prev.kind === 'file' && prev.hash
|
|
1298
|
+
&& prev.size === st.size && prev.mtime === st.mtimeMs && prev.mode === mode) {
|
|
1299
|
+
manifest[rel] = prev
|
|
1300
|
+
} else {
|
|
1301
|
+
manifest[rel] = {
|
|
1302
|
+
kind: 'file',
|
|
1303
|
+
hash: hashFile(abs),
|
|
1304
|
+
size: st.size,
|
|
1305
|
+
mtime: st.mtimeMs,
|
|
1306
|
+
mode,
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return manifest
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** Read a workspace file's text; '' when unreadable (binary, deleted, permission). */
|
|
1314
|
+
SnapshotStore.prototype.readWorkspaceContent = function (cwd, rel) {
|
|
1315
|
+
try { return readFileSync(resolve(cwd, rel), 'utf-8') } catch { return '' }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1177
1318
|
// Add preview helper to SnapshotStore prototype.
|
|
1178
|
-
// Returns the files that
|
|
1179
|
-
SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
|
|
1180
|
-
const chain = this.loadChain(sessionId)
|
|
1319
|
+
// Returns the files that undo (restoring to the target's baseline) will affect.
|
|
1320
|
+
SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
|
|
1181
1321
|
if (targetTurn === null) {
|
|
1182
1322
|
return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
|
|
1183
1323
|
}
|
|
1324
|
+
const chain = this.loadChain(sessionId)
|
|
1325
|
+
if (chain.length === 0) {
|
|
1326
|
+
return { ok: true, status: 'no-snapshot', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
|
|
1327
|
+
}
|
|
1184
1328
|
|
|
1185
1329
|
// 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
|
|
1186
1330
|
// baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
|
|
1187
1331
|
// (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
|
|
1188
|
-
// baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
|
|
1189
|
-
// (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
|
|
1190
|
-
// 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
|
|
1191
1332
|
let baseline = null
|
|
1192
1333
|
for (const m of chain) {
|
|
1193
1334
|
if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
|
|
1194
1335
|
baseline = m
|
|
1195
1336
|
}
|
|
1196
1337
|
}
|
|
1197
|
-
const baselineManifest = baseline ? baseline.manifest : {}
|
|
1198
1338
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1339
|
+
// 目标消息早于最早可用快照:既无法描述也无法执行恢复。把当前所有文件
|
|
1340
|
+
// 都列成 "created" 会宣称"恢复到空工作区",而 restore() 随后必以
|
|
1341
|
+
// NO_SNAPSHOT 拒绝 —— 必须明确报告无基线,而不是给出误导性的文件清单。
|
|
1342
|
+
if (baseline === null) {
|
|
1343
|
+
return { ok: true, status: 'no-baseline', targetTurn, totalChanges: 0, changes: [], noBaseline: true }
|
|
1201
1344
|
}
|
|
1345
|
+
const baselineManifest = baseline.manifest
|
|
1202
1346
|
|
|
1203
1347
|
// 会话最新快照 = 撤销点之后所有改动的累积终点。
|
|
1204
1348
|
const latest = chain[chain.length - 1]
|
|
1205
|
-
const latestManifest = latest.manifest
|
|
1206
1349
|
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
// the
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1350
|
+
// If targetTurn is beyond the latest snapshot (e.g., the turn hasn't ended
|
|
1351
|
+
// yet or snapshots were skipped), compare baseline against the CURRENT
|
|
1352
|
+
// workspace state instead of the stale latest snapshot. This ensures the
|
|
1353
|
+
// preview shows meaningful changes even when the active turn has no snapshot.
|
|
1354
|
+
let latestManifest = latest.manifest
|
|
1355
|
+
let latestIsLive = false
|
|
1356
|
+
if (targetTurn > latest.turn && cwd) {
|
|
1357
|
+
const live = this.buildLiveManifest(cwd, latest.manifest)
|
|
1358
|
+
if (live) {
|
|
1359
|
+
latestManifest = live
|
|
1360
|
+
latestIsLive = true
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// ── Collect changed paths FIRST ───────────────────────────────────────
|
|
1365
|
+
// Contents are read only for files whose hash differs; reading every object
|
|
1366
|
+
// in both manifests made one preview cost hundreds of file reads.
|
|
1216
1367
|
const deleted = []
|
|
1217
1368
|
const created = []
|
|
1218
|
-
|
|
1219
|
-
// Collect deleted files
|
|
1369
|
+
const modified = []
|
|
1220
1370
|
for (const rel of Object.keys(baselineManifest)) {
|
|
1221
|
-
if (!latestManifest[rel]) {
|
|
1222
|
-
changes.push({ path: rel, kind: 'deleted' })
|
|
1223
|
-
deleted.push({ path: rel, hash: baselineManifest[rel].hash })
|
|
1224
|
-
}
|
|
1371
|
+
if (!latestManifest[rel]) deleted.push({ path: rel, hash: baselineManifest[rel].hash })
|
|
1225
1372
|
}
|
|
1226
|
-
|
|
1227
|
-
// Collect created and modified files
|
|
1228
1373
|
for (const rel of Object.keys(latestManifest)) {
|
|
1229
1374
|
const entry = latestManifest[rel]
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1375
|
+
const prev = baselineManifest[rel]
|
|
1376
|
+
if (!prev) created.push({ path: rel, hash: entry.hash })
|
|
1377
|
+
else if (entry.hash !== prev.hash) modified.push({ path: rel, oldHash: prev.hash, newHash: entry.hash })
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const changes = []
|
|
1381
|
+
for (const d of deleted) changes.push({ path: d.path, kind: 'deleted' })
|
|
1382
|
+
for (const c of created) changes.push({ path: c.path, kind: 'created' })
|
|
1383
|
+
for (const m of modified) {
|
|
1384
|
+
const oldContent = this.readObjectContent(m.oldHash) || ''
|
|
1385
|
+
// A live entry's bytes were never written to the object store; read the
|
|
1386
|
+
// working tree instead, or the diff would show every old line as removed.
|
|
1387
|
+
const newContent = latestIsLive
|
|
1388
|
+
? this.readWorkspaceContent(cwd, m.path)
|
|
1389
|
+
: (this.readObjectContent(m.newHash) || '')
|
|
1390
|
+
const oldLines = oldContent.split('\n')
|
|
1391
|
+
const newLines = newContent.split('\n')
|
|
1392
|
+
// generateDiff is O(n*m) DP; skip hunks for pathologically large files
|
|
1393
|
+
// rather than allocating a multi-hundred-MB table on a preview click.
|
|
1394
|
+
const tooLarge = oldLines.length * newLines.length > 4_000_000
|
|
1395
|
+
changes.push({
|
|
1396
|
+
path: m.path,
|
|
1397
|
+
kind: 'modified',
|
|
1398
|
+
diff: tooLarge
|
|
1399
|
+
? { oldLines: oldLines.length, newLines: newLines.length, hunks: [], truncated: true }
|
|
1400
|
+
: { oldLines: oldLines.length, newLines: newLines.length, hunks: this.generateDiff(oldLines, newLines, 10) },
|
|
1401
|
+
})
|
|
1248
1402
|
}
|
|
1249
1403
|
|
|
1250
1404
|
// Detect rename/move: same content hash, different path
|