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.
Files changed (4) hide show
  1. package/README.md +3 -3
  2. package/client.js +342 -27
  3. package/index.js +238 -50
  4. package/package.json +6 -3
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 功能
6
6
 
7
7
  - ✅ 每条用户消息的操作栏注入一个“撤销”按钮(↺)。
8
- - ✅ 弹窗显示本轮会恢复/删除/修改的文件,并给出警告。
8
+ - ✅ 弹窗显示撤销该消息后会影响的全部文件——包括该消息之后所有轮次的改动(不只是消息本轮的),并给出警告。
9
9
  - ✅ 确认后:
10
10
  - 恢复工作区文件到**发送该消息之前**的快照状态。
11
11
  - 通过 DSH 原生的 `sessionController.fork` 从该消息之前 fork 出新会话。
@@ -24,7 +24,7 @@ dsh plugin --profile <name> add dsh-turn-undo
24
24
  ## 使用
25
25
 
26
26
  1. 在对话中,每条用户消息的操作栏会出现撤销按钮。
27
- 2. 点击按钮,弹窗列出本轮影响的文件。
27
+ 2. 点击按钮,弹窗列出撤销该消息后会影响的文件(该消息及之后所有轮次的改动)。
28
28
  3. 点击“确认恢复并继续”:
29
29
  - 文件恢复。
30
30
  - 旧会话被标记为 `(已撤销)`。
@@ -47,7 +47,7 @@ dsh plugin --profile <name> add dsh-turn-undo
47
47
  - 对“第一条用户消息”前面没有 completed turn 时,fall back 到 `ctx.sessionController.create({ cwd, agentPreset })` 创建空白会话。
48
48
  - 旧会话通过 `ctx.sessionController.rename` 加上 `(已撤销)` 前缀。
49
49
  - **HTTP API**:
50
- - `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览本轮影响。
50
+ - `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览撤销该消息后会影响的文件(该消息之后所有轮次改动的并集)。
51
51
  - `POST /api/turn-undo`(body `{ sessionId, messageSeq, promptText }`):执行恢复 + fork。
52
52
 
53
53
  ### 客户端(`client.js`)
package/client.js CHANGED
@@ -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
 
@@ -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 queued = false
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
- var queueRefresh = function () {
195
- if (queued || !active) return
196
- queued = true
197
- queueMicrotask(function () {
198
- queued = false
199
- refresh()
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, turn: messageSeq,
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
- 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) {
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
- changes.map(function (change, idx) {
381
- return h('div', { key: idx, className: 'dtu-file' },
382
- h('code', {}, change.path),
383
- h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
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
- 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
  })
@@ -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
- // The turn/end snapshot shows the workspace after AI work in this turn.
1030
- let target = null
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 === targetTurn) {
1033
- target = m
1034
- break
1193
+ if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
1194
+ baseline = m
1035
1195
  }
1036
1196
  }
1037
- if (!target) {
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
- // Use the newest snapshot strictly before this turn as the baseline.
1042
- // It is normally the turn/start snapshot (T - 0.5), but if an intermediate
1043
- // turn had no file changes, no manifest was written, so we fall back to the
1044
- // most recent available baseline. For the very first turn we use an empty
1045
- // baseline.
1046
- let prev = null
1047
- for (const m of chain) {
1048
- if (m.turn < targetTurn && (prev === null || m.turn > prev.turn)) {
1049
- prev = m
1050
- }
1051
- }
1052
- const prevManifest = prev ? prev.manifest : {}
1203
+ // 会话最新快照 = 撤销点之后所有改动的累积终点。
1204
+ const latest = chain[chain.length - 1]
1205
+ const latestManifest = latest.manifest
1053
1206
 
1054
- // Calculate changes: files that differ between turn/start and turn/end.
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
- const targetManifest = target.manifest
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
- for (const rel of Object.keys(targetManifest)) {
1059
- const entry = targetManifest[rel]
1060
- if (!prevManifest[rel]) {
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 = prevManifest[rel]
1234
+ const prevEntry = baselineManifest[rel]
1064
1235
  if (entry.hash !== prevEntry.hash) {
1065
- changes.push({ path: rel, kind: 'modified' })
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
- for (const rel of Object.keys(prevManifest)) {
1071
- if (!targetManifest[rel]) {
1072
- changes.push({ path: rel, kind: 'deleted' })
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: target.turn,
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.1",
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": "github:gege9527/dsh-turn-undo",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/gege9527/dsh-turn-undo.git"
30
+ },
28
31
  "bugs": "https://github.com/gege9527/dsh-turn-undo/issues",
29
32
  "homepage": "https://github.com/gege9527/dsh-turn-undo#readme",
30
33
  "license": "MIT",
@@ -39,4 +42,4 @@
39
42
  "optional": false
40
43
  }
41
44
  }
42
- }
45
+ }