dsh-turn-undo 0.0.2 → 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/client.js +333 -29
- package/index.js +202 -26
- package/package.json +1 -1
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
|
|
|
@@ -100,6 +167,32 @@ window.__ModuleLoader__.load({
|
|
|
100
167
|
'.dtu-btn{padding:8px 16px;border:0;border-radius:6px;font-size:14px;cursor:pointer}',
|
|
101
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)}',
|
|
102
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}',
|
|
103
196
|
].join('')
|
|
104
197
|
document.head.appendChild(styleEl)
|
|
105
198
|
}
|
|
@@ -175,16 +268,24 @@ window.__ModuleLoader__.load({
|
|
|
175
268
|
var sessionId = props.sessionId
|
|
176
269
|
var openRestoredSessionProp = props.openRestoredSession
|
|
177
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 都稳定。
|
|
178
278
|
var nodes = useChat(function (snapshot) {
|
|
179
279
|
return snapshot.nodes ? snapshot.nodes.values() : []
|
|
180
|
-
})
|
|
280
|
+
}, sameNodeList)
|
|
281
|
+
|
|
181
282
|
var targetsState = useState([])
|
|
182
283
|
var targets = targetsState[0]
|
|
183
284
|
var setTargets = targetsState[1]
|
|
184
285
|
|
|
185
286
|
useLayoutEffect(function () {
|
|
186
287
|
var active = true
|
|
187
|
-
var
|
|
288
|
+
var timer = 0
|
|
188
289
|
var refresh = function () {
|
|
189
290
|
if (!active) return
|
|
190
291
|
var next = collectPortalTargets(nodes)
|
|
@@ -192,19 +293,55 @@ window.__ModuleLoader__.load({
|
|
|
192
293
|
return samePortalTargets(current, next) ? current : next
|
|
193
294
|
})
|
|
194
295
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|
|
202
338
|
}
|
|
203
339
|
refresh()
|
|
204
340
|
var observer = new MutationObserver(queueRefresh)
|
|
205
341
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
206
342
|
return function () {
|
|
207
343
|
active = false
|
|
344
|
+
if (timer) clearTimeout(timer)
|
|
208
345
|
observer.disconnect()
|
|
209
346
|
}
|
|
210
347
|
}, [nodes])
|
|
@@ -225,6 +362,27 @@ window.__ModuleLoader__.load({
|
|
|
225
362
|
return portals
|
|
226
363
|
}
|
|
227
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
|
+
|
|
228
386
|
function RestoreMessageAction(props) {
|
|
229
387
|
var matched = props.matched
|
|
230
388
|
var sessionId = props.sessionId
|
|
@@ -338,7 +496,128 @@ window.__ModuleLoader__.load({
|
|
|
338
496
|
)
|
|
339
497
|
}
|
|
340
498
|
|
|
341
|
-
|
|
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) {
|
|
342
621
|
var sessionId = props.sessionId
|
|
343
622
|
var messageText = props.messageText
|
|
344
623
|
var onClose = props.onClose
|
|
@@ -352,6 +631,11 @@ 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]
|
|
355
639
|
|
|
356
640
|
// 文件很多时避免一次性渲染大量 DOM(性能优化):只渲染前 200 个,
|
|
357
641
|
// 其余折叠成一条提示。总数仍在标题里显示。
|
|
@@ -384,16 +668,29 @@ window.__ModuleLoader__.load({
|
|
|
384
668
|
? h('div', { className: 'dtu-section' },
|
|
385
669
|
h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
|
|
386
670
|
h('div', { className: 'dtu-files' },
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
h('
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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,
|
|
397
694
|
),
|
|
398
695
|
) : null,
|
|
399
696
|
(!loading && !previewError && changes.length > 0)
|
|
@@ -458,6 +755,13 @@ window.__ModuleLoader__.load({
|
|
|
458
755
|
// 用与 findIconActions 相同的稳定策略定位操作行容器。
|
|
459
756
|
var actions = findIconActions(row)
|
|
460
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
|
+
}
|
|
461
765
|
targets.push({ container: actions, matched: target.matched })
|
|
462
766
|
}
|
|
463
767
|
return targets
|
|
@@ -478,4 +782,4 @@ window.__ModuleLoader__.load({
|
|
|
478
782
|
exports.inject = ['slots', 'sessions', 'conversation']
|
|
479
783
|
return module.exports
|
|
480
784
|
},
|
|
481
|
-
})
|
|
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
|
})
|
|
@@ -932,7 +1073,7 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
932
1073
|
try {
|
|
933
1074
|
const chain = runtime.store.loadChain(sessionId)
|
|
934
1075
|
const lastTurn = chain.length ? chain[chain.length - 1].turn : 0
|
|
935
|
-
await runtime.captureNow(cwd, sessionId, lastTurn + 0.
|
|
1076
|
+
await runtime.captureNow(cwd, sessionId, lastTurn + 0.999)
|
|
936
1077
|
} catch (e) {
|
|
937
1078
|
console.warn('[turn-undo] safety snapshot failed (non-fatal):', e?.message)
|
|
938
1079
|
}
|
|
@@ -1067,21 +1208,52 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
|
|
|
1067
1208
|
// undo (restoring to baseline) will affect — every change made at or after
|
|
1068
1209
|
// the target turn.
|
|
1069
1210
|
const changes = []
|
|
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
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Collect created and modified files
|
|
1070
1228
|
for (const rel of Object.keys(latestManifest)) {
|
|
1071
1229
|
const entry = latestManifest[rel]
|
|
1072
1230
|
if (!baselineManifest[rel]) {
|
|
1073
1231
|
changes.push({ path: rel, kind: 'created' })
|
|
1232
|
+
created.push({ path: rel, hash: entry.hash })
|
|
1074
1233
|
} else {
|
|
1075
1234
|
const prevEntry = baselineManifest[rel]
|
|
1076
1235
|
if (entry.hash !== prevEntry.hash) {
|
|
1077
|
-
|
|
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
|
+
})
|
|
1078
1246
|
}
|
|
1079
1247
|
}
|
|
1080
1248
|
}
|
|
1081
1249
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
+
}
|
|
1085
1257
|
}
|
|
1086
1258
|
}
|
|
1087
1259
|
|
|
@@ -1091,5 +1263,9 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
|
|
|
1091
1263
|
targetTurn,
|
|
1092
1264
|
totalChanges: changes.length,
|
|
1093
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),
|
|
1094
1270
|
}
|
|
1095
|
-
}
|
|
1271
|
+
}
|