dsh-turn-undo 0.0.4 → 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.
Files changed (3) hide show
  1. package/client.js +14 -20
  2. package/index.js +224 -109
  3. 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(true)
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
- useEffect(function () {
412
- var cancelled = false
413
- setLoading(true)
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: cancelled 只存在于 mount effect 闭包,show() 引用它会抛
431
- // ReferenceError setLoading(false) 永不执行 → 弹框一直"正在检查…",
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, canApply: canApply, applyRestore: applyRestore,
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 && !previewError && !noSnapshot && changes.length === 0)
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
- message = events.find(e => (
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
- // If promptText matching failed or not provided, find the latest user message.
727
- if (!message) {
728
- for (let i = events.length - 1; i >= 0; i--) {
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,23 +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 source = await readSession(ctx, sessionId)
1023
- if (source) {
1024
- const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
1025
- // For preview, pass the turn that the user wants to undo (b.turn).
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
- const source = await readSession(ctx, sessionId)
1035
- const cwd = source ? getCwd(source) : undefined
1036
- const preview = runtime.store.preview(sessionId, targetTurn, cwd)
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))
1037
1127
  return json(response, 200, preview)
1038
1128
  }
1039
1129
 
@@ -1053,15 +1143,25 @@ function createHandler(ctx, runtime, sessions, agents) {
1053
1143
  const source = await readSession(ctx, sessionId)
1054
1144
  if (!source) return json(response, 400, { error: 'Session not found' })
1055
1145
  const cwd = getCwd(source)
1056
- const b = messageSeq !== null ? resolveForkBoundary(source, messageSeq, promptText) : { boundary: null, turn: null, cwd }
1146
+ const b = messageSeq !== null
1147
+ ? resolveForkBoundary(source, messageSeq, promptText)
1148
+ : { boundary: null, turn: null, cwd, reason: 'no-message-seq' }
1057
1149
 
1058
1150
  // Determine restore target: "recover to before this message" means
1059
1151
  // the state at turn/start, i.e. turn T - 0.5. That snapshot captures
1060
1152
  // the workspace before the user sent this message and before any AI work.
1061
- let restoreTurn = null
1062
- if (b.turn !== null && b.turn > 0) {
1063
- restoreTurn = b.turn - 0.5
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
+ })
1064
1163
  }
1164
+ const restoreTurn = b.turn - 0.5
1065
1165
 
1066
1166
  // 3. Wait for any pending snapshot to settle so the manifest is on disk.
1067
1167
  // Without this, a restore issued right after turn/end may read an
@@ -1081,12 +1181,7 @@ function createHandler(ctx, runtime, sessions, agents) {
1081
1181
  }
1082
1182
 
1083
1183
  // 4. Restore files to the best snapshot at/before restoreTurn.
1084
- let restoreResult
1085
- if (restoreTurn !== null) {
1086
- restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
1087
- } else {
1088
- restoreResult = { ok: false, error: 'NO_SNAPSHOT' }
1089
- }
1184
+ const restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
1090
1185
 
1091
1186
  // 5. Fork 新会话(DSH 原生,web 的在此分叉按钮同款:ctx.agents.create),
1092
1187
  // 并把旧会话标题加上 (已撤销) 前缀后保留。
@@ -1141,6 +1236,8 @@ export function apply(ctx, config = {}) {
1141
1236
  maxSnapshotBytes: config.maxSnapshotBytes,
1142
1237
  snapshotTtlDays: config.snapshotTtlDays,
1143
1238
  maxSnapshotsPerSession: config.maxSnapshotsPerSession,
1239
+ objectGcGraceMs: config.objectGcGraceMs,
1240
+ objectGcIntervalMs: config.objectGcIntervalMs,
1144
1241
  })
1145
1242
  const runtime = new SnapshotRuntime({
1146
1243
  store,
@@ -1178,55 +1275,74 @@ export function apply(ctx, config = {}) {
1178
1275
 
1179
1276
  /**
1180
1277
  * Build a manifest from the current workspace state without persisting it.
1181
- * Used by preview when the target turn is beyond the latest snapshot.
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).
1182
1284
  */
1183
- SnapshotStore.prototype.buildLiveManifest = function (cwd) {
1285
+ SnapshotStore.prototype.buildLiveManifest = function (cwd, referenceManifest) {
1184
1286
  const ignore = makeIgnore(cwd, this.excludes)
1185
1287
  const files = this.scan(cwd, ignore)
1186
1288
  if (!files) return null
1187
1289
  const manifest = {}
1188
- for (const p of files) {
1189
- const abs = p
1290
+ for (const abs of files) {
1190
1291
  let st
1191
1292
  try { st = lstatSync(abs) } catch { continue }
1192
1293
  if (!st.isFile()) continue
1193
1294
  const rel = relative(cwd, abs).split('\\').join('/')
1194
- manifest[rel] = {
1195
- kind: 'file',
1196
- hash: hashFile(abs),
1197
- size: st.size,
1198
- mtime: st.mtimeMs,
1199
- mode: st.mode.toString(8).padStart(4, '0'),
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
+ }
1200
1308
  }
1201
1309
  }
1202
1310
  return manifest
1203
1311
  }
1204
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
+
1205
1318
  // Add preview helper to SnapshotStore prototype.
1206
- // Returns the files that changed during the target turn (turn/end vs turn/start).
1319
+ // Returns the files that undo (restoring to the target's baseline) will affect.
1207
1320
  SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1208
- const chain = this.loadChain(sessionId)
1209
1321
  if (targetTurn === null) {
1210
1322
  return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
1211
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
+ }
1212
1328
 
1213
1329
  // 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
1214
1330
  // baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
1215
1331
  // (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
1216
- // baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
1217
- // (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
1218
- // 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
1219
1332
  let baseline = null
1220
1333
  for (const m of chain) {
1221
1334
  if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
1222
1335
  baseline = m
1223
1336
  }
1224
1337
  }
1225
- const baselineManifest = baseline ? baseline.manifest : {}
1226
1338
 
1227
- if (chain.length === 0) {
1228
- return { ok: true, status: 'ready', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
1339
+ // 目标消息早于最早可用快照:既无法描述也无法执行恢复。把当前所有文件
1340
+ // 都列成 "created" 会宣称"恢复到空工作区",而 restore() 随后必以
1341
+ // NO_SNAPSHOT 拒绝 —— 必须明确报告无基线,而不是给出误导性的文件清单。
1342
+ if (baseline === null) {
1343
+ return { ok: true, status: 'no-baseline', targetTurn, totalChanges: 0, changes: [], noBaseline: true }
1229
1344
  }
1345
+ const baselineManifest = baseline.manifest
1230
1346
 
1231
1347
  // 会话最新快照 = 撤销点之后所有改动的累积终点。
1232
1348
  const latest = chain[chain.length - 1]
@@ -1235,55 +1351,54 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1235
1351
  // yet or snapshots were skipped), compare baseline against the CURRENT
1236
1352
  // workspace state instead of the stale latest snapshot. This ensures the
1237
1353
  // preview shows meaningful changes even when the active turn has no snapshot.
1238
- let latestManifest
1354
+ let latestManifest = latest.manifest
1355
+ let latestIsLive = false
1239
1356
  if (targetTurn > latest.turn && cwd) {
1240
- const live = this.buildLiveManifest(cwd)
1241
- latestManifest = live || latest.manifest
1242
- } else {
1243
- latestManifest = latest.manifest
1357
+ const live = this.buildLiveManifest(cwd, latest.manifest)
1358
+ if (live) {
1359
+ latestManifest = live
1360
+ latestIsLive = true
1361
+ }
1244
1362
  }
1245
1363
 
1246
- // Calculate changes between latest and baseline: these are the files that
1247
- // undo (restoring to baseline) will affect every change made at or after
1248
- // the target turn.
1249
- const changes = []
1250
-
1251
- // Read manifest contents for diff computation
1252
- const baselineContents = this.readManifestContents(baselineManifest)
1253
- const latestContents = this.readManifestContents(latestManifest)
1254
-
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.
1255
1367
  const deleted = []
1256
1368
  const created = []
1257
-
1258
- // Collect deleted files
1369
+ const modified = []
1259
1370
  for (const rel of Object.keys(baselineManifest)) {
1260
- if (!latestManifest[rel]) {
1261
- changes.push({ path: rel, kind: 'deleted' })
1262
- deleted.push({ path: rel, hash: baselineManifest[rel].hash })
1263
- }
1371
+ if (!latestManifest[rel]) deleted.push({ path: rel, hash: baselineManifest[rel].hash })
1264
1372
  }
1265
-
1266
- // Collect created and modified files
1267
1373
  for (const rel of Object.keys(latestManifest)) {
1268
1374
  const entry = latestManifest[rel]
1269
- if (!baselineManifest[rel]) {
1270
- changes.push({ path: rel, kind: 'created' })
1271
- created.push({ path: rel, hash: entry.hash })
1272
- } else {
1273
- const prevEntry = baselineManifest[rel]
1274
- if (entry.hash !== prevEntry.hash) {
1275
- const oldContent = baselineContents[rel] || ''
1276
- const newContent = latestContents[rel] || ''
1277
- const oldLines = oldContent.split('\n')
1278
- const newLines = newContent.split('\n')
1279
- const diff = this.generateDiff(oldLines, newLines, 10)
1280
- changes.push({
1281
- path: rel,
1282
- kind: 'modified',
1283
- diff: { oldLines: oldLines.length, newLines: newLines.length, hunks: diff }
1284
- })
1285
- }
1286
- }
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
+ })
1287
1402
  }
1288
1403
 
1289
1404
  // Detect rename/move: same content hash, different path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-undo",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Undo to any point in a DSH conversation — revert files and fork a new session",
5
5
  "type": "module",
6
6
  "main": "index.js",