dsh-turn-undo 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/client.js +333 -29
  2. package/index.js +244 -29
  3. 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). We locate it by taking the parent of
13
- // the row's first <button> (dimension-independent of hashed CSS classes).
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
- // 定位用户/steering 消息的操作行容器。
32
- // 运行时用户行内没有 data-actions-reveal(该属性只在 turn-tail 上);
33
- // 直接用稳定结构:取行内第一个 <button>(copy 等)的 parentElement 作为操作行。
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
- var firstButton = row.querySelector('button')
39
- if (!firstButton) return null
40
- var actions = firstButton.parentElement
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 queued = false
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
- var queueRefresh = function () {
196
- if (queued || !active) return
197
- queued = true
198
- queueMicrotask(function () {
199
- queued = false
200
- refresh()
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
- function RestoreDialog(props) {
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, '&amp;')
552
+ .replace(/</g, '&lt;')
553
+ .replace(/>/g, '&gt;')
554
+ .replace(/"/g, '&quot;')
555
+ .replace(/'/g, '&#039;')
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
- shownChanges.map(function (change, idx) {
388
- return h('div', { key: idx, className: 'dtu-file' },
389
- h('code', {}, change.path),
390
- h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
391
- )
392
- }),
393
- (changes.length > shownChanges.length)
394
- ? h('div', { className: 'dtu-more' },
395
- '… 还有 ' + (changes.length - shownChanges.length) + ' 个文件未显示'
396
- ) : null,
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
- linkSync,
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
- if (prev && prev.size === st.size && prev.mtime === st.mtimeMs) {
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
- rs.pipe(ws)
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
- try {
385
- linkSync(objPath, dest) // hard link back: cheap + safe (object won't be re-modified)
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)) continue
452
- this.materializeObject(objPath, abs)
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
- /* skip un-restorable files */
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 { /* noop */ }
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
- return {
476
- ok: true,
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
  })
@@ -890,7 +1031,9 @@ function createHandler(ctx, runtime, sessions, agents) {
890
1031
  // Wait for any in-flight snapshot capture so the preview reflects the
891
1032
  // latest committed workspace state (avoids showing a stale turn/end).
892
1033
  try { await runtime.waitForSnapshots() } catch {}
893
- const preview = runtime.store.preview(sessionId, targetTurn)
1034
+ const source = await readSession(ctx, sessionId)
1035
+ const cwd = source ? getCwd(source) : undefined
1036
+ const preview = runtime.store.preview(sessionId, targetTurn, cwd)
894
1037
  return json(response, 200, preview)
895
1038
  }
896
1039
 
@@ -932,7 +1075,7 @@ function createHandler(ctx, runtime, sessions, agents) {
932
1075
  try {
933
1076
  const chain = runtime.store.loadChain(sessionId)
934
1077
  const lastTurn = chain.length ? chain[chain.length - 1].turn : 0
935
- await runtime.captureNow(cwd, sessionId, lastTurn + 0.5)
1078
+ await runtime.captureNow(cwd, sessionId, lastTurn + 0.999)
936
1079
  } catch (e) {
937
1080
  console.warn('[turn-undo] safety snapshot failed (non-fatal):', e?.message)
938
1081
  }
@@ -1033,9 +1176,35 @@ export function apply(ctx, config = {}) {
1033
1176
  })
1034
1177
  }
1035
1178
 
1179
+ /**
1180
+ * Build a manifest from the current workspace state without persisting it.
1181
+ * Used by preview when the target turn is beyond the latest snapshot.
1182
+ */
1183
+ SnapshotStore.prototype.buildLiveManifest = function (cwd) {
1184
+ const ignore = makeIgnore(cwd, this.excludes)
1185
+ const files = this.scan(cwd, ignore)
1186
+ if (!files) return null
1187
+ const manifest = {}
1188
+ for (const p of files) {
1189
+ const abs = p
1190
+ let st
1191
+ try { st = lstatSync(abs) } catch { continue }
1192
+ if (!st.isFile()) continue
1193
+ const rel = relative(cwd, abs).split('\\').join('/')
1194
+ manifest[rel] = {
1195
+ kind: 'file',
1196
+ hash: hashFile(abs),
1197
+ size: st.size,
1198
+ mtime: st.mtimeMs,
1199
+ mode: st.mode.toString(8).padStart(4, '0'),
1200
+ }
1201
+ }
1202
+ return manifest
1203
+ }
1204
+
1036
1205
  // Add preview helper to SnapshotStore prototype.
1037
1206
  // Returns the files that changed during the target turn (turn/end vs turn/start).
1038
- SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
1207
+ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1039
1208
  const chain = this.loadChain(sessionId)
1040
1209
  if (targetTurn === null) {
1041
1210
  return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
@@ -1061,27 +1230,69 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
1061
1230
 
1062
1231
  // 会话最新快照 = 撤销点之后所有改动的累积终点。
1063
1232
  const latest = chain[chain.length - 1]
1064
- const latestManifest = latest.manifest
1233
+
1234
+ // If targetTurn is beyond the latest snapshot (e.g., the turn hasn't ended
1235
+ // yet or snapshots were skipped), compare baseline against the CURRENT
1236
+ // workspace state instead of the stale latest snapshot. This ensures the
1237
+ // preview shows meaningful changes even when the active turn has no snapshot.
1238
+ let latestManifest
1239
+ if (targetTurn > latest.turn && cwd) {
1240
+ const live = this.buildLiveManifest(cwd)
1241
+ latestManifest = live || latest.manifest
1242
+ } else {
1243
+ latestManifest = latest.manifest
1244
+ }
1065
1245
 
1066
1246
  // Calculate changes between latest and baseline: these are the files that
1067
1247
  // undo (restoring to baseline) will affect — every change made at or after
1068
1248
  // the target turn.
1069
1249
  const changes = []
1250
+
1251
+ // Read manifest contents for diff computation
1252
+ const baselineContents = this.readManifestContents(baselineManifest)
1253
+ const latestContents = this.readManifestContents(latestManifest)
1254
+
1255
+ const deleted = []
1256
+ const created = []
1257
+
1258
+ // Collect deleted files
1259
+ for (const rel of Object.keys(baselineManifest)) {
1260
+ if (!latestManifest[rel]) {
1261
+ changes.push({ path: rel, kind: 'deleted' })
1262
+ deleted.push({ path: rel, hash: baselineManifest[rel].hash })
1263
+ }
1264
+ }
1265
+
1266
+ // Collect created and modified files
1070
1267
  for (const rel of Object.keys(latestManifest)) {
1071
1268
  const entry = latestManifest[rel]
1072
1269
  if (!baselineManifest[rel]) {
1073
1270
  changes.push({ path: rel, kind: 'created' })
1271
+ created.push({ path: rel, hash: entry.hash })
1074
1272
  } else {
1075
1273
  const prevEntry = baselineManifest[rel]
1076
1274
  if (entry.hash !== prevEntry.hash) {
1077
- changes.push({ path: rel, kind: 'modified' })
1275
+ const oldContent = baselineContents[rel] || ''
1276
+ const newContent = latestContents[rel] || ''
1277
+ const oldLines = oldContent.split('\n')
1278
+ const newLines = newContent.split('\n')
1279
+ const diff = this.generateDiff(oldLines, newLines, 10)
1280
+ changes.push({
1281
+ path: rel,
1282
+ kind: 'modified',
1283
+ diff: { oldLines: oldLines.length, newLines: newLines.length, hunks: diff }
1284
+ })
1078
1285
  }
1079
1286
  }
1080
1287
  }
1081
1288
 
1082
- for (const rel of Object.keys(baselineManifest)) {
1083
- if (!latestManifest[rel]) {
1084
- changes.push({ path: rel, kind: 'deleted' })
1289
+ // Detect rename/move: same content hash, different path
1290
+ for (let i = 0; i < deleted.length; i++) {
1291
+ for (let j = 0; j < created.length; j++) {
1292
+ if (deleted[i].hash === created[j].hash) {
1293
+ deleted[i].matched = true
1294
+ created[j].matched = true
1295
+ }
1085
1296
  }
1086
1297
  }
1087
1298
 
@@ -1091,5 +1302,9 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
1091
1302
  targetTurn,
1092
1303
  totalChanges: changes.length,
1093
1304
  changes,
1305
+ renameMap: deleted.filter(d => d.matched).map(d => ({
1306
+ oldPath: d.path,
1307
+ newPath: created.find(c => c.matched)?.path
1308
+ })).filter(Boolean),
1094
1309
  }
1095
- }
1310
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-undo",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",