dsh-turn-undo 0.0.1 → 0.0.2

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 (4) hide show
  1. package/README.md +3 -3
  2. package/client.js +14 -3
  3. package/index.js +40 -28
  4. package/package.json +6 -3
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 功能
6
6
 
7
7
  - ✅ 每条用户消息的操作栏注入一个“撤销”按钮(↺)。
8
- - ✅ 弹窗显示本轮会恢复/删除/修改的文件,并给出警告。
8
+ - ✅ 弹窗显示撤销该消息后会影响的全部文件——包括该消息之后所有轮次的改动(不只是消息本轮的),并给出警告。
9
9
  - ✅ 确认后:
10
10
  - 恢复工作区文件到**发送该消息之前**的快照状态。
11
11
  - 通过 DSH 原生的 `sessionController.fork` 从该消息之前 fork 出新会话。
@@ -24,7 +24,7 @@ dsh plugin --profile <name> add dsh-turn-undo
24
24
  ## 使用
25
25
 
26
26
  1. 在对话中,每条用户消息的操作栏会出现撤销按钮。
27
- 2. 点击按钮,弹窗列出本轮影响的文件。
27
+ 2. 点击按钮,弹窗列出撤销该消息后会影响的文件(该消息及之后所有轮次的改动)。
28
28
  3. 点击“确认恢复并继续”:
29
29
  - 文件恢复。
30
30
  - 旧会话被标记为 `(已撤销)`。
@@ -47,7 +47,7 @@ dsh plugin --profile <name> add dsh-turn-undo
47
47
  - 对“第一条用户消息”前面没有 completed turn 时,fall back 到 `ctx.sessionController.create({ cwd, agentPreset })` 创建空白会话。
48
48
  - 旧会话通过 `ctx.sessionController.rename` 加上 `(已撤销)` 前缀。
49
49
  - **HTTP API**:
50
- - `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览本轮影响。
50
+ - `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览撤销该消息后会影响的文件(该消息之后所有轮次改动的并集)。
51
51
  - `POST /api/turn-undo`(body `{ sessionId, messageSeq, promptText }`):执行恢复 + fork。
52
52
 
53
53
  ### 客户端(`client.js`)
package/client.js CHANGED
@@ -90,6 +90,7 @@ window.__ModuleLoader__.load({
90
90
  '.dtu-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
91
91
  '.dtu-files{min-width:0;max-width:100%;box-sizing:border-box;max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}',
92
92
  '.dtu-file{display:flex;justify-content:space-between;gap:16px;min-width:0;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}.dtu-file:last-child{border-bottom:0}',
93
+ '.dtu-more{display:flex;justify-content:center;gap:8px;padding:8px 10px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
93
94
  '.dtu-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}',
94
95
  '.dtu-kind{flex:none;color:var(--dsw-alias-label-tertiary)}',
95
96
  '.dtu-warning,.dtu-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border-radius:10px;font-size:12px;line-height:18px}',
@@ -329,7 +330,7 @@ window.__ModuleLoader__.load({
329
330
  ),
330
331
  ),
331
332
  open ? h(RestoreDialog, {
332
- sessionId: sessionId, messageText: messageText, turn: messageSeq,
333
+ sessionId: sessionId, messageText: messageText,
333
334
  onClose: close, preview: preview, loading: loading, error: error,
334
335
  applying: applying, done: done, changes: changes,
335
336
  previewError: previewError, noSnapshot: noSnapshot, canApply: canApply, applyRestore: applyRestore,
@@ -340,7 +341,6 @@ window.__ModuleLoader__.load({
340
341
  function RestoreDialog(props) {
341
342
  var sessionId = props.sessionId
342
343
  var messageText = props.messageText
343
- var turn = props.turn
344
344
  var onClose = props.onClose
345
345
  var preview = props.preview
346
346
  var loading = props.loading
@@ -353,6 +353,13 @@ window.__ModuleLoader__.load({
353
353
  var canApply = props.canApply
354
354
  var applyRestore = props.applyRestore
355
355
 
356
+ // 文件很多时避免一次性渲染大量 DOM(性能优化):只渲染前 200 个,
357
+ // 其余折叠成一条提示。总数仍在标题里显示。
358
+ var MAX_PREVIEW_FILES = 200
359
+ var shownChanges = changes.length > MAX_PREVIEW_FILES
360
+ ? changes.slice(0, MAX_PREVIEW_FILES)
361
+ : changes
362
+
356
363
  return reactDom.createPortal(
357
364
  h('div', { className: 'dtu-overlay', onClick: onClose },
358
365
  h('div', { className: 'dtu-dialog', onClick: function (e) { e.stopPropagation() } },
@@ -377,12 +384,16 @@ window.__ModuleLoader__.load({
377
384
  ? h('div', { className: 'dtu-section' },
378
385
  h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
379
386
  h('div', { className: 'dtu-files' },
380
- changes.map(function (change, idx) {
387
+ shownChanges.map(function (change, idx) {
381
388
  return h('div', { key: idx, className: 'dtu-file' },
382
389
  h('code', {}, change.path),
383
390
  h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
384
391
  )
385
392
  }),
393
+ (changes.length > shownChanges.length)
394
+ ? h('div', { className: 'dtu-more' },
395
+ '… 还有 ' + (changes.length - shownChanges.length) + ' 个文件未显示'
396
+ ) : null,
386
397
  ),
387
398
  ) : null,
388
399
  (!loading && !previewError && changes.length > 0)
package/index.js CHANGED
@@ -887,6 +887,9 @@ function createHandler(ctx, runtime, sessions, agents) {
887
887
  }
888
888
  }
889
889
 
890
+ // Wait for any in-flight snapshot capture so the preview reflects the
891
+ // latest committed workspace state (avoids showing a stale turn/end).
892
+ try { await runtime.waitForSnapshots() } catch {}
890
893
  const preview = runtime.store.preview(sessionId, targetTurn)
891
894
  return json(response, 200, preview)
892
895
  }
@@ -922,6 +925,18 @@ function createHandler(ctx, runtime, sessions, agents) {
922
925
  // empty snapshot chain and skip file restoration.
923
926
  try { await runtime.waitForSnapshots() } catch {}
924
927
 
928
+ // 3.5 Safety snapshot: before this irreversible restore wipes files,
929
+ // force-capture the CURRENT workspace into the session chain (as a
930
+ // fractional turn just past the newest snapshot) so the user can
931
+ // restore again to the pre-undo state if needed. Non-fatal on failure.
932
+ try {
933
+ const chain = runtime.store.loadChain(sessionId)
934
+ const lastTurn = chain.length ? chain[chain.length - 1].turn : 0
935
+ await runtime.captureNow(cwd, sessionId, lastTurn + 0.5)
936
+ } catch (e) {
937
+ console.warn('[turn-undo] safety snapshot failed (non-fatal):', e?.message)
938
+ }
939
+
925
940
  // 4. Restore files to the best snapshot at/before restoreTurn.
926
941
  let restoreResult
927
942
  if (restoreTurn !== null) {
@@ -1026,49 +1041,46 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
1026
1041
  return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
1027
1042
  }
1028
1043
 
1029
- // The turn/end snapshot shows the workspace after AI work in this turn.
1030
- let target = null
1044
+ // 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
1045
+ // baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
1046
+ // (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
1047
+ // baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
1048
+ // (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
1049
+ // 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
1050
+ let baseline = null
1031
1051
  for (const m of chain) {
1032
- if (m.turn === targetTurn) {
1033
- target = m
1034
- break
1052
+ if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
1053
+ baseline = m
1035
1054
  }
1036
1055
  }
1037
- if (!target) {
1056
+ const baselineManifest = baseline ? baseline.manifest : {}
1057
+
1058
+ if (chain.length === 0) {
1038
1059
  return { ok: true, status: 'ready', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
1039
1060
  }
1040
1061
 
1041
- // Use the newest snapshot strictly before this turn as the baseline.
1042
- // It is normally the turn/start snapshot (T - 0.5), but if an intermediate
1043
- // turn had no file changes, no manifest was written, so we fall back to the
1044
- // most recent available baseline. For the very first turn we use an empty
1045
- // baseline.
1046
- let prev = null
1047
- for (const m of chain) {
1048
- if (m.turn < targetTurn && (prev === null || m.turn > prev.turn)) {
1049
- prev = m
1050
- }
1051
- }
1052
- const prevManifest = prev ? prev.manifest : {}
1062
+ // 会话最新快照 = 撤销点之后所有改动的累积终点。
1063
+ const latest = chain[chain.length - 1]
1064
+ const latestManifest = latest.manifest
1053
1065
 
1054
- // Calculate changes: files that differ between turn/start and turn/end.
1066
+ // Calculate changes between latest and baseline: these are the files that
1067
+ // undo (restoring to baseline) will affect — every change made at or after
1068
+ // the target turn.
1055
1069
  const changes = []
1056
- const targetManifest = target.manifest
1057
-
1058
- for (const rel of Object.keys(targetManifest)) {
1059
- const entry = targetManifest[rel]
1060
- if (!prevManifest[rel]) {
1070
+ for (const rel of Object.keys(latestManifest)) {
1071
+ const entry = latestManifest[rel]
1072
+ if (!baselineManifest[rel]) {
1061
1073
  changes.push({ path: rel, kind: 'created' })
1062
1074
  } else {
1063
- const prevEntry = prevManifest[rel]
1075
+ const prevEntry = baselineManifest[rel]
1064
1076
  if (entry.hash !== prevEntry.hash) {
1065
1077
  changes.push({ path: rel, kind: 'modified' })
1066
1078
  }
1067
1079
  }
1068
1080
  }
1069
1081
 
1070
- for (const rel of Object.keys(prevManifest)) {
1071
- if (!targetManifest[rel]) {
1082
+ for (const rel of Object.keys(baselineManifest)) {
1083
+ if (!latestManifest[rel]) {
1072
1084
  changes.push({ path: rel, kind: 'deleted' })
1073
1085
  }
1074
1086
  }
@@ -1076,7 +1088,7 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
1076
1088
  return {
1077
1089
  ok: true,
1078
1090
  status: 'ready',
1079
- targetTurn: target.turn,
1091
+ targetTurn,
1080
1092
  totalChanges: changes.length,
1081
1093
  changes,
1082
1094
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-undo",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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",
@@ -24,7 +24,10 @@
24
24
  "client.js",
25
25
  "cordis.patch.yml"
26
26
  ],
27
- "repository": "github:gege9527/dsh-turn-undo",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/gege9527/dsh-turn-undo.git"
30
+ },
28
31
  "bugs": "https://github.com/gege9527/dsh-turn-undo/issues",
29
32
  "homepage": "https://github.com/gege9527/dsh-turn-undo#readme",
30
33
  "license": "MIT",
@@ -39,4 +42,4 @@
39
42
  "optional": false
40
43
  }
41
44
  }
42
- }
45
+ }