dsh-turn-undo 0.0.1 → 0.0.3
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/README.md +3 -3
- package/client.js +342 -27
- package/index.js +238 -50
- 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
|
@@ -9,10 +9,27 @@
|
|
|
9
9
|
// messages carry these flow kinds (AI replies use "assistant-step",
|
|
10
10
|
// "tool-call", etc. and are never selected).
|
|
11
11
|
// 2. Inside the row, find the message actions container (the element
|
|
12
|
-
// holding the copy/branch buttons).
|
|
13
|
-
//
|
|
12
|
+
// holding the copy/branch buttons). DSH seats the row's renderer inside
|
|
13
|
+
// passthrough wrappers (slot outlets render display:contents with one
|
|
14
|
+
// child), so we walk down firstElementChild past single-child wrappers
|
|
15
|
+
// to the UserStyleBubble root, whose LAST direct element child is the
|
|
16
|
+
// MessageIconActions row (dimension-independent of hashed CSS classes).
|
|
17
|
+
// "Parent of the first button" broke after the ui-attachment refactor:
|
|
18
|
+
// attachment thumbnails are <button>s that render before the actions
|
|
19
|
+
// row, so the first button is no longer the copy control.
|
|
14
20
|
// 3. That parent element becomes the portal target for the undo button.
|
|
15
21
|
//
|
|
22
|
+
// DSH 0.1.6+ CSS-reveals a user/steering row's .actions strip only on
|
|
23
|
+
// hover/focus while a later user/steering row exists
|
|
24
|
+
// (MessageIconActions.module.css:
|
|
25
|
+
// :is([data-chat-flow-kind='user'],[data-chat-flow-kind='steering']):has(
|
|
26
|
+
// ~ :is(...)) .actions { opacity:0 }). A portal button inside .actions
|
|
27
|
+
// would inherit that invisibility, so collectPortalTargets tags the
|
|
28
|
+
// injected container with `data-dtu-always` and the plugin stylesheet
|
|
29
|
+
// forces `[data-dtu-always]{opacity:1!important}`, keeping the undo
|
|
30
|
+
// control permanently visible without changing DSH's hover behavior for
|
|
31
|
+
// the native copy/branch controls elsewhere in the row.
|
|
32
|
+
//
|
|
16
33
|
// Communication:
|
|
17
34
|
// GET /api/turn-undo?sessionId=...&turn=... -> preview
|
|
18
35
|
// POST /api/turn-undo -> restore
|
|
@@ -28,18 +45,68 @@ window.__ModuleLoader__.load({
|
|
|
28
45
|
var API_PATH = '/api/turn-undo'
|
|
29
46
|
var USER_ROW_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]'
|
|
30
47
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
48
|
+
// Locate the user/steering message's IconActions row container.
|
|
49
|
+
//
|
|
50
|
+
// Structure (ChatNodeSeat.tsx + MessageItem.tsx UserStyleBubble, DSH 0.1.6+):
|
|
51
|
+
// div[data-chat-flow-kind="user"] (row, .flowItem)
|
|
52
|
+
// div[data-slot="conversation.chat.node"] (SlotOutlet anchor,
|
|
53
|
+
// display:contents passthrough)
|
|
54
|
+
// div.userRow (UserStyleBubble root)
|
|
55
|
+
// div.userStack // bubble + attachment rows
|
|
56
|
+
// div.actions // MessageIconActions row,
|
|
57
|
+
// LAST child of userRow
|
|
58
|
+
//
|
|
59
|
+
// The number of passthrough wrappers between the row and userRow is NOT
|
|
60
|
+
// stable across DSH versions (renderSlot outlets, providers, ...), so we
|
|
61
|
+
// cannot hardcode `row.firstElementChild.lastElementChild`. Instead walk
|
|
62
|
+
// down firstElementChild while the element is a single-child passthrough
|
|
63
|
+
// (SlotOutlet anchors render display:contents with exactly one child);
|
|
64
|
+
// the first element with 2+ children is userRow — UserStyleBubble always
|
|
65
|
+
// renders [userStack, actions], and actions is always its LAST child.
|
|
66
|
+
//
|
|
67
|
+
// The container must NOT be located by "parent of the first <button>":
|
|
68
|
+
// since the ui-attachment refactor, image thumbnails / file-card retry
|
|
69
|
+
// controls are <button>s that render INSIDE userStack (attachmentRow),
|
|
70
|
+
// before the actions row. The first button in document order is then an
|
|
71
|
+
// attachment control, whose parent is the attachment row — landing the
|
|
72
|
+
// undo button next to the image.
|
|
73
|
+
//
|
|
74
|
+
// DSH 0.1.6+ additionally CSS-gates the whole .actions strip to
|
|
75
|
+
// opacity:0 on any user/steering row that has a later user/steering
|
|
76
|
+
// sibling (MessageIconActions.module.css `:has(~ …) .actions{opacity:0}`),
|
|
77
|
+
// revealing it only on row hover/focus. A portal child inside .actions
|
|
78
|
+
// would be invisible at rest, so collectPortalTargets tags the resolved
|
|
79
|
+
// container with `data-dtu-always` and the plugin stylesheet forces
|
|
80
|
+
// `[data-dtu-always]{opacity:1!important}` — the undo control stays
|
|
81
|
+
// permanently visible on every user row.
|
|
34
82
|
function findIconActions(row) {
|
|
35
83
|
if (!row || row.nodeType !== 1) return null
|
|
36
84
|
var kind = row.getAttribute('data-chat-flow-kind')
|
|
37
85
|
if (kind !== 'user' && kind !== 'steering') return null
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
86
|
+
// Walk down through passthrough wrappers (slot outlets etc.): each
|
|
87
|
+
// renders exactly one child. Stop at the first element with more than
|
|
88
|
+
// one child — that is userRow ([userStack, actions]). The depth cap is
|
|
89
|
+
// a runaway guard, not an expected bound.
|
|
90
|
+
var el = row.firstElementChild
|
|
91
|
+
var depth = 0
|
|
92
|
+
while (el && el.children.length <= 1 && depth < 8) {
|
|
93
|
+
el = el.firstElementChild
|
|
94
|
+
depth++
|
|
95
|
+
}
|
|
96
|
+
if (!el || el.children.length < 2) return null
|
|
97
|
+
var actions = el.lastElementChild
|
|
98
|
+
if (!actions || actions.nodeType !== 1) return null
|
|
41
99
|
if (!actions || actions.nodeType !== 1) return null
|
|
100
|
+
// Guard: the actions row always carries at least one direct <button>
|
|
101
|
+
// (the copy control). If it does not, the row has no action strip and
|
|
102
|
+
// there is nowhere to seat the undo button.
|
|
42
103
|
if (actions.querySelectorAll(':scope > button').length < 1) return null
|
|
104
|
+
// The guard is content-level: an attachment gallery or retry control
|
|
105
|
+
// that leaked into this container means the DOM structure drifted, and
|
|
106
|
+
// seating the undo button there would repeat the "next to the image"
|
|
107
|
+
// bug. Reject rather than inject into the wrong element.
|
|
108
|
+
if (actions.querySelector('[data-variant]')) return null
|
|
109
|
+
if (actions.querySelector('img')) return null
|
|
43
110
|
return actions
|
|
44
111
|
}
|
|
45
112
|
|
|
@@ -90,6 +157,7 @@ window.__ModuleLoader__.load({
|
|
|
90
157
|
'.dtu-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
|
|
91
158
|
'.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
159
|
'.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}',
|
|
160
|
+
'.dtu-more{display:flex;justify-content:center;gap:8px;padding:8px 10px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
93
161
|
'.dtu-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}',
|
|
94
162
|
'.dtu-kind{flex:none;color:var(--dsw-alias-label-tertiary)}',
|
|
95
163
|
'.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}',
|
|
@@ -99,6 +167,32 @@ window.__ModuleLoader__.load({
|
|
|
99
167
|
'.dtu-btn{padding:8px 16px;border:0;border-radius:6px;font-size:14px;cursor:pointer}',
|
|
100
168
|
'.dtu-btn-cancel{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}.dtu-btn-cancel:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
101
169
|
'.dtu-btn-primary{background:var(--dsw-alias-state-business-primary);color:#fff;font-weight:500;min-width:120px}.dtu-btn-primary:hover{opacity:.9}.dtu-btn-primary:disabled{opacity:.5;cursor:not-allowed}',
|
|
170
|
+
'.dtu-file{cursor:pointer}.dtu-file:hover{background:var(--dsw-alias-bg-layer-3)}',
|
|
171
|
+
'.dtu-fullscreen-diff{position:fixed;inset:0;background:var(--dsw-alias-bg-layer-1);z-index:11000;display:flex;flex-direction:column}',
|
|
172
|
+
'.dtu-fullscreen-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l2);flex-shrink:0}',
|
|
173
|
+
'.dtu-fullscreen-back{background:transparent;border:0;font-size:14px;cursor:pointer;color:var(--dsw-alias-label-secondary);padding:6px 10px;border-radius:6px}.dtu-fullscreen-back:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
174
|
+
'.dtu-fullscreen-path{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
175
|
+
'.dtu-fullscreen-close{background:transparent;border:0;font-size:24px;cursor:pointer;color:var(--dsw-alias-label-tertiary);padding:4px 8px;border-radius:6px}.dtu-fullscreen-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
176
|
+
'.dtu-fullscreen-body{flex:1;display:flex;flex-direction:column;overflow:hidden}',
|
|
177
|
+
'.dtu-diff-columns-wrapper{display:flex;padding:0 16px 8px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:0.5px;flex-shrink:0}',
|
|
178
|
+
'.dtu-diff-column-label{flex:1;text-align:center;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:4px}',
|
|
179
|
+
'.dtu-diff-scroll-container{flex:1;overflow:auto}',
|
|
180
|
+
'.dtu-diff-row{display:flex;border-bottom:1px solid var(--dsw-alias-border-l1);min-height:21px}',
|
|
181
|
+
'.dtu-diff-row-removed{background:rgba(248,81,73,.15)}',
|
|
182
|
+
'.dtu-diff-row-added{background:rgba(63,185,80,.15)}',
|
|
183
|
+
'.dtu-diff-cell{flex:1;font-family:monospace;font-size:13px;line-height:21px;padding:0 8px 0 52px;display:flex;white-space:pre-wrap;word-break:break-all;position:relative}',
|
|
184
|
+
'.dtu-diff-cell-left{border-right:1px solid var(--dsw-alias-border-l1)}',
|
|
185
|
+
'.dtu-diff-cell-removed{color:#f85149}',
|
|
186
|
+
'.dtu-diff-cell-added{color:#39b54e}',
|
|
187
|
+
'.dtu-diff-line-num{position:absolute;left:4px;top:0;width:44px;text-align:right;color:var(--dsw-alias-label-tertiary);font-size:12px;pointer-events:none;padding-right:4px}',
|
|
188
|
+
'.dtu-diff-line-num-empty{visibility:hidden}',
|
|
189
|
+
'.dtu-diff-empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary);font-size:14px}',
|
|
190
|
+
// 插件把 portal 按钮注入 .actions 行;DSH 0.1.6+ 对"后面还有 user 行"的
|
|
191
|
+
// user/steering 行默认 .actions{opacity:0}(仅 hover/focus 显现),
|
|
192
|
+
// 注入按钮会跟着消失。给注入的容器打 data-dtu-always,用 !important
|
|
193
|
+
// 强制常显,且不影响 DSH 原生 copy/branch 的 hover 行为(它们仍在
|
|
194
|
+
// .actions 里,随父盒透明度一起显隐,与撤销按钮一致的常显)。
|
|
195
|
+
'[data-dtu-always]{opacity:1!important}',
|
|
102
196
|
].join('')
|
|
103
197
|
document.head.appendChild(styleEl)
|
|
104
198
|
}
|
|
@@ -174,16 +268,24 @@ window.__ModuleLoader__.load({
|
|
|
174
268
|
var sessionId = props.sessionId
|
|
175
269
|
var openRestoredSessionProp = props.openRestoredSession
|
|
176
270
|
var useChat = props.useChat
|
|
271
|
+
|
|
272
|
+
// 卡死根因 1:裸用 `useChat(s => s.nodes.values())`。
|
|
273
|
+
// NodesView.values() 在 upsert 后构建**新数组**(流式输出期间几乎每帧都 dirty),
|
|
274
|
+
// 而 useSyncExternalStoreWithSelector 的默认比较是 Object.is(选择器返回值)
|
|
275
|
+
// → 组件每帧重渲染 → useLayoutEffect([nodes]) 每帧 teardown/recreate
|
|
276
|
+
// body-subtree 的 MutationObserver + 全页扫描 → GUI 卡死。
|
|
277
|
+
// 修复:用 eq 按内容比较,内容不变时选择器返回旧数组引用,渲染与 effect 都稳定。
|
|
177
278
|
var nodes = useChat(function (snapshot) {
|
|
178
279
|
return snapshot.nodes ? snapshot.nodes.values() : []
|
|
179
|
-
})
|
|
280
|
+
}, sameNodeList)
|
|
281
|
+
|
|
180
282
|
var targetsState = useState([])
|
|
181
283
|
var targets = targetsState[0]
|
|
182
284
|
var setTargets = targetsState[1]
|
|
183
285
|
|
|
184
286
|
useLayoutEffect(function () {
|
|
185
287
|
var active = true
|
|
186
|
-
var
|
|
288
|
+
var timer = 0
|
|
187
289
|
var refresh = function () {
|
|
188
290
|
if (!active) return
|
|
189
291
|
var next = collectPortalTargets(nodes)
|
|
@@ -191,19 +293,55 @@ window.__ModuleLoader__.load({
|
|
|
191
293
|
return samePortalTargets(current, next) ? current : next
|
|
192
294
|
})
|
|
193
295
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
296
|
+
// 卡死根因 2:observer 回调对**任何** body 变更都跑全页扫描
|
|
297
|
+
// (流式 token、输入框、hover 状态都在内)。只有关心 user/steering 行
|
|
298
|
+
// 内部的变更才值得重扫;其余记录直接丢弃。
|
|
299
|
+
// 再叠加 80ms 时间防抖,把"每帧重扫"压成"静默期结束后扫一次"。
|
|
300
|
+
var isRelevant = function (record) {
|
|
301
|
+
var target = record.target
|
|
302
|
+
var relevant = false
|
|
303
|
+
var node = target
|
|
304
|
+
while (node && node.nodeType === 1) {
|
|
305
|
+
if (node.hasAttribute && node.hasAttribute('data-chat-anchor-key')) {
|
|
306
|
+
relevant = node.getAttribute('data-chat-flow-kind') === 'user'
|
|
307
|
+
|| node.getAttribute('data-chat-flow-kind') === 'steering'
|
|
308
|
+
if (relevant) break
|
|
309
|
+
}
|
|
310
|
+
node = node.parentNode
|
|
311
|
+
}
|
|
312
|
+
if (!relevant && record.addedNodes) {
|
|
313
|
+
for (var i = 0; i < record.addedNodes.length; i++) {
|
|
314
|
+
var added = record.addedNodes[i]
|
|
315
|
+
var probe = added
|
|
316
|
+
while (probe && probe.nodeType === 1) {
|
|
317
|
+
if (probe.hasAttribute && probe.hasAttribute('data-chat-anchor-key')) {
|
|
318
|
+
relevant = probe.getAttribute('data-chat-flow-kind') === 'user'
|
|
319
|
+
|| probe.getAttribute('data-chat-flow-kind') === 'steering'
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
probe = probe.parentNode
|
|
323
|
+
}
|
|
324
|
+
if (relevant) break
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return relevant
|
|
328
|
+
}
|
|
329
|
+
var queueRefresh = function (records) {
|
|
330
|
+
if (!active) return
|
|
331
|
+
for (var i = 0; i < records.length; i++) {
|
|
332
|
+
if (isRelevant(records[i])) {
|
|
333
|
+
if (timer) clearTimeout(timer)
|
|
334
|
+
timer = setTimeout(refresh, 80)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
}
|
|
201
338
|
}
|
|
202
339
|
refresh()
|
|
203
340
|
var observer = new MutationObserver(queueRefresh)
|
|
204
341
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
205
342
|
return function () {
|
|
206
343
|
active = false
|
|
344
|
+
if (timer) clearTimeout(timer)
|
|
207
345
|
observer.disconnect()
|
|
208
346
|
}
|
|
209
347
|
}, [nodes])
|
|
@@ -224,6 +362,27 @@ window.__ModuleLoader__.load({
|
|
|
224
362
|
return portals
|
|
225
363
|
}
|
|
226
364
|
|
|
365
|
+
// eq for the useChat selector: content-level equality so the selected array
|
|
366
|
+
// keeps its reference across snapshots whose node set did not structurally
|
|
367
|
+
// change. A streaming frame that only refreshes node payloads therefore
|
|
368
|
+
// neither re-renders this component nor re-runs the observer effect.
|
|
369
|
+
//
|
|
370
|
+
// 注意:不能把 `sameNodeList` 放在 RestoreMessagePortals 内部——
|
|
371
|
+
// hook 行在函数声明提升之前执行时,词法作用域里的函数声明虽已提升,
|
|
372
|
+
// 但 useChat 的第二参必须是一个稳定可调用对象;放模块级最稳。
|
|
373
|
+
function sameNodeList(left, right) {
|
|
374
|
+
if (left === right) return true
|
|
375
|
+
if (left.length !== right.length) return false
|
|
376
|
+
for (var i = 0; i < left.length; i++) {
|
|
377
|
+
var a = left[i]
|
|
378
|
+
var b = right[i]
|
|
379
|
+
var av = ('key' in a && 'data' in a) ? a : { key: 'node', data: a }
|
|
380
|
+
var bv = ('key' in b && 'data' in b) ? b : { key: 'node', data: b }
|
|
381
|
+
if (av.key !== bv.key || av.data !== bv.data) return false
|
|
382
|
+
}
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
|
|
227
386
|
function RestoreMessageAction(props) {
|
|
228
387
|
var matched = props.matched
|
|
229
388
|
var sessionId = props.sessionId
|
|
@@ -329,7 +488,7 @@ window.__ModuleLoader__.load({
|
|
|
329
488
|
),
|
|
330
489
|
),
|
|
331
490
|
open ? h(RestoreDialog, {
|
|
332
|
-
sessionId: sessionId, messageText: messageText,
|
|
491
|
+
sessionId: sessionId, messageText: messageText,
|
|
333
492
|
onClose: close, preview: preview, loading: loading, error: error,
|
|
334
493
|
applying: applying, done: done, changes: changes,
|
|
335
494
|
previewError: previewError, noSnapshot: noSnapshot, canApply: canApply, applyRestore: applyRestore,
|
|
@@ -337,10 +496,130 @@ window.__ModuleLoader__.load({
|
|
|
337
496
|
)
|
|
338
497
|
}
|
|
339
498
|
|
|
340
|
-
|
|
499
|
+
// Full-screen VSCode-style diff overlay — side-by-side with single scrollbar
|
|
500
|
+
// Full-screen VSCode-style diff overlay — single scrollbar, line numbers
|
|
501
|
+
function DiffOverlay(props) {
|
|
502
|
+
var onClose = props.onClose
|
|
503
|
+
var change = props.change
|
|
504
|
+
|
|
505
|
+
var isCreated = change.kind === 'created'
|
|
506
|
+
var isDeleted = change.kind === 'deleted'
|
|
507
|
+
var isModified = change.kind === 'modified'
|
|
508
|
+
|
|
509
|
+
// 构建 diff 行数据
|
|
510
|
+
var diffRows = []
|
|
511
|
+
|
|
512
|
+
if (change.diff && change.diff.hunks) {
|
|
513
|
+
var oldLine = 0
|
|
514
|
+
var newLine = 0
|
|
515
|
+
|
|
516
|
+
for (var i = 0; i < change.diff.hunks.length; i++) {
|
|
517
|
+
var hunk = change.diff.hunks[i]
|
|
518
|
+
|
|
519
|
+
if (hunk.type === 'removed') {
|
|
520
|
+
diffRows.push({
|
|
521
|
+
type: 'removed',
|
|
522
|
+
text: hunk.value,
|
|
523
|
+
oldLine: ++oldLine,
|
|
524
|
+
newLine: null
|
|
525
|
+
})
|
|
526
|
+
} else if (hunk.type === 'added') {
|
|
527
|
+
diffRows.push({
|
|
528
|
+
type: 'added',
|
|
529
|
+
text: hunk.value,
|
|
530
|
+
oldLine: null,
|
|
531
|
+
newLine: ++newLine
|
|
532
|
+
})
|
|
533
|
+
} else {
|
|
534
|
+
diffRows.push({
|
|
535
|
+
type: 'same',
|
|
536
|
+
text: hunk.value,
|
|
537
|
+
oldLine: ++oldLine,
|
|
538
|
+
newLine: ++newLine
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
var leftLabel = isCreated ? '' : '原始版本'
|
|
545
|
+
var rightLabel = isDeleted ? '' : '修改后版本'
|
|
546
|
+
|
|
547
|
+
// 安全转义 HTML
|
|
548
|
+
function escapeHtml(text) {
|
|
549
|
+
if (!text) return ''
|
|
550
|
+
return text
|
|
551
|
+
.replace(/&/g, '&')
|
|
552
|
+
.replace(/</g, '<')
|
|
553
|
+
.replace(/>/g, '>')
|
|
554
|
+
.replace(/"/g, '"')
|
|
555
|
+
.replace(/'/g, ''')
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return reactDom.createPortal(
|
|
559
|
+
h('div', {
|
|
560
|
+
className: 'dtu-fullscreen-diff',
|
|
561
|
+
onClick: onClose,
|
|
562
|
+
onKeyDown: function (e) {
|
|
563
|
+
if (e.key === 'Escape') onClose()
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
h('div', { className: 'dtu-fullscreen-header', onClick: function (e) { e.stopPropagation() } },
|
|
567
|
+
h('button', {
|
|
568
|
+
className: 'dtu-fullscreen-back',
|
|
569
|
+
onClick: onClose
|
|
570
|
+
}, '← 关闭'),
|
|
571
|
+
h('span', { className: 'dtu-fullscreen-path' }, change.path),
|
|
572
|
+
h('div', null,
|
|
573
|
+
isCreated ? h('span', { style: { color: '#39b54e', fontSize: '11px' } }, '● 新文件') : null,
|
|
574
|
+
isDeleted ? h('span', { style: { color: '#f85149', fontSize: '11px' } }, '● 已删除') : null,
|
|
575
|
+
isModified ? h('span', { style: { color: '#f85149', fontSize: '11px', marginRight: '8px' } }, '● 删除') : null,
|
|
576
|
+
isModified ? h('span', { style: { color: '#39b54e', fontSize: '11px' } }, '● 添加') : null
|
|
577
|
+
),
|
|
578
|
+
h('button', {
|
|
579
|
+
className: 'dtu-fullscreen-close',
|
|
580
|
+
onClick: onClose
|
|
581
|
+
}, '✕')
|
|
582
|
+
),
|
|
583
|
+
h('div', { className: 'dtu-fullscreen-body' },
|
|
584
|
+
h('div', { className: 'dtu-diff-columns-wrapper' },
|
|
585
|
+
leftLabel ? h('div', { className: 'dtu-diff-column-label' }, leftLabel) : null,
|
|
586
|
+
rightLabel ? h('div', { className: 'dtu-diff-column-label' }, rightLabel) : null
|
|
587
|
+
),
|
|
588
|
+
h('div', { className: 'dtu-diff-scroll-container' },
|
|
589
|
+
diffRows.length > 0
|
|
590
|
+
? h('div', null, diffRows.map(function (row, idx) {
|
|
591
|
+
var rowClass = 'dtu-diff-row'
|
|
592
|
+
if (row.type === 'removed') rowClass += ' dtu-diff-row-removed'
|
|
593
|
+
else if (row.type === 'added') rowClass += ' dtu-diff-row-added'
|
|
594
|
+
|
|
595
|
+
var escapedText = escapeHtml(row.text)
|
|
596
|
+
|
|
597
|
+
// 根据行类型决定左右栏内容
|
|
598
|
+
var leftText = (row.type === 'added') ? '' : escapedText
|
|
599
|
+
var rightText = (row.type === 'removed') ? '' : escapedText
|
|
600
|
+
|
|
601
|
+
return h('div', { key: idx, className: rowClass },
|
|
602
|
+
// 左栏
|
|
603
|
+
h('div', { className: 'dtu-diff-cell dtu-diff-cell-left' },
|
|
604
|
+
h('span', { className: 'dtu-diff-line-num' }, row.oldLine || ''),
|
|
605
|
+
leftText
|
|
606
|
+
),
|
|
607
|
+
// 右栏
|
|
608
|
+
h('div', { className: 'dtu-diff-cell dtu-diff-cell-right' },
|
|
609
|
+
h('span', { className: 'dtu-diff-line-num' }, row.newLine || ''),
|
|
610
|
+
rightText
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
}))
|
|
614
|
+
: h('div', { className: 'dtu-diff-empty' }, '(无差异)')
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
),
|
|
618
|
+
document.body
|
|
619
|
+
)
|
|
620
|
+
}function RestoreDialog(props) {
|
|
341
621
|
var sessionId = props.sessionId
|
|
342
622
|
var messageText = props.messageText
|
|
343
|
-
var turn = props.turn
|
|
344
623
|
var onClose = props.onClose
|
|
345
624
|
var preview = props.preview
|
|
346
625
|
var loading = props.loading
|
|
@@ -352,6 +631,18 @@ window.__ModuleLoader__.load({
|
|
|
352
631
|
var noSnapshot = props.noSnapshot
|
|
353
632
|
var canApply = props.canApply
|
|
354
633
|
var applyRestore = props.applyRestore
|
|
634
|
+
|
|
635
|
+
// Diff viewing state
|
|
636
|
+
var showDiffState = useState(null)
|
|
637
|
+
var showDiff = showDiffState[1]
|
|
638
|
+
var viewingDiff = showDiffState[0]
|
|
639
|
+
|
|
640
|
+
// 文件很多时避免一次性渲染大量 DOM(性能优化):只渲染前 200 个,
|
|
641
|
+
// 其余折叠成一条提示。总数仍在标题里显示。
|
|
642
|
+
var MAX_PREVIEW_FILES = 200
|
|
643
|
+
var shownChanges = changes.length > MAX_PREVIEW_FILES
|
|
644
|
+
? changes.slice(0, MAX_PREVIEW_FILES)
|
|
645
|
+
: changes
|
|
355
646
|
|
|
356
647
|
return reactDom.createPortal(
|
|
357
648
|
h('div', { className: 'dtu-overlay', onClick: onClose },
|
|
@@ -377,12 +668,29 @@ window.__ModuleLoader__.load({
|
|
|
377
668
|
? h('div', { className: 'dtu-section' },
|
|
378
669
|
h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
|
|
379
670
|
h('div', { className: 'dtu-files' },
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
h('
|
|
384
|
-
|
|
385
|
-
|
|
671
|
+
h('div', { className: 'dtu-files' },
|
|
672
|
+
shownChanges.map(function (change, idx) {
|
|
673
|
+
var isModified = change.kind === 'modified' && change.diff
|
|
674
|
+
return h('div', {
|
|
675
|
+
key: idx,
|
|
676
|
+
className: 'dtu-file',
|
|
677
|
+
onClick: isModified ? function () { showDiff(isModified ? change : null) } : undefined,
|
|
678
|
+
style: isModified ? { cursor: 'pointer' } : {}
|
|
679
|
+
},
|
|
680
|
+
h('code', {}, change.path),
|
|
681
|
+
h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
|
|
682
|
+
isModified ? h('span', { className: 'dtu-diff-indicator', style: { fontSize: '11px', marginLeft: '4px', opacity: '.6' } }, '👁') : null
|
|
683
|
+
)
|
|
684
|
+
}),
|
|
685
|
+
(changes.length > shownChanges.length)
|
|
686
|
+
? h('div', { className: 'dtu-more' },
|
|
687
|
+
'… 还有 ' + (changes.length - shownChanges.length) + ' 个文件未显示'
|
|
688
|
+
) : null,
|
|
689
|
+
),
|
|
690
|
+
// Full-screen diff overlay
|
|
691
|
+
viewingDiff
|
|
692
|
+
? h(DiffOverlay, { change: viewingDiff, onClose: function () { showDiff(null) } })
|
|
693
|
+
: null,
|
|
386
694
|
),
|
|
387
695
|
) : null,
|
|
388
696
|
(!loading && !previewError && changes.length > 0)
|
|
@@ -447,6 +755,13 @@ window.__ModuleLoader__.load({
|
|
|
447
755
|
// 用与 findIconActions 相同的稳定策略定位操作行容器。
|
|
448
756
|
var actions = findIconActions(row)
|
|
449
757
|
if (!actions) continue
|
|
758
|
+
// DSH 0.1.6+ 的 CSS:有"后续 user/steering 行"的 user 行,其 .actions
|
|
759
|
+
// 默认 opacity:0(仅 hover/focus 显现),portal 进去的撤销按钮会跟着
|
|
760
|
+
// 不可见。给容器打 data-dtu-always,配合全局 [data-dtu-always]
|
|
761
|
+
// {opacity:1!important} 让注入按钮常显。
|
|
762
|
+
if (!actions.hasAttribute('data-dtu-always')) {
|
|
763
|
+
actions.setAttribute('data-dtu-always', '')
|
|
764
|
+
}
|
|
450
765
|
targets.push({ container: actions, matched: target.matched })
|
|
451
766
|
}
|
|
452
767
|
return targets
|
|
@@ -467,4 +782,4 @@ window.__ModuleLoader__.load({
|
|
|
467
782
|
exports.inject = ['slots', 'sessions', 'conversation']
|
|
468
783
|
return module.exports
|
|
469
784
|
},
|
|
470
|
-
})
|
|
785
|
+
})
|
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
readdirSync,
|
|
29
29
|
readlinkSync,
|
|
30
30
|
rmSync,
|
|
31
|
-
|
|
31
|
+
chmodSync,
|
|
32
32
|
createWriteStream,
|
|
33
33
|
createReadStream,
|
|
34
34
|
} from 'node:fs'
|
|
@@ -195,7 +195,7 @@ class SnapshotStore {
|
|
|
195
195
|
* Capture the workspace at cwd into the session's chain.
|
|
196
196
|
* Returns the manifest (or null if unchanged since the session's last).
|
|
197
197
|
*/
|
|
198
|
-
capture(cwd, sessionId, turn) {
|
|
198
|
+
async capture(cwd, sessionId, turn) {
|
|
199
199
|
ensureDir(this.objDir)
|
|
200
200
|
ensureDir(join(this.snapDir, sessionId))
|
|
201
201
|
|
|
@@ -229,13 +229,15 @@ class SnapshotStore {
|
|
|
229
229
|
// Reuse previous object if content unchanged (compare size+mtime via
|
|
230
230
|
// prev manifest, else re-hash the file).
|
|
231
231
|
const prev = prevManifest && prevManifest[rel]
|
|
232
|
-
|
|
232
|
+
const prevMode = prev && prev.mode
|
|
233
|
+
const currentMode = st.mode.toString(8).padStart(4, '0')
|
|
234
|
+
if (prev && prev.size === st.size && prev.mtime === st.mtimeMs && prevMode === currentMode) {
|
|
233
235
|
entry = prev // unchanged — reuse (no new object written)
|
|
234
236
|
} else {
|
|
235
237
|
const hash = hashFile(abs)
|
|
236
238
|
const objPath = join(this.objDir, hash)
|
|
237
239
|
if (!existsSync(objPath)) {
|
|
238
|
-
this.copyIntoObjects(abs, objPath)
|
|
240
|
+
await this.copyIntoObjects(abs, objPath)
|
|
239
241
|
}
|
|
240
242
|
entry = {
|
|
241
243
|
kind: 'file',
|
|
@@ -355,37 +357,46 @@ class SnapshotStore {
|
|
|
355
357
|
* where the newly-materialized file is immediately superseded by the next
|
|
356
358
|
* turn's capture and there is no long-lived object to corrupt.
|
|
357
359
|
*/
|
|
358
|
-
copyIntoObjects(src, dst) {
|
|
360
|
+
async copyIntoObjects(src, dst) {
|
|
361
|
+
let success = false
|
|
359
362
|
try {
|
|
360
363
|
copyFileSync(src, dst)
|
|
364
|
+
success = true
|
|
361
365
|
} catch {
|
|
362
366
|
// e.g. src vanished mid-read; try streaming fallback
|
|
363
367
|
try {
|
|
364
368
|
const rs = createReadStream(src)
|
|
365
369
|
const ws = createWriteStream(dst)
|
|
366
|
-
|
|
367
|
-
return new Promise((resolve, reject) => {
|
|
370
|
+
await new Promise((resolve, reject) => {
|
|
368
371
|
rs.on('error', reject)
|
|
369
372
|
ws.on('error', reject)
|
|
370
373
|
ws.on('finish', resolve)
|
|
371
374
|
})
|
|
375
|
+
success = true
|
|
372
376
|
} catch (e) {
|
|
373
377
|
console.warn('[turn-undo] object write failed:', e.message)
|
|
374
378
|
}
|
|
375
379
|
}
|
|
380
|
+
// Verify the written object hash matches the source
|
|
381
|
+
if (success) {
|
|
382
|
+
const srcHash = hashFile(src)
|
|
383
|
+
const dstHash = hashFile(dst)
|
|
384
|
+
if (srcHash !== dstHash) {
|
|
385
|
+
// Hash mismatch — file was corrupted during copy
|
|
386
|
+
try { unlinkSync(dst) } catch {}
|
|
387
|
+
throw new Error(`Object hash mismatch: expected ${srcHash}, got ${dstHash}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
376
390
|
}
|
|
377
391
|
|
|
378
392
|
/** Materialize an object into the workspace (write-back during restore). */
|
|
379
|
-
materializeObject(objPath, dest) {
|
|
393
|
+
materializeObject(objPath, dest, mode) {
|
|
380
394
|
try {
|
|
381
395
|
unlinkSync(dest)
|
|
382
396
|
} catch { /* may not exist */ }
|
|
383
397
|
ensureDir(dirname(dest))
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
} catch {
|
|
387
|
-
copyFileSync(objPath, dest)
|
|
388
|
-
}
|
|
398
|
+
copyFileSync(objPath, dest)
|
|
399
|
+
if (mode) chmodSync(dest, parseInt(mode, 8))
|
|
389
400
|
}
|
|
390
401
|
|
|
391
402
|
manifestsEqual(a, b) {
|
|
@@ -398,10 +409,122 @@ class SnapshotStore {
|
|
|
398
409
|
if (!eb) return false
|
|
399
410
|
if (ea.kind !== eb.kind) return false
|
|
400
411
|
if (ea.hash !== eb.hash) return false
|
|
412
|
+
if (ea.mode !== eb.mode) return false
|
|
401
413
|
}
|
|
402
414
|
return true
|
|
403
415
|
}
|
|
404
416
|
|
|
417
|
+
/** Read an object file from the content-addressed store and return its text content. */
|
|
418
|
+
readObjectContent(hash) {
|
|
419
|
+
if (!hash) return null
|
|
420
|
+
const objPath = join(this.objDir, hash)
|
|
421
|
+
if (!existsSync(objPath)) return null
|
|
422
|
+
try {
|
|
423
|
+
return readFileSync(objPath, 'utf-8')
|
|
424
|
+
} catch {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Generate a simple line-based diff between two arrays of lines. */
|
|
430
|
+
generateDiff(oldLines, newLines, contextLines = 10) {
|
|
431
|
+
const m = oldLines.length
|
|
432
|
+
const n = newLines.length
|
|
433
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
|
|
434
|
+
for (let i = 1; i <= m; i++) {
|
|
435
|
+
for (let j = 1; j <= n; j++) {
|
|
436
|
+
if (oldLines[i - 1] === newLines[j - 1]) {
|
|
437
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
438
|
+
} else {
|
|
439
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const result = []
|
|
445
|
+
let i = m
|
|
446
|
+
let j = n
|
|
447
|
+
|
|
448
|
+
while (i > 0 || j > 0) {
|
|
449
|
+
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
|
450
|
+
result.push({ type: 'same', value: oldLines[i - 1] })
|
|
451
|
+
i--
|
|
452
|
+
j--
|
|
453
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
454
|
+
result.push({ type: 'added', value: newLines[j - 1] })
|
|
455
|
+
j--
|
|
456
|
+
} else {
|
|
457
|
+
result.push({ type: 'removed', value: oldLines[i - 1] })
|
|
458
|
+
i--
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
result.reverse()
|
|
463
|
+
|
|
464
|
+
// 如果有上下文行数限制,过滤只保留变化行及其上下文
|
|
465
|
+
if (contextLines > 0) {
|
|
466
|
+
// 找出所有变化的行索引
|
|
467
|
+
const changedIndices = new Set()
|
|
468
|
+
for (let idx = 0; idx < result.length; idx++) {
|
|
469
|
+
if (result[idx].type !== 'same') {
|
|
470
|
+
changedIndices.add(idx)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 标记需要包含的行
|
|
475
|
+
const keepIndices = new Set()
|
|
476
|
+
for (const idx of changedIndices) {
|
|
477
|
+
// 保留变化行本身
|
|
478
|
+
keepIndices.add(idx)
|
|
479
|
+
// 保留前后 contextLines 行
|
|
480
|
+
for (let k = 1; k <= contextLines; k++) {
|
|
481
|
+
if (idx - k >= 0) keepIndices.add(idx - k)
|
|
482
|
+
if (idx + k < result.length) keepIndices.add(idx + k)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 过滤结果
|
|
487
|
+
const filtered = []
|
|
488
|
+
for (let idx = 0; idx < result.length; idx++) {
|
|
489
|
+
if (keepIndices.has(idx)) {
|
|
490
|
+
filtered.push(result[idx])
|
|
491
|
+
} else if (result[idx].type === 'same') {
|
|
492
|
+
// 省略相同行标记
|
|
493
|
+
filtered.push({ type: 'same', value: '...' })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 合并连续的省略标记
|
|
498
|
+
const merged = []
|
|
499
|
+
for (const item of filtered) {
|
|
500
|
+
if (item.type === 'same' && item.value === '...') {
|
|
501
|
+
if (merged.length > 0 && merged[merged.length - 1].value === '...') {
|
|
502
|
+
continue // 跳过连续的省略
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
merged.push(item)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return merged
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return result
|
|
512
|
+
}
|
|
513
|
+
|
|
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
|
+
|
|
405
528
|
/** Load snapshot manifests for a session, oldest first. */
|
|
406
529
|
loadChain(sessionId) {
|
|
407
530
|
const dir = join(this.snapDir, sessionId)
|
|
@@ -439,6 +562,7 @@ class SnapshotStore {
|
|
|
439
562
|
|
|
440
563
|
let restoredFiles = 0
|
|
441
564
|
let deletedFiles = 0
|
|
565
|
+
const skippedFiles = [] // Track files that couldn't be restored
|
|
442
566
|
// 1. Write back / refresh files present in target manifest.
|
|
443
567
|
for (const rel of rels) {
|
|
444
568
|
const entry = manifest[rel]
|
|
@@ -448,12 +572,15 @@ class SnapshotStore {
|
|
|
448
572
|
ensureDir(dirname(abs))
|
|
449
573
|
if (entry.kind === 'file') {
|
|
450
574
|
const objPath = join(this.objDir, entry.hash)
|
|
451
|
-
if (!existsSync(objPath))
|
|
452
|
-
|
|
575
|
+
if (!existsSync(objPath)) {
|
|
576
|
+
skippedFiles.push({ path: rel, reason: 'object_missing' })
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
this.materializeObject(objPath, abs, entry.mode)
|
|
453
580
|
restoredFiles++
|
|
454
581
|
}
|
|
455
582
|
} catch (e) {
|
|
456
|
-
|
|
583
|
+
skippedFiles.push({ path: rel, reason: 'materialize_failed', error: e.message })
|
|
457
584
|
}
|
|
458
585
|
}
|
|
459
586
|
|
|
@@ -461,6 +588,7 @@ class SnapshotStore {
|
|
|
461
588
|
// except excluded dirs. Only within cwd. Uses a boundary-unlimited walk
|
|
462
589
|
// (unlike scan, whose caps would silently stop the pruning early).
|
|
463
590
|
const current = this.walkAll(cwd, ignore)
|
|
591
|
+
const failedDeletions = []
|
|
464
592
|
for (const abs of current) {
|
|
465
593
|
if (ignore(abs)) continue
|
|
466
594
|
const rel = relative(cwd, abs).split('\\').join('/')
|
|
@@ -468,18 +596,31 @@ class SnapshotStore {
|
|
|
468
596
|
try {
|
|
469
597
|
rmSync(abs, { force: true })
|
|
470
598
|
deletedFiles++
|
|
471
|
-
} catch {
|
|
599
|
+
} catch (e) {
|
|
600
|
+
failedDeletions.push({ path: rel, error: e.message })
|
|
601
|
+
}
|
|
472
602
|
}
|
|
473
603
|
}
|
|
604
|
+
// If any deletions failed, mark the restore as partially failed
|
|
605
|
+
if (failedDeletions.length > 0) {
|
|
606
|
+
console.warn('[turn-undo] restore failed to delete', failedDeletions.length, 'files:', failedDeletions.map(f => f.path).join(', '))
|
|
607
|
+
}
|
|
474
608
|
|
|
475
|
-
|
|
476
|
-
ok:
|
|
609
|
+
const result = {
|
|
610
|
+
ok: skippedFiles.length === 0 && failedDeletions.length === 0,
|
|
477
611
|
restoredFiles,
|
|
478
612
|
deletedFiles,
|
|
613
|
+
skippedFiles,
|
|
614
|
+
failedDeletions,
|
|
479
615
|
targetTurn,
|
|
480
616
|
restoredTurn: target.turn,
|
|
481
617
|
totalFiles: rels.length,
|
|
482
618
|
}
|
|
619
|
+
// Log skipped files for debugging
|
|
620
|
+
if (skippedFiles.length > 0) {
|
|
621
|
+
console.warn('[turn-undo] restore skipped', skippedFiles.length, 'files:', skippedFiles.map(f => f.path).join(', '))
|
|
622
|
+
}
|
|
623
|
+
return result
|
|
483
624
|
}
|
|
484
625
|
|
|
485
626
|
/**
|
|
@@ -707,7 +848,7 @@ class SnapshotRuntime {
|
|
|
707
848
|
const run = prev.then(async () => {
|
|
708
849
|
// brief delay so writes settle
|
|
709
850
|
await wait(this.delayMs)
|
|
710
|
-
const result = this.store.capture(cwd, sessionId, turn)
|
|
851
|
+
const result = await this.store.capture(cwd, sessionId, turn)
|
|
711
852
|
this.store.cleanup()
|
|
712
853
|
return result
|
|
713
854
|
})
|
|
@@ -887,6 +1028,9 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
887
1028
|
}
|
|
888
1029
|
}
|
|
889
1030
|
|
|
1031
|
+
// Wait for any in-flight snapshot capture so the preview reflects the
|
|
1032
|
+
// latest committed workspace state (avoids showing a stale turn/end).
|
|
1033
|
+
try { await runtime.waitForSnapshots() } catch {}
|
|
890
1034
|
const preview = runtime.store.preview(sessionId, targetTurn)
|
|
891
1035
|
return json(response, 200, preview)
|
|
892
1036
|
}
|
|
@@ -922,6 +1066,18 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
922
1066
|
// empty snapshot chain and skip file restoration.
|
|
923
1067
|
try { await runtime.waitForSnapshots() } catch {}
|
|
924
1068
|
|
|
1069
|
+
// 3.5 Safety snapshot: before this irreversible restore wipes files,
|
|
1070
|
+
// force-capture the CURRENT workspace into the session chain (as a
|
|
1071
|
+
// fractional turn just past the newest snapshot) so the user can
|
|
1072
|
+
// restore again to the pre-undo state if needed. Non-fatal on failure.
|
|
1073
|
+
try {
|
|
1074
|
+
const chain = runtime.store.loadChain(sessionId)
|
|
1075
|
+
const lastTurn = chain.length ? chain[chain.length - 1].turn : 0
|
|
1076
|
+
await runtime.captureNow(cwd, sessionId, lastTurn + 0.999)
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
console.warn('[turn-undo] safety snapshot failed (non-fatal):', e?.message)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
925
1081
|
// 4. Restore files to the best snapshot at/before restoreTurn.
|
|
926
1082
|
let restoreResult
|
|
927
1083
|
if (restoreTurn !== null) {
|
|
@@ -1026,58 +1182,90 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
|
|
|
1026
1182
|
return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
|
|
1027
1183
|
}
|
|
1028
1184
|
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1185
|
+
// 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
|
|
1186
|
+
// baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
|
|
1187
|
+
// (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
|
|
1188
|
+
// baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
|
|
1189
|
+
// (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
|
|
1190
|
+
// 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
|
|
1191
|
+
let baseline = null
|
|
1031
1192
|
for (const m of chain) {
|
|
1032
|
-
if (m.turn ===
|
|
1033
|
-
|
|
1034
|
-
break
|
|
1193
|
+
if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
|
|
1194
|
+
baseline = m
|
|
1035
1195
|
}
|
|
1036
1196
|
}
|
|
1037
|
-
|
|
1197
|
+
const baselineManifest = baseline ? baseline.manifest : {}
|
|
1198
|
+
|
|
1199
|
+
if (chain.length === 0) {
|
|
1038
1200
|
return { ok: true, status: 'ready', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
|
|
1039
1201
|
}
|
|
1040
1202
|
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
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 : {}
|
|
1203
|
+
// 会话最新快照 = 撤销点之后所有改动的累积终点。
|
|
1204
|
+
const latest = chain[chain.length - 1]
|
|
1205
|
+
const latestManifest = latest.manifest
|
|
1053
1206
|
|
|
1054
|
-
// Calculate changes
|
|
1207
|
+
// Calculate changes between latest and baseline: these are the files that
|
|
1208
|
+
// undo (restoring to baseline) will affect — every change made at or after
|
|
1209
|
+
// the target turn.
|
|
1055
1210
|
const changes = []
|
|
1056
|
-
|
|
1211
|
+
|
|
1212
|
+
// Read manifest contents for diff computation
|
|
1213
|
+
const baselineContents = this.readManifestContents(baselineManifest)
|
|
1214
|
+
const latestContents = this.readManifestContents(latestManifest)
|
|
1215
|
+
|
|
1216
|
+
const deleted = []
|
|
1217
|
+
const created = []
|
|
1218
|
+
|
|
1219
|
+
// Collect deleted files
|
|
1220
|
+
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
|
+
}
|
|
1225
|
+
}
|
|
1057
1226
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1227
|
+
// Collect created and modified files
|
|
1228
|
+
for (const rel of Object.keys(latestManifest)) {
|
|
1229
|
+
const entry = latestManifest[rel]
|
|
1230
|
+
if (!baselineManifest[rel]) {
|
|
1061
1231
|
changes.push({ path: rel, kind: 'created' })
|
|
1232
|
+
created.push({ path: rel, hash: entry.hash })
|
|
1062
1233
|
} else {
|
|
1063
|
-
const prevEntry =
|
|
1234
|
+
const prevEntry = baselineManifest[rel]
|
|
1064
1235
|
if (entry.hash !== prevEntry.hash) {
|
|
1065
|
-
|
|
1236
|
+
const oldContent = baselineContents[rel] || ''
|
|
1237
|
+
const newContent = latestContents[rel] || ''
|
|
1238
|
+
const oldLines = oldContent.split('\n')
|
|
1239
|
+
const newLines = newContent.split('\n')
|
|
1240
|
+
const diff = this.generateDiff(oldLines, newLines, 10)
|
|
1241
|
+
changes.push({
|
|
1242
|
+
path: rel,
|
|
1243
|
+
kind: 'modified',
|
|
1244
|
+
diff: { oldLines: oldLines.length, newLines: newLines.length, hunks: diff }
|
|
1245
|
+
})
|
|
1066
1246
|
}
|
|
1067
1247
|
}
|
|
1068
1248
|
}
|
|
1069
1249
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1250
|
+
// Detect rename/move: same content hash, different path
|
|
1251
|
+
for (let i = 0; i < deleted.length; i++) {
|
|
1252
|
+
for (let j = 0; j < created.length; j++) {
|
|
1253
|
+
if (deleted[i].hash === created[j].hash) {
|
|
1254
|
+
deleted[i].matched = true
|
|
1255
|
+
created[j].matched = true
|
|
1256
|
+
}
|
|
1073
1257
|
}
|
|
1074
1258
|
}
|
|
1075
1259
|
|
|
1076
1260
|
return {
|
|
1077
1261
|
ok: true,
|
|
1078
1262
|
status: 'ready',
|
|
1079
|
-
targetTurn
|
|
1263
|
+
targetTurn,
|
|
1080
1264
|
totalChanges: changes.length,
|
|
1081
1265
|
changes,
|
|
1266
|
+
renameMap: deleted.filter(d => d.matched).map(d => ({
|
|
1267
|
+
oldPath: d.path,
|
|
1268
|
+
newPath: created.find(c => c.matched)?.path
|
|
1269
|
+
})).filter(Boolean),
|
|
1082
1270
|
}
|
|
1083
|
-
}
|
|
1271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-turn-undo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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":
|
|
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
|
+
}
|