dsh-git-changes 0.0.0-stage → 0.1.0

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +105 -0
  3. package/README.md +122 -2
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/vendor/monaco/LICENSE +21 -0
  6. package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
  7. package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  8. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
  9. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
  10. package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
  11. package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  12. package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  13. package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  14. package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  15. package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  16. package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  17. package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  18. package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  19. package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  20. package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  21. package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  22. package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  23. package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  24. package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  25. package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  26. package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  27. package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  28. package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  29. package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  30. package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  31. package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  32. package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  33. package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  34. package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  35. package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  36. package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  37. package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  38. package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  39. package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  40. package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  41. package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  42. package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  43. package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  44. package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  45. package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  46. package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  47. package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  48. package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  49. package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  50. package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  51. package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  52. package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  53. package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  54. package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  55. package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  56. package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  57. package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  58. package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  59. package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  60. package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  61. package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  62. package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  63. package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  64. package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  65. package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  66. package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  67. package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  68. package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  69. package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  70. package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  71. package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  72. package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  73. package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  74. package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  75. package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  76. package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  77. package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  78. package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  79. package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  80. package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  81. package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  82. package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  83. package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  84. package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  85. package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  86. package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  87. package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  88. package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  89. package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  90. package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  91. package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
  92. package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
  93. package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
  94. package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
  95. package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
  96. package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
  97. package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  98. package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
  99. package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
  100. package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
  101. package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  102. package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
  103. package/assets/vendor/monaco/vs/loader.js +10 -0
  104. package/cordis.patch.yml +3 -0
  105. package/docs/images/git-changes-diff.png +0 -0
  106. package/icon.svg +10 -0
  107. package/lib/client.js +9418 -0
  108. package/lib/host/assets.js +161 -0
  109. package/lib/host/commit-message.js +158 -0
  110. package/lib/host/files-index.js +206 -0
  111. package/lib/host/git-write.js +194 -0
  112. package/lib/host/git.js +1147 -0
  113. package/lib/host/lsp.js +842 -0
  114. package/lib/host/routes.js +976 -0
  115. package/lib/host/session.js +84 -0
  116. package/lib/host/util.js +107 -0
  117. package/lib/index.js +33 -0
  118. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  119. package/licenses/vscode-theme-seti-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  121. package/locale/en.json +6 -0
  122. package/locale/zh.json +6 -0
  123. package/package.json +63 -3
  124. package/src/client/00-const.js +79 -0
  125. package/src/client/05-utils.js +277 -0
  126. package/src/client/10-i18n.js +548 -0
  127. package/src/client/12-vscode-icons.js +460 -0
  128. package/src/client/15-theme.js +509 -0
  129. package/src/client/20-monaco.js +1404 -0
  130. package/src/client/22-lsp.js +592 -0
  131. package/src/client/25-tree.js +563 -0
  132. package/src/client/28-changes.js +1163 -0
  133. package/src/client/30-page.js +2259 -0
  134. package/src/client/35-pill.js +185 -0
  135. package/src/client/40-commands.js +248 -0
  136. package/src/client/45-css.js +357 -0
  137. package/src/client/47-pickers.js +457 -0
  138. package/src/client/50-apply.js +207 -0
  139. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,2259 @@
1
+ /* dsh-git-changes client half — the review page (toolbar, code area, tree, status). */
2
+
3
+ /** Cross-component runtime state: the mounted page controller plus optional services. */
4
+ const runtimeState = {
5
+ page: null,
6
+ sessionId: null,
7
+ right: undefined,
8
+ tabs: undefined,
9
+ }
10
+
11
+ /**
12
+ * Per-session page state that survives the right-sidebar tab body unmounting.
13
+ * DSH tears down the previous conversation's sidebar tab bodies when the user
14
+ * switches sessions (and may unmount inactive tab bodies when switching tabs);
15
+ * without this store every remount started from scratch, so a session switch
16
+ * looked like "all open files closed + git version reset". Memory only: the
17
+ * stored paths belong to a live workspace/commit, never to a DSH restart.
18
+ */
19
+ const pageSessionStore = new Map()
20
+ /** Upper bound of remembered sessions (LRU eviction keeps memory bounded). */
21
+ const PAGE_SESSION_STORE_LIMIT = 40
22
+
23
+ /**
24
+ * Read one session's remembered page state (LRU-touched).
25
+ * @param {string} sessionId session identity
26
+ * @returns {object | null} remembered state or null
27
+ */
28
+ function rememberedPageState(sessionId) {
29
+ const key = sessionId ? String(sessionId) : ''
30
+ if (!key) return null
31
+ const value = pageSessionStore.get(key)
32
+ if (!value) return null
33
+ pageSessionStore.delete(key)
34
+ pageSessionStore.set(key, value)
35
+ return value
36
+ }
37
+
38
+ /**
39
+ * Remember one session's page state for the next mount of this tab body.
40
+ * @param {string} sessionId session identity
41
+ * @param {object | null} state page state snapshot
42
+ */
43
+ function rememberPageState(sessionId, state) {
44
+ const key = sessionId ? String(sessionId) : ''
45
+ if (!key || !state) return
46
+ pageSessionStore.delete(key)
47
+ pageSessionStore.set(key, state)
48
+ while (pageSessionStore.size > PAGE_SESSION_STORE_LIMIT) {
49
+ const oldest = pageSessionStore.keys().next()
50
+ if (oldest.done) break
51
+ pageSessionStore.delete(oldest.value)
52
+ }
53
+ }
54
+
55
+ /** Read the last-used comparison baseline (survives app restarts). */
56
+ function storedBaseline() {
57
+ try {
58
+ const value = localStorage.getItem('dsh-git-changes.mode')
59
+ return ['turn', 'git', 'revision', 'code'].includes(value) ? value : ''
60
+ } catch (error) {
61
+ return ''
62
+ }
63
+ }
64
+
65
+ /** Read the last-used staged/unstaged scope for git comparisons. */
66
+ function storedChangeScope() {
67
+ try {
68
+ return localStorage.getItem('dsh-git-changes.changeScope') === 'staged' ? 'staged' : 'unstaged'
69
+ } catch (error) {
70
+ return 'unstaged'
71
+ }
72
+ }
73
+
74
+ /** The client plugin context captured by `apply` (used for optional event subscriptions). */
75
+ const globalCtxRef = { current: null }
76
+
77
+ /** Last known copied-path feedback timer (module scope so remounts do not stack). */
78
+ let copiedTimer = null
79
+
80
+ /**
81
+ * Editor cursor position as a tiny external store: the status bar is the only
82
+ * consumer, so a click/selection in the editor re-renders that one line instead
83
+ * of the whole review page (file tree + tab strip included). That full-page
84
+ * re-render was the reason a click could take a visible moment to paint the
85
+ * word-selection background.
86
+ */
87
+ const cursorStore = {
88
+ value: { line: 1, column: 1 },
89
+ listeners: new Set(),
90
+ frame: 0,
91
+ pending: null,
92
+ set(position) {
93
+ if (!position) return
94
+ const next = { line: Number(position.line) || 1, column: Number(position.column) || 1 }
95
+ if (next.line === this.value.line && next.column === this.value.column) return
96
+ this.pending = next
97
+ if (this.frame) return
98
+ const flush = () => {
99
+ this.frame = 0
100
+ const value = this.pending
101
+ this.pending = null
102
+ if (!value) return
103
+ this.value = value
104
+ for (const listener of this.listeners) {
105
+ try { listener() } catch (error) { /* ignore */ }
106
+ }
107
+ }
108
+ try { this.frame = requestAnimationFrame(flush) } catch (error) { flush() }
109
+ },
110
+ reset() { this.value = { line: 1, column: 1 } },
111
+ subscribe(listener) {
112
+ this.listeners.add(listener)
113
+ return () => this.listeners.delete(listener)
114
+ },
115
+ }
116
+
117
+ /** Subscribe one component to the cursor store. */
118
+ function useCursorPosition() {
119
+ const [value, setValue] = React.useState(cursorStore.value)
120
+ React.useEffect(() => cursorStore.subscribe(() => setValue(cursorStore.value)), [])
121
+ return value
122
+ }
123
+
124
+ /**
125
+ * Text width helper: ellipsis in the middle so file extensions stay visible.
126
+ * @param {string} text source text
127
+ * @param {number} max maximum characters
128
+ * @returns {string}
129
+ */
130
+ function ellipsisMiddle(text, max) {
131
+ const value = String(text || '')
132
+ if (value.length <= max) return value
133
+ const half = Math.max(1, Math.floor((max - 1) / 2))
134
+ return value.slice(0, half) + '…' + value.slice(value.length - (max - half - 1))
135
+ }
136
+
137
+ /**
138
+ * Copy text to the clipboard with a legacy textarea fallback.
139
+ * @param {string} text text to copy
140
+ * @returns {Promise<boolean>}
141
+ */
142
+ async function copyText(text) {
143
+ try {
144
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
145
+ await navigator.clipboard.writeText(text)
146
+ return true
147
+ }
148
+ } catch (error) { /* fall through */ }
149
+ try {
150
+ const area = document.createElement('textarea')
151
+ area.value = text
152
+ area.style.position = 'fixed'
153
+ area.style.opacity = '0'
154
+ document.body.appendChild(area)
155
+ area.select()
156
+ const ok = document.execCommand('copy')
157
+ document.body.removeChild(area)
158
+ return ok
159
+ } catch (error) {
160
+ return false
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Map a path recorded by workspace-changes (absolute host path or workspace
166
+ * relative, either separator) onto a workspace-relative posix path. The TURN
167
+ * recorder may record absolute paths for file-tool edits, while the review
168
+ * routes and tree use cwd-relative paths.
169
+ * @param {string} rawPath recorded path
170
+ * @param {string | undefined} cwd session working directory
171
+ * @returns {string} workspace-relative posix path (unchanged when not under cwd)
172
+ */
173
+ function toWorkspaceRel(rawPath, cwd) {
174
+ const value = normalizeRel(rawPath)
175
+ if (!value) return ''
176
+ const absolute = /^[\\/]/.test(String(rawPath)) || /^[a-zA-Z]:[\\/]/.test(String(rawPath))
177
+ const rootPath = String(cwd || '').replace(/\\/g, '/')
178
+ const windows = /^[a-zA-Z]:\//.test(rootPath) || /^\/\/[^/]+\/[^/]+/.test(rootPath)
179
+ const root = normalizeRel(rootPath).replace(/\/+$/, '')
180
+ if (root && absolute) {
181
+ const comparedValue = windows ? value.toLowerCase() : value
182
+ const comparedRoot = windows ? root.toLowerCase() : root
183
+ if (comparedValue === comparedRoot) return ''
184
+ if (comparedValue.startsWith(comparedRoot + '/')) return value.slice(root.length + 1)
185
+ }
186
+ return value
187
+ }
188
+
189
+ /**
190
+ * The right-sidebar / builtin preview resource address for one workspace file.
191
+ * @param {string} sessionId session identity
192
+ * @param {string} rel workspace-relative path * @returns {string} `dsh-resource://file/session/…` address
193
+ */
194
+ function fileAddressOf(sessionId, rel) {
195
+ const segments = segmentsOf(rel).map((segment) => encodeURIComponent(segment))
196
+ return FILE_ADDRESS_PREFIX + encodeURIComponent(sessionId) + '/' + segments.join('/')
197
+ }
198
+
199
+ /**
200
+ * The builtin hunk-review resource address for one turn.
201
+ * @param {string} sessionId session identity
202
+ * @param {number} seq `workspace/changes` event sequence
203
+ * @param {number} turn turn number
204
+ * @returns {string} `dsh-resource://changes-review/session/…` address
205
+ */
206
+ function changesReviewAddressOf(sessionId, seq, turn) {
207
+ return CHANGES_REVIEW_PREFIX + encodeURIComponent(sessionId) + '/' + String(seq) + '/' + String(turn)
208
+ }
209
+
210
+ /**
211
+ * Call `openResource` defensively; returns false instead of throwing.
212
+ * @param {any} right sidebarRight controller
213
+ * @param {string} address resource address
214
+ * @param {object} [options] navigation options
215
+ * @returns {boolean}
216
+ */
217
+ function openResourceSafe(right, address, options) {
218
+ if (!right || typeof right.openResource !== 'function') return false
219
+ try {
220
+ right.openResource(address, options || undefined)
221
+ return true
222
+ } catch (error) {
223
+ log('warn', 'openResource failed', address, error)
224
+ return false
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Small icon button.
230
+ * @param {object} props button props
231
+ * @returns {any} React element
232
+ */
233
+ function IconButton(props) {
234
+ const { title, label, onClick, active, disabled, className } = props
235
+ return h('button', {
236
+ type: 'button',
237
+ className: cx('drp-icon-btn', active && 'drp-icon-btn-on', className),
238
+ title,
239
+ 'aria-label': title,
240
+ disabled: disabled === true,
241
+ onClick: disabled === true ? undefined : onClick,
242
+ }, label)
243
+ }
244
+
245
+ /**
246
+ * Non-code / binary / oversized handoff card: never a blank page.
247
+ * @param {object} props component props
248
+ * @returns {any} React element
249
+ */
250
+ function HandoffCard(props) {
251
+ const { sessionId, path, kind, t, right, absolutePath, bytes, onCopy, note } = props
252
+ const name = basename(path)
253
+ const isCodeButBlocked = kind === 'binary' || kind === 'oversized'
254
+ const [feedback, setFeedback] = React.useState('')
255
+ /** Open the file through the builtin DSH preview. */
256
+ const openPreview = () => {
257
+ const ok = openResourceSafe(right, fileAddressOf(sessionId, path))
258
+ if (!ok) {
259
+ setFeedback(t('error.openFailed', { message: 'preview' }))
260
+ void copyText(absolutePath || path)
261
+ }
262
+ }
263
+ /** Copy the absolute (or relative) location. */
264
+ const copy = async () => {
265
+ const ok = await copyText(absolutePath || path)
266
+ setFeedback(ok ? t('common.copied') : '')
267
+ onCopy?.()
268
+ }
269
+ return h(
270
+ 'div',
271
+ { className: 'drp-handoff' },
272
+ h('div', { className: 'drp-handoff-icon' }, isCodeButBlocked ? '⚠' : '¶'),
273
+ h('div', { className: 'drp-handoff-title' }, isCodeButBlocked
274
+ ? (kind === 'binary' ? t('status.binary') : t('status.oversized', { size: fmtInt(Math.round((bytes || 0) / 1024)) + ' KiB' }))
275
+ : t('handoff.title')),
276
+ h('div', { className: 'drp-handoff-path' }, absolutePath || path),
277
+ h('div', { className: 'drp-handoff-body' }, isCodeButBlocked
278
+ ? t('handoff.body', { name })
279
+ : t('handoff.body', { name })),
280
+ h(
281
+ 'div',
282
+ { className: 'drp-handoff-actions' },
283
+ h('button', { type: 'button', className: 'drp-btn drp-btn-primary', onClick: openPreview }, t('action.openInPreview')),
284
+ h('button', { type: 'button', className: 'drp-btn', onClick: () => { void copy() } }, t('action.copyPath')),
285
+ ),
286
+ feedback ? h('div', { className: 'drp-handoff-note' }, feedback) : null,
287
+ note ? h('div', { className: 'drp-handoff-note' }, note) : null,
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Empty placeholder shown before a file is selected.
293
+ * @param {object} props component props
294
+ * @returns {any} React element
295
+ */
296
+ function EmptyState(props) {
297
+ const { t, latest } = props
298
+ return h(
299
+ 'div',
300
+ { className: 'drp-empty' },
301
+ h('div', { className: 'drp-empty-icon' }, '⌥'),
302
+ h('div', { className: 'drp-empty-title' }, t('empty.title')),
303
+ h('div', { className: 'drp-empty-body' }, t('empty.body')),
304
+ latest && latest.available && latest.summary
305
+ ? h('div', { className: 'drp-empty-hint' }, t('hint.turnMulti', { count: String(latest.summary.files.length) }))
306
+ : null,
307
+ )
308
+ }
309
+
310
+ /**
311
+ * The editor status bar. Cursor moves are the only high-frequency update in the
312
+ * page, so this component subscribes to the cursor store itself and React never
313
+ * re-renders the tree / tab strip for a click.
314
+ * @param {object} props component props
315
+ * @returns {any} React element
316
+ */
317
+ function ReviewStatusBar(props) {
318
+ const { statusText, file, gitText, t, narrow } = props
319
+ const cursor = useCursorPosition()
320
+ const fileReady = file && file.status === 'ready'
321
+ return h(
322
+ 'div',
323
+ { className: 'drp-status' },
324
+ h('span', { className: 'drp-status-text', title: statusText }, statusText),
325
+ h('span', { className: 'drp-spacer' }),
326
+ fileReady && !narrow ? h('span', {}, t('status.fileInfo', { lines: fmtInt(file.lines), language: file.language })) : null,
327
+ gitText ? h('span', {}, gitText) : null,
328
+ h('span', {}, t('status.lines', { line: String(cursor.line), column: String(cursor.column) })),
329
+ )
330
+ }
331
+
332
+ /** Empty hunk counters (stable identity so re-applying it skips re-renders). */
333
+ const EMPTY_HUNK_META = { total: 0, added: 0, deleted: 0 }
334
+
335
+ /**
336
+ * Normalize one `/file` route payload into the page's file record.
337
+ * @param {string} path workspace-relative path
338
+ * @param {any} value host response
339
+ * @returns {object} file record
340
+ */
341
+ function fileRecordOf(path, value) {
342
+ if (value && value.kind === 'text') {
343
+ return {
344
+ status: 'ready',
345
+ path,
346
+ kind: 'text',
347
+ text: value.text || '',
348
+ lines: Number(value.lines) || splitLines(value.text || '').length,
349
+ language: languageOf(path),
350
+ version: value.version,
351
+ absolutePath: value.absolutePath,
352
+ bytes: value.bytes,
353
+ }
354
+ }
355
+ if (!value || !value.kind) {
356
+ return { status: 'handoff', path, kind: 'external', absolutePath: value && value.absolutePath, bytes: value && value.bytes }
357
+ }
358
+ return { status: 'handoff', path, kind: value.kind, absolutePath: value.absolutePath, bytes: value.bytes }
359
+ }
360
+
361
+ /**
362
+ * The code review page.
363
+ * @param {object} props component props (`sessionId`, `t`, `useSessions`, plus services)
364
+ * @returns {any} React element
365
+ */
366
+ function ReviewPage(props) {
367
+ const { sessionId, t, useSessions, useTabInfo, services, address } = props
368
+ const right = services && services.right
369
+ const rootRef = React.useRef(null)
370
+ const abortRef = React.useRef(null)
371
+ const prefetchAbortRef = React.useRef(null)
372
+ const fileTokenRef = React.useRef(0)
373
+ const latestTokenRef = React.useRef(0)
374
+ const revisionTokenRef = React.useRef(0)
375
+ const historyRef = React.useRef({ past: [], future: [] })
376
+ const handlersRef = React.useRef({})
377
+ const initialisedRef = React.useRef(false)
378
+ const autoOpenedRef = React.useRef(false)
379
+ const sessionRef = React.useRef(sessionId)
380
+ /** Latest render snapshot of the state that must survive this body's unmount. */
381
+ const pageSnapshotRef = React.useRef(null)
382
+ /** One-shot flag: the restored active file still has to be read from the Host. */
383
+ const restoreLoadRef = React.useRef(false)
384
+ /** Snapshot-effect bookkeeping: the last (snapshotKey, path) it force-loaded. */
385
+ const lastSnapshotLoadRef = React.useRef(null)
386
+ const editorControlsRef = React.useRef(null)
387
+ const fontSizeRef = React.useRef(13)
388
+ const gitStatusRef = React.useRef(null)
389
+ const pendingRevealRef = React.useRef(null)
390
+ /** Per-path file payload cache: a revisit renders from memory, not a round trip. */
391
+ const fileCacheRef = React.useRef(new Map())
392
+ const cacheGenerationRef = React.useRef(0)
393
+ /** Per (baseline, path) comparison cache: diff rendering is instant on revisit. */
394
+ const diffCacheRef = React.useRef(new Map())
395
+ /** Files whose first diff was already auto-revealed (revisit keeps scroll). */
396
+ const revealOnceRef = React.useRef(new Set())
397
+
398
+ const cwdFromStore = useSessions
399
+ ? useSessions((sessions) => (sessions && sessions.byId && sessionId && sessions.byId[sessionId] ? sessions.byId[sessionId].cwd : undefined))
400
+ : undefined
401
+ const running = useSessions
402
+ ? useSessions((sessions) => (sessions && sessions.byId && sessionId && sessions.byId[sessionId] ? sessions.byId[sessionId].running : undefined))
403
+ : undefined
404
+
405
+ const [monaco, setMonaco] = React.useState(null)
406
+ const [monacoError, setMonacoError] = React.useState(null)
407
+ const [latest, setLatest] = React.useState(null)
408
+ const [latestLoading, setLatestLoading] = React.useState(true)
409
+ const [mode, setMode] = React.useState('code')
410
+ const [modeTouched, setModeTouched] = React.useState(false)
411
+ const [openTabs, setOpenTabs] = React.useState([])
412
+ const [activePath, setActivePath] = React.useState(null)
413
+ const [file, setFile] = React.useState(null)
414
+ const [comparison, setComparison] = React.useState(null)
415
+ const [regions, setRegions] = React.useState([])
416
+ const [hunkMeta, setHunkMeta] = React.useState({ total: 0, added: 0, deleted: 0 })
417
+ const [hunkIndex, setHunkIndex] = React.useState(0)
418
+ const [reveal, setReveal] = React.useState({ line: 0, nonce: 0 })
419
+ const [treeOpen, setTreeOpen] = React.useState(true)
420
+ const [treeWidth, setTreeWidth] = React.useState(TREE_WIDTH_DEFAULT)
421
+ const [narrow, setNarrow] = React.useState(false)
422
+ const [status, setStatus] = React.useState(null)
423
+ const [scheme, setScheme] = React.useState(themeRuntime.scheme)
424
+ const [fontSize, setFontSize] = React.useState(() => {
425
+ try {
426
+ const stored = Number(localStorage.getItem('dsh-git-changes.fontSize'))
427
+ return Number.isFinite(stored) && stored >= 9 && stored <= 28 ? Math.round(stored) : 13
428
+ } catch (error) {
429
+ return 13
430
+ }
431
+ })
432
+ const [menuOpen, setMenuOpen] = React.useState(false)
433
+ const [gitStatus, setGitStatus] = React.useState(null)
434
+ /** Right-hand panel surface: Git CHANGES (default) or the workspace tree. */
435
+ const [panelMode, setPanelMode] = React.useState(() => {
436
+ try {
437
+ return localStorage.getItem('dsh-git-changes.panelMode') === 'tree' ? 'tree' : 'changes'
438
+ } catch (error) {
439
+ return 'changes'
440
+ }
441
+ })
442
+ /** Which diff baseline the CHANGES panel opened ("staged" = HEAD↔index). */
443
+ const [changeScope, setChangeScope] = React.useState(() => storedChangeScope())
444
+ /** Diff presentation: auto (width decides), inline (VS Code inline view) or side. */
445
+ const [diffLayout, setDiffLayout] = React.useState(() => {
446
+ try {
447
+ const stored = localStorage.getItem('dsh-git-changes.diffLayout')
448
+ return stored === 'inline' || stored === 'side' ? stored : 'auto'
449
+ } catch (error) {
450
+ return 'auto'
451
+ }
452
+ })
453
+ /** Latest per-line blame rows for the active file (shown on cursor dwell). */
454
+ const [blameLines, setBlameLines] = React.useState([])
455
+ const [revision, setRevision] = React.useState(null)
456
+ const [revisionData, setRevisionData] = React.useState(null)
457
+ const [revisionLoading, setRevisionLoading] = React.useState(false)
458
+ const [revisionRefreshToken, setRevisionRefreshToken] = React.useState(0)
459
+ // Bumped when the shown snapshot kind changes (worktree ↔ historical commit):
460
+ // Monaco keeps the same model/text, so the Host half needs an explicit
461
+ // re-announce to hand the language server the right document.
462
+ const [documentEpoch, setDocumentEpoch] = React.useState(0)
463
+ const [picker, setPicker] = React.useState(null)
464
+ const [treeRefreshToken, setTreeRefreshToken] = React.useState(0)
465
+ const [lspMissing, setLspMissing] = React.useState(null)
466
+
467
+ const treeVisible = treeOpen && !narrow
468
+
469
+ // Remember which panel surface the user prefers (VS Code persists this too).
470
+ React.useEffect(() => {
471
+ try {
472
+ localStorage.setItem('dsh-git-changes.panelMode', panelMode)
473
+ } catch (error) { /* storage unavailable */ }
474
+ }, [panelMode])
475
+
476
+ React.useEffect(() => {
477
+ try {
478
+ localStorage.setItem('dsh-git-changes.diffLayout', diffLayout)
479
+ } catch (error) { /* storage unavailable */ }
480
+ }, [diffLayout])
481
+
482
+ React.useEffect(() => {
483
+ try {
484
+ localStorage.setItem('dsh-git-changes.mode', mode)
485
+ } catch (error) { /* storage unavailable */ }
486
+ }, [mode])
487
+
488
+ React.useEffect(() => {
489
+ try {
490
+ localStorage.setItem('dsh-git-changes.changeScope', changeScope)
491
+ } catch (error) { /* storage unavailable */ }
492
+ }, [changeScope])
493
+
494
+ // Blame follows the visible file: switching files or the snapshot refetches.
495
+ // The rows are only rendered on demand when the cursor rests on a line.
496
+ React.useEffect(() => {
497
+ if (!activePath || !sessionId) {
498
+ setBlameLines([])
499
+ return undefined
500
+ }
501
+ const controller = new AbortController()
502
+ let live = true
503
+ apiGet('blame', { sessionId, path: activePath }, controller.signal)
504
+ .then((value) => {
505
+ if (!live) return
506
+ const rows = value && value.available === true && Array.isArray(value.lines) ? value.lines : []
507
+ setBlameLines(rows)
508
+ })
509
+ .catch((error) => {
510
+ if (live) setBlameLines([])
511
+ })
512
+ return () => {
513
+ live = false
514
+ controller.abort()
515
+ }
516
+ }, [activePath, sessionId, documentEpoch])
517
+ const summary = latest && latest.available ? latest.summary : null
518
+ // Revision mode pins every cache key to one (commit, scope) pair, so switching
519
+ // versions never paints another version's text or hunks.
520
+ const revisionKey = mode === 'revision' && revision ? 'rev:' + revision.sha + ':' + revision.scope : ''
521
+ const revisionSha = revision ? revision.sha : ''
522
+ const revisionScope = revision ? revision.scope : ''
523
+ // Content and comparisons belong to the same announcement. During the
524
+ // render before the refresh effect runs, the old text must not accept the
525
+ // next snapshot's hunks.
526
+ const snapshotKey = String(sessionId || '') + ':' + (revisionKey || (latest && latest.available ? String(latest.seq) : 'none'))
527
+ const fileCurrent = file && file.path === activePath && file.snapshotKey === snapshotKey
528
+ // Historical (= immutable commit content) mode: the diff is base → commit, and
529
+ // the language server must not see the old document (line numbers of the
530
+ // worktree no longer match).
531
+ const historical = mode === 'revision' && Boolean(revision) && revision.scope === 'commit'
532
+
533
+ // Render-synchronous snapshot of everything worth restoring after this tab
534
+ // body unmounts (session switch, tab switch, tab close).
535
+ sessionRef.current = sessionId
536
+ pageSnapshotRef.current = {
537
+ openTabs: openTabs.slice(),
538
+ activePath,
539
+ mode,
540
+ modeTouched,
541
+ revision: revision ? { ...revision } : null,
542
+ treeOpen,
543
+ treeWidth,
544
+ initialised: initialisedRef.current,
545
+ autoOpened: autoOpenedRef.current,
546
+ }
547
+
548
+ /** Publish a transient status line. */
549
+ const notify = React.useCallback((text, kind) => {
550
+ if (!text) return
551
+ setStatus({ text: String(text), kind: kind || 'info', at: Date.now() })
552
+ }, [])
553
+
554
+ // Transient status auto-clear.
555
+ React.useEffect(() => {
556
+ if (!status) return undefined
557
+ const timer = setTimeout(() => setStatus(null), 4200)
558
+ return () => clearTimeout(timer)
559
+ }, [status])
560
+
561
+ // Page width measurement for the narrow layout. Only the boolean crosses the
562
+ // React state boundary: during a DSH splitter drag the sidebar width changes
563
+ // on every frame, and keeping pixel width in state re-rendered the whole page
564
+ // (tree + editor) per frame — the reported divider-drag stutter.
565
+ React.useEffect(() => {
566
+ const node = rootRef.current
567
+ if (!node) return undefined
568
+ const measure = () => {
569
+ try {
570
+ const next = (node.getBoundingClientRect().width || node.clientWidth || 900) < NARROW_WIDTH
571
+ setNarrow((previous) => (previous === next ? previous : next))
572
+ } catch (error) { /* ignore */ }
573
+ }
574
+ measure()
575
+ let observer
576
+ try {
577
+ observer = new ResizeObserver(measure)
578
+ observer.observe(node)
579
+ } catch (error) { /* ResizeObserver unavailable: keep the default */ }
580
+ window.addEventListener('resize', measure)
581
+ return () => {
582
+ try { observer?.disconnect() } catch (error) { /* ignore */ }
583
+ window.removeEventListener('resize', measure)
584
+ }
585
+ }, [])
586
+
587
+ // Monaco loads lazily when this page first mounts.
588
+ React.useEffect(() => {
589
+ let cancelled = false
590
+ loadMonaco()
591
+ .then((namespace) => {
592
+ if (cancelled) return
593
+ setMonaco(namespace)
594
+ const applied = applyMonacoTheme(namespace)
595
+ setScheme(applied)
596
+ })
597
+ .catch((error) => {
598
+ if (cancelled) return
599
+ setMonacoError(error)
600
+ log('error', 'monaco failed to load', error)
601
+ })
602
+ return () => { cancelled = true }
603
+ }, [])
604
+
605
+ // Theme changes repaint the editor and diff decorations.
606
+ React.useEffect(() => {
607
+ const dispose = observeTheme(globalCtxRef.current, () => {
608
+ const next = applyMonacoTheme(monaco)
609
+ setScheme(next)
610
+ })
611
+ return dispose
612
+ }, [monaco])
613
+
614
+ /** Reload the latest turn announcement for this session. */
615
+ const modeRef = React.useRef(mode)
616
+ modeRef.current = mode
617
+ const tRef = React.useRef(t)
618
+ tRef.current = t
619
+ const notifyRef = React.useRef(notify)
620
+ notifyRef.current = notify
621
+ const refreshLatest = React.useCallback(async () => {
622
+ if (!sessionId) return
623
+ const token = ++latestTokenRef.current
624
+ setLatestLoading(true)
625
+ try {
626
+ const value = await apiGet('latest', { sessionId })
627
+ if (token !== latestTokenRef.current) return
628
+ // Keep the previous object when the snapshot did not change: a fresh
629
+ // object identity would re-run the diff effect and repaint the editor
630
+ // (the old code refreshed on every window focus and looked laggy).
631
+ setLatest((previous) => {
632
+ const next = { ...(value || {}), loaded: true }
633
+ if (previous && previous.loaded === true
634
+ && previous.available === next.available
635
+ && previous.seq === next.seq
636
+ && previous.turn === next.turn) return previous
637
+ return next
638
+ })
639
+ if (modeRef.current === 'turn' && value && value.available === false) {
640
+ const fallback = gitStatusRef.current?.available ? 'git' : 'code'
641
+ setMode(fallback)
642
+ setModeTouched(true)
643
+ notifyRef.current(tRef.current('mode.turn.unavailable'), 'warn')
644
+ }
645
+ } catch (failure) {
646
+ if (token !== latestTokenRef.current) return
647
+ setLatest((previous) => (previous && previous.loaded === true ? previous : { available: false, loaded: true, reason: 'request-failed' }))
648
+ } finally {
649
+ if (token === latestTokenRef.current) setLatestLoading(false)
650
+ }
651
+ }, [sessionId])
652
+
653
+ /** Refresh the git working-tree status used by the Git mode and tree badges. */
654
+ const loadGitStatus = React.useCallback(async () => {
655
+ if (!sessionId) return
656
+ try {
657
+ const value = await apiGet('gitStatus', { sessionId })
658
+ gitStatusRef.current = value || { available: false }
659
+ setGitStatus(value || { available: false })
660
+ } catch (failure) {
661
+ const value = { available: false, reason: 'request-failed' }
662
+ gitStatusRef.current = value
663
+ setGitStatus(value)
664
+ }
665
+ }, [sessionId])
666
+
667
+ // Probe git once when the page mounts (also decides the initial baseline).
668
+ React.useEffect(() => {
669
+ void loadGitStatus()
670
+ }, [loadGitStatus])
671
+
672
+ // Initial baseline: restore this session's remembered workspace (tabs, active
673
+ // file, diff baseline, git version) or fall back to TURN when the Host recorded
674
+ // a turn, else GIT when the workspace is a repository, else code-only. The
675
+ // right-sidebar tab body is remounted whenever the user switches sessions, so
676
+ // this is the only place the previous visit's state comes back.
677
+ React.useEffect(() => {
678
+ if (initialisedRef.current) return
679
+ if (!latest || latest.loaded !== true) return
680
+ if (gitStatus === null) return
681
+ initialisedRef.current = true
682
+ const gitAvailable = gitStatus.available === true
683
+ const defaultMode = () => (gitAvailable ? 'git' : latest.available ? 'turn' : 'code')
684
+ // The Host probes can be slow: the user may already have opened a file or
685
+ // picked a baseline during the remount window. That choice wins over the
686
+ // remembered snapshot (which was captured before this mount).
687
+ const interacted = Boolean(activePath) || openTabs.length > 0 || revision !== null || modeTouched === true || mode !== 'code'
688
+ if (interacted) {
689
+ if (!modeTouched) setMode(defaultMode())
690
+ // Never let the turn's auto-open replace the file the user just picked.
691
+ autoOpenedRef.current = true
692
+ return
693
+ }
694
+ const saved = rememberedPageState(sessionId)
695
+ if (!saved) {
696
+ // First mount in this app run: honour the baseline the user left behind in
697
+ // a previous run so diff mode (and its hunk navigation/colors) comes back.
698
+ const rememberedMode = storedBaseline()
699
+ const nextMode = rememberedMode === 'git' && gitAvailable
700
+ ? 'git'
701
+ : rememberedMode === 'turn' && latest.available
702
+ ? 'turn'
703
+ : defaultMode()
704
+ setMode(nextMode)
705
+ if (nextMode !== defaultMode()) setModeTouched(true)
706
+ return
707
+ }
708
+ const modes = ['turn', 'git', 'revision', 'code']
709
+ let nextMode = modes.includes(saved.mode) ? saved.mode : ''
710
+ if (!nextMode) nextMode = defaultMode()
711
+ if (nextMode === 'turn' && !latest.available) nextMode = gitAvailable ? 'git' : 'code'
712
+ if (nextMode === 'git' && !gitAvailable) nextMode = 'code'
713
+ let nextRevision = null
714
+ if (nextMode === 'revision') {
715
+ const record = saved.revision
716
+ if (gitAvailable && record && typeof record.sha === 'string' && record.sha !== '') {
717
+ nextRevision = {
718
+ sha: record.sha,
719
+ short: typeof record.short === 'string' && record.short !== '' ? record.short : record.sha,
720
+ subject: typeof record.subject === 'string' ? record.subject : '',
721
+ date: typeof record.date === 'string' ? record.date : undefined,
722
+ scope: record.scope === 'since' ? 'since' : 'commit',
723
+ }
724
+ } else {
725
+ // A git version belongs to a repository: never restore it without one.
726
+ nextMode = gitAvailable ? 'git' : 'code'
727
+ }
728
+ }
729
+ const tabs = Array.isArray(saved.openTabs) ? saved.openTabs.filter((path) => typeof path === 'string' && path !== '') : []
730
+ const active = typeof saved.activePath === 'string' && saved.activePath !== '' ? saved.activePath : null
731
+ if (active && !tabs.includes(active)) tabs.push(active)
732
+ setOpenTabs(tabs)
733
+ setActivePath(active)
734
+ if (typeof saved.treeOpen === 'boolean') setTreeOpen(saved.treeOpen)
735
+ const width = Number(saved.treeWidth)
736
+ if (Number.isFinite(width)) setTreeWidth(Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width))))
737
+ autoOpenedRef.current = saved.autoOpened === true || Boolean(active)
738
+ // A restored file did not go through openPath(), so it still has to be
739
+ // read from the Host once the final baseline announcement is known.
740
+ restoreLoadRef.current = Boolean(active)
741
+ setRevision(nextRevision)
742
+ setMode(nextMode)
743
+ setModeTouched(saved.modeTouched === true)
744
+ }, [latest, gitStatus, sessionId])
745
+
746
+ // Persist the page's workspace state before this tab body unmounts. DSH
747
+ // unmounts the previous conversation's sidebar tab bodies on a session switch,
748
+ // so the next mount of the same session restores its tabs, active file and git
749
+ // version instead of starting from scratch.
750
+ React.useEffect(() => () => {
751
+ const state = pageSnapshotRef.current
752
+ if (!state) return
753
+ if (state.initialised !== true && state.openTabs.length === 0 && !state.activePath) return
754
+ rememberPageState(sessionRef.current, state)
755
+ }, [sessionId])
756
+
757
+ // Revision baseline: one compare payload drives the tree's changed-file list,
758
+ // the status bar and the per-file rev+scope comparisons below. refreshAll
759
+ // bumps revisionRefreshToken so this reloads.
760
+ React.useEffect(() => {
761
+ revisionTokenRef.current += 1
762
+ const token = revisionTokenRef.current
763
+ if (mode !== 'revision' || !revisionSha || !sessionId) {
764
+ setRevisionData(null)
765
+ setRevisionLoading(false)
766
+ return undefined
767
+ }
768
+ setRevisionData(null)
769
+ setRevisionLoading(true)
770
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
771
+ apiGet('gitCompare', { sessionId, rev: revisionSha, scope: revisionScope }, controller ? controller.signal : undefined)
772
+ .then((value) => {
773
+ if (token !== revisionTokenRef.current) return
774
+ setRevisionData(value || { available: false, reason: 'request-failed' })
775
+ })
776
+ .catch((failure) => {
777
+ if (token !== revisionTokenRef.current) return
778
+ setRevisionData({ available: false, reason: 'request-failed', message: failure && failure.message })
779
+ })
780
+ .finally(() => {
781
+ if (token === revisionTokenRef.current) setRevisionLoading(false)
782
+ })
783
+ return () => {
784
+ revisionTokenRef.current += 1
785
+ try { controller?.abort() } catch (error) { /* ignore */ }
786
+ }
787
+ }, [mode, sessionId, revisionSha, revisionScope, revisionRefreshToken])
788
+
789
+ /** Cache key for one comparison (turn snapshot, git baseline or git revision). */
790
+ const diffCacheKey = React.useCallback((path) => {
791
+ if (!path) return ''
792
+ if (mode === 'turn') return latest && latest.available ? 'turn:' + String(latest.seq) + ':' + path : ''
793
+ if (mode === 'git') return 'git:' + changeScope + ':' + (gitStatus && gitStatus.available === true ? '1' : '0') + ':' + path
794
+ if (mode === 'revision') return revisionSha ? 'rev:' + revisionSha + ':' + revisionScope + ':' + path : ''
795
+ return 'code:' + path
796
+ }, [mode, changeScope, latest && latest.seq, latest && latest.available, gitStatus && gitStatus.available, revisionSha, revisionScope])
797
+
798
+ /**
799
+ * Apply one cached diff entry (or an error/empty marker) to the page state.
800
+ * The first diff of a file is revealed once; revisits keep the scroll position
801
+ * the user left, which is what makes switching back feel like VS Code.
802
+ * @param {object} entry cached diff entry
803
+ * @param {string} path active file
804
+ * @param {string} text current file text
805
+ */
806
+ const applyDiffEntry = React.useCallback((entry, path, text) => {
807
+ if (!entry) return
808
+ const context = { path, snapshotKey, generation: cacheGenerationRef.current, key: diffCacheKey(path) }
809
+ if (entry.status === 'ready') {
810
+ const built = buildRegions(entry.value, text || '')
811
+ setComparison({ ...context, status: 'ready', value: entry.value, index: entry.index })
812
+ setRegions(built.regions)
813
+ setHunkMeta({ total: built.total, added: built.added, deleted: built.deleted })
814
+ setHunkIndex(0)
815
+ if (built.regions.length > 0 && !pendingRevealRef.current && !revealOnceRef.current.has(path)) {
816
+ revealOnceRef.current.add(path)
817
+ setReveal((previous) => ({ line: built.regions[0].start, nonce: previous.nonce + 1 }))
818
+ }
819
+ return
820
+ }
821
+ setRegions([])
822
+ setHunkMeta(EMPTY_HUNK_META)
823
+ setHunkIndex(0)
824
+ if (entry.status === 'deleted') {
825
+ setComparison({ ...context, status: 'deleted' })
826
+ notify(t('status.git.deleted'), 'warn')
827
+ return
828
+ }
829
+ setComparison({ ...context, status: entry.status, message: entry.message, kind: entry.kind })
830
+ }, [notify, t, snapshotKey, diffCacheKey])
831
+
832
+ // When this tab opens without a remembered baseline the session defaults to
833
+ // the turn comparison; if that comparison has nothing to show but the working
834
+ // tree does, fall forward to the git baseline so diff review is never blank.
835
+ React.useEffect(() => {
836
+ if (mode !== 'turn' || modeTouched) return
837
+ const status = gitStatusRef.current
838
+ if (!status || status.available !== true || !Array.isArray(status.files) || status.files.length === 0) return
839
+ if (!comparison || comparison.status !== 'ready' || regions.length > 0) return
840
+ setMode('git')
841
+ setModeTouched(true)
842
+ notify(t('mode.turn.emptyToGit'), 'info')
843
+ }, [mode, modeTouched, comparison, regions.length, notify, t])
844
+
845
+ /** Load one file's current content, using the per-path cache for instant switch. */
846
+ const loadFile = React.useCallback(async (path, options) => {
847
+ if (!sessionId || !path) return
848
+ const force = Boolean(options && options.force)
849
+ fileTokenRef.current += 1
850
+ const token = fileTokenRef.current
851
+ try { abortRef.current?.abort() } catch (error) { /* ignore */ }
852
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
853
+ abortRef.current = controller
854
+ cursorStore.reset()
855
+ if (!isCodeFile(path)) {
856
+ const handoff = { status: 'handoff', path, kind: 'external', snapshotKey }
857
+ fileCacheRef.current.set(path, handoff)
858
+ setFile(handoff)
859
+ setComparison({ status: 'none' })
860
+ setRegions([])
861
+ setHunkMeta(EMPTY_HUNK_META)
862
+ setHunkIndex(0)
863
+ return
864
+ }
865
+ const candidate = force ? undefined : fileCacheRef.current.get(path)
866
+ const cached = candidate && candidate.snapshotKey === snapshotKey ? candidate : undefined
867
+ if (cached) setFile(cached)
868
+ else setFile({ status: 'loading', path, kind: 'text', snapshotKey })
869
+ // Re-apply a cached comparison synchronously; the diff effects only run for
870
+ // files whose comparison is not cached yet.
871
+ const cachedKey = force ? '' : diffCacheKey(path)
872
+ const cachedDiff = cachedKey ? diffCacheRef.current.get(cachedKey) : undefined
873
+ if (cachedDiff) applyDiffEntry(cachedDiff, path, cached && cached.status === 'ready' ? cached.text : '')
874
+ else {
875
+ setRegions([])
876
+ setHunkMeta(EMPTY_HUNK_META)
877
+ setHunkIndex(0)
878
+ setComparison(null)
879
+ }
880
+ if (cached) return
881
+ try {
882
+ // Historical content comes from git at the selected commit, never from the
883
+ // worktree; since-scope and the other baselines read the worktree file.
884
+ const params = historical && revision ? { sessionId, path, rev: revision.sha } : { sessionId, path }
885
+ const value = await apiGet('file', params, controller ? controller.signal : undefined)
886
+ if (token !== fileTokenRef.current) return
887
+ const record = { ...fileRecordOf(path, value), snapshotKey }
888
+ fileCacheRef.current.set(path, record)
889
+ setFile(record)
890
+ } catch (failure) {
891
+ if (token !== fileTokenRef.current) return
892
+ // A missing file is only a candidate deletion. It becomes an empty
893
+ // review model after the comparison confirms that its after side is
894
+ // absent. Permission/session/read errors remain ordinary errors.
895
+ const missing = failure && failure.status === 404 && failure.code === 'not-found'
896
+ const record = {
897
+ status: missing ? 'missing' : 'error', path, kind: 'text', snapshotKey,
898
+ text: '', lines: 1, language: languageOf(path), missing,
899
+ error: failure && failure.message ? failure.message : String(failure),
900
+ }
901
+ if (missing) fileCacheRef.current.set(path, record)
902
+ setFile(record)
903
+ }
904
+ }, [sessionId, snapshotKey, diffCacheKey, applyDiffEntry, historical, revision && revision.sha])
905
+
906
+ /** Match one active file against the turn summary's changed files. */
907
+ const changedIndexOf = React.useCallback((path) => {
908
+ if (!summary || !Array.isArray(summary.files) || !path) return -1
909
+ const target = normalizeRel(path)
910
+ const targetName = basename(target)
911
+ const relativeOfEntry = (fileEntry) => toWorkspaceRel(fileEntry.path || fileEntry.display || '', cwdFromStore)
912
+ for (let index = 0; index < summary.files.length; index += 1) {
913
+ const fileEntry = summary.files[index]
914
+ if (!fileEntry) continue
915
+ if (relativeOfEntry(fileEntry) === target) return index
916
+ }
917
+ // Fallback: unique basename match (workspace-changes may record paths
918
+ // relative to a repository above the session cwd, or absolute paths).
919
+ const candidates = []
920
+ for (let index = 0; index < summary.files.length; index += 1) {
921
+ const fileEntry = summary.files[index]
922
+ const rel = relativeOfEntry(fileEntry)
923
+ if (rel.endsWith('/' + target) || rel === target || basename(rel) === targetName) candidates.push(index)
924
+ }
925
+ return candidates.length === 1 ? candidates[0] : -1
926
+ }, [summary, cwdFromStore])
927
+
928
+ // Refresh the turn announcement on mount and when a running turn settles.
929
+ React.useEffect(() => {
930
+ void refreshLatest()
931
+ }, [sessionId, refreshLatest])
932
+
933
+ React.useEffect(() => {
934
+ if (running === false) void refreshLatest()
935
+ }, [running, refreshLatest])
936
+
937
+ // Reload the active text as well as invalidating caches. Keep Monaco mounted
938
+ // and keep the first-reveal history so refreshing a viewed file retains its
939
+ // scroll position. loadFile cancels the previous read before starting anew.
940
+ const snapshotRef = React.useRef(null)
941
+ React.useEffect(() => {
942
+ if (snapshotRef.current === snapshotKey) return
943
+ snapshotRef.current = snapshotKey
944
+ cacheGenerationRef.current += 1
945
+ fileCacheRef.current = new Map()
946
+ diffCacheRef.current = new Map()
947
+ if (activePath) {
948
+ lastSnapshotLoadRef.current = { key: snapshotKey, path: activePath }
949
+ void loadFile(activePath, { force: true })
950
+ }
951
+ }, [snapshotKey, activePath, loadFile])
952
+
953
+ // The baseline restore puts the remembered active file in place without going
954
+ // through openPath(); read it once here, from the render that already carries
955
+ // the restored baseline/revision (so historical content is read at its commit).
956
+ // The snapshot effect above may already have force-loaded that exact file while
957
+ // settling the restored revision (which changes snapshotKey); never fetch twice.
958
+ React.useEffect(() => {
959
+ if (!restoreLoadRef.current) return
960
+ if (!activePath) return
961
+ restoreLoadRef.current = false
962
+ const already = lastSnapshotLoadRef.current
963
+ if (already && already.key === snapshotKey && already.path === activePath) return
964
+ snapshotRef.current = snapshotKey
965
+ cacheGenerationRef.current += 1
966
+ fileCacheRef.current = new Map()
967
+ diffCacheRef.current = new Map()
968
+ void loadFile(activePath, { force: true })
969
+ }, [activePath, snapshotKey, loadFile])
970
+
971
+ // A mode switch invalidates the current decorations before the matching
972
+ // baseline effect repaints from cache in the same commit.
973
+ const previousModeRef = React.useRef(mode)
974
+ React.useEffect(() => {
975
+ if (previousModeRef.current === mode) return
976
+ previousModeRef.current = mode
977
+ applyDiffEntry({ status: 'none' })
978
+ }, [mode, applyDiffEntry])
979
+
980
+ // Depend on the file record itself: a refresh can return identical text while
981
+ // React batches away the intermediate loading state. Its new record still
982
+ // has to restart the comparison after cache invalidation.
983
+ // TURN baseline: cached per (turn seq, path), fetched once.
984
+ React.useEffect(() => {
985
+ if (!activePath || mode !== 'turn' || !summary || !latest || !latest.available) return undefined
986
+ if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
987
+ const index = changedIndexOf(activePath)
988
+ if (index < 0) {
989
+ applyDiffEntry({ status: 'none' }, activePath, file.text)
990
+ return undefined
991
+ }
992
+ const key = diffCacheKey(activePath)
993
+ const cached = key ? diffCacheRef.current.get(key) : undefined
994
+ if (cached) {
995
+ applyDiffEntry(cached, activePath, file.text)
996
+ return undefined
997
+ }
998
+ let cancelled = false
999
+ const generation = cacheGenerationRef.current
1000
+ let showedLoading = false
1001
+ if (!comparison || comparison.status !== 'ready') showedLoading = true
1002
+ if (showedLoading) setComparison({ status: 'loading', index })
1003
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1004
+ apiGet('diff', { sessionId, seq: latest.seq, index }, controller ? controller.signal : undefined)
1005
+ .then((value) => {
1006
+ if (cancelled || generation !== cacheGenerationRef.current) return
1007
+ const entry = { status: 'ready', value, index }
1008
+ if (key) diffCacheRef.current.set(key, entry)
1009
+ applyDiffEntry(entry, activePath, file.text)
1010
+ })
1011
+ .catch((failure) => {
1012
+ if (cancelled || generation !== cacheGenerationRef.current) return
1013
+ const status = failure && failure.status === 404 ? 'missing' : 'error'
1014
+ if (key) diffCacheRef.current.set(key, { status, message: failure && failure.message })
1015
+ applyDiffEntry({ status, message: failure && failure.message }, activePath, file.text)
1016
+ if (failure && failure.status === 404) notify(t('error.sessionGone'), 'warn')
1017
+ })
1018
+ return () => { cancelled = true; controller?.abort() }
1019
+ }, [activePath, mode, snapshotKey, latest && latest.seq, latest && latest.available, summary, file, sessionId, changedIndexOf, diffCacheKey, applyDiffEntry, notify, t])
1020
+
1021
+ // Git baseline: HEAD ↔ working tree for the active file (cached per path).
1022
+ React.useEffect(() => {
1023
+ if (mode !== 'git' || !activePath) return undefined
1024
+ if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
1025
+ if (!gitStatus || gitStatus.available !== true) {
1026
+ applyDiffEntry({ status: 'unavailable', message: t('mode.git.unavailable') }, activePath, file.text)
1027
+ return undefined
1028
+ }
1029
+ const key = diffCacheKey(activePath)
1030
+ const cached = key ? diffCacheRef.current.get(key) : undefined
1031
+ if (cached) {
1032
+ applyDiffEntry(cached, activePath, file.text)
1033
+ return undefined
1034
+ }
1035
+ let cancelled = false
1036
+ const generation = cacheGenerationRef.current
1037
+ setComparison({ status: 'loading' })
1038
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1039
+ apiGet('gitDiff', { sessionId, path: activePath, scope: changeScope }, controller ? controller.signal : undefined)
1040
+ .then((value) => {
1041
+ if (cancelled || generation !== cacheGenerationRef.current) return
1042
+ let entry
1043
+ if (!value || !value.kind) entry = { status: 'error' }
1044
+ else if (value.kind === 'text') entry = { status: 'ready', value }
1045
+ else if (value.kind === 'deleted') entry = { status: 'deleted' }
1046
+ else entry = { status: 'unavailable', kind: value.kind }
1047
+ if (key) diffCacheRef.current.set(key, entry)
1048
+ applyDiffEntry(entry, activePath, file.text)
1049
+ })
1050
+ .catch((failure) => {
1051
+ if (cancelled || generation !== cacheGenerationRef.current) return
1052
+ applyDiffEntry({ status: 'error', message: failure && failure.message }, activePath, file.text)
1053
+ })
1054
+ return () => { cancelled = true; controller?.abort() }
1055
+ }, [mode, changeScope, activePath, snapshotKey, file, gitStatus && gitStatus.available, sessionId, diffCacheKey, applyDiffEntry, t])
1056
+
1057
+ // Revision baseline: compare the active file against the selected git version
1058
+ // (`commit` = rev^1 → rev, `since` = rev → worktree). The tree's compare
1059
+ // payload has to be ready first: without it the diff columns are meaningless.
1060
+ React.useEffect(() => {
1061
+ if (mode !== 'revision' || !activePath || !revision) return undefined
1062
+ if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
1063
+ if (!revisionData) return undefined
1064
+ if (revisionData.available !== true) {
1065
+ applyDiffEntry({ status: 'unavailable', message: t('status.revision.unavailable') }, activePath, file.text)
1066
+ return undefined
1067
+ }
1068
+ const key = diffCacheKey(activePath)
1069
+ const cached = key ? diffCacheRef.current.get(key) : undefined
1070
+ if (cached) {
1071
+ applyDiffEntry(cached, activePath, file.text)
1072
+ return undefined
1073
+ }
1074
+ let cancelled = false
1075
+ const generation = cacheGenerationRef.current
1076
+ setComparison({ status: 'loading' })
1077
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1078
+ apiGet('gitFile', { sessionId, path: activePath, rev: revision.sha, scope: revision.scope }, controller ? controller.signal : undefined)
1079
+ .then((value) => {
1080
+ if (cancelled || generation !== cacheGenerationRef.current) return
1081
+ let entry
1082
+ if (!value || !value.kind) entry = { status: 'error' }
1083
+ else if (value.kind === 'text') entry = { status: 'ready', value }
1084
+ else if (value.kind === 'deleted') entry = { status: 'deleted' }
1085
+ else entry = { status: 'unavailable', kind: value.kind }
1086
+ if (key) diffCacheRef.current.set(key, entry)
1087
+ applyDiffEntry(entry, activePath, file.text)
1088
+ })
1089
+ .catch((failure) => {
1090
+ if (cancelled || generation !== cacheGenerationRef.current) return
1091
+ applyDiffEntry({ status: 'error', message: failure && failure.message }, activePath, file.text)
1092
+ })
1093
+ return () => { cancelled = true; controller?.abort() }
1094
+ }, [mode, activePath, snapshotKey, file, revision && revision.sha, revision && revision.scope, revisionData, sessionId, diffCacheKey, applyDiffEntry, t])
1095
+
1096
+ // Prefetch file text + comparison for the turn's changed files in the
1097
+ // background, so the first click on a file in the tree also renders its diff
1098
+ // without a round-trip (limited concurrency, cache-only; never touches the
1099
+ // live page state).
1100
+ React.useEffect(() => {
1101
+ if (mode !== 'turn' || !latest || !latest.available || !summary || !sessionId) return undefined
1102
+ const seq = latest.seq
1103
+ const generation = cacheGenerationRef.current
1104
+ const files = Array.isArray(summary.files) ? summary.files : []
1105
+ const targets = []
1106
+ for (const fileEntry of files) {
1107
+ const rel = toWorkspaceRel((fileEntry && (fileEntry.path || fileEntry.display)) || '', cwdFromStore)
1108
+ if (!rel || !isCodeFile(rel) || targets.includes(rel)) continue
1109
+ targets.push(rel)
1110
+ if (targets.length >= 8) break
1111
+ }
1112
+ let cancelled = false
1113
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1114
+ prefetchAbortRef.current = controller
1115
+ const run = async () => {
1116
+ for (const path of targets) {
1117
+ if (cancelled || generation !== cacheGenerationRef.current) return
1118
+ const key = 'turn:' + String(seq) + ':' + path
1119
+ const haveFile = fileCacheRef.current.get(path)?.status === 'ready'
1120
+ const haveDiff = diffCacheRef.current.has(key)
1121
+ if (haveFile && haveDiff) continue
1122
+ const index = changedIndexOf(path)
1123
+ if (index < 0) continue
1124
+ try {
1125
+ const [fileValue, diffValue] = await Promise.all([
1126
+ haveFile ? null : apiGet('file', { sessionId, path }, controller ? controller.signal : undefined),
1127
+ haveDiff ? null : apiGet('diff', { sessionId, seq, index }, controller ? controller.signal : undefined),
1128
+ ])
1129
+ if (cancelled || generation !== cacheGenerationRef.current) return
1130
+ if (fileValue) {
1131
+ const record = { ...fileRecordOf(path, fileValue), snapshotKey }
1132
+ if (record.status === 'ready' || record.status === 'handoff') fileCacheRef.current.set(path, record)
1133
+ }
1134
+ if (diffValue) diffCacheRef.current.set(key, { status: 'ready', value: diffValue, index })
1135
+ } catch (error) {
1136
+ /* prefetch is best effort */
1137
+ }
1138
+ await new Promise((resolve) => setTimeout(resolve, 0))
1139
+ }
1140
+ }
1141
+ void run()
1142
+ return () => {
1143
+ cancelled = true
1144
+ controller?.abort()
1145
+ if (prefetchAbortRef.current === controller) prefetchAbortRef.current = null
1146
+ }
1147
+ }, [mode, snapshotKey, latest && latest.seq, latest && latest.available, summary, sessionId, cwdFromStore, changedIndexOf])
1148
+
1149
+ // A search hit / Quick Open result may ask to reveal a specific line once the
1150
+ // file content is in and its comparison settled.
1151
+ React.useEffect(() => {
1152
+ const pending = pendingRevealRef.current
1153
+ if (!pending) return
1154
+ if (!file || file.status !== 'ready' || file.path !== pending.path) return
1155
+ if (comparison && comparison.status === 'loading') return
1156
+ setReveal((previous) => ({ ...pending, line: Math.max(1, pending.line), nonce: previous.nonce + 1 }))
1157
+ }, [file, comparison])
1158
+
1159
+ /** Open a file in the code area (adding a tab and recording nav history). */
1160
+ const openPath = React.useCallback((path, options) => {
1161
+ const rel = normalizeRel(path)
1162
+ if (!rel) return
1163
+ if (!options?.location) pendingRevealRef.current = null
1164
+ if (activePath && activePath !== rel) {
1165
+ const history = historyRef.current
1166
+ history.past.push(activePath)
1167
+ if (history.past.length > 100) history.past.shift()
1168
+ history.future = []
1169
+ }
1170
+ setActivePath(rel)
1171
+ setOpenTabs((tabs) => (tabs.includes(rel) ? tabs : tabs.concat(rel)))
1172
+ setMenuOpen(false)
1173
+ if (!(options && options.silent)) void loadFile(rel)
1174
+ }, [activePath, loadFile])
1175
+
1176
+ /**
1177
+ * Open one file with the exact baseline the CHANGES row points at:
1178
+ * staged = HEAD↔index, unstaged = index↔worktree.
1179
+ */
1180
+ const openChangeDiff = React.useCallback((path, scope) => {
1181
+ const rel = normalizeRel(path)
1182
+ if (!rel) return
1183
+ setChangeScope(scope === 'staged' ? 'staged' : 'unstaged')
1184
+ setMode('git')
1185
+ setModeTouched(true)
1186
+ openPath(rel)
1187
+ }, [openPath])
1188
+
1189
+ // SPEC §3.1/§10.2: opening the page selects the first changed file and jumps
1190
+ // to its first diff (once per mount). Works for both the turn baseline and
1191
+ // the uncommitted-changes baseline this plugin defaults to.
1192
+ React.useEffect(() => {
1193
+ if (autoOpenedRef.current) return
1194
+ let files = null
1195
+ if (mode === 'turn') {
1196
+ if (!latest || latest.available !== true || !latest.summary) return
1197
+ files = Array.isArray(latest.summary.files) ? latest.summary.files : []
1198
+ } else if (mode === 'git') {
1199
+ if (!gitStatus || gitStatus.available !== true) return
1200
+ files = Array.isArray(gitStatus.files) ? gitStatus.files.map((entry) => ({ path: entry.path })) : []
1201
+ } else {
1202
+ return
1203
+ }
1204
+ if (files.length === 0) return
1205
+ autoOpenedRef.current = true
1206
+ const mapped = files.map((fileEntry) => toWorkspaceRel(fileEntry.path || fileEntry.display || '', cwdFromStore))
1207
+ const candidates = mapped.filter((rel) => rel !== '' && !rel.startsWith('..') && !/^[a-zA-Z]:/.test(rel))
1208
+ if (candidates.length === 0) return
1209
+ const target = candidates.find((rel) => isCodeFile(rel)) || candidates[0]
1210
+ openPath(target)
1211
+ }, [latest, gitStatus, mode, cwdFromStore, openPath])
1212
+
1213
+ /** Close one tab, selecting a neighbour. */
1214
+ const closeTab = React.useCallback((path) => {
1215
+ const tabs = openTabs
1216
+ const index = tabs.indexOf(path)
1217
+ const next = tabs.filter((tab) => tab !== path)
1218
+ setOpenTabs(next)
1219
+ if (activePath !== path) return
1220
+ const fallback = next[Math.min(Math.max(index, 0), next.length - 1)] || null
1221
+ setActivePath(fallback)
1222
+ if (fallback) void loadFile(fallback)
1223
+ else setFile(null)
1224
+ }, [openTabs, activePath, loadFile])
1225
+
1226
+ /** Cycle through open tabs. */
1227
+ const cycleTab = React.useCallback((delta) => {
1228
+ if (openTabs.length === 0) return
1229
+ const index = Math.max(0, openTabs.indexOf(activePath))
1230
+ const next = (index + delta + openTabs.length) % openTabs.length
1231
+ const path = openTabs[next]
1232
+ if (path) openPath(path)
1233
+ }, [openTabs, activePath, openPath])
1234
+
1235
+ /** Navigate the in-page history. */
1236
+ const navigateHistory = React.useCallback((direction) => {
1237
+ const history = historyRef.current
1238
+ const current = activePath
1239
+ if (direction === 'back') {
1240
+ const previous = history.past.pop()
1241
+ if (!previous) return
1242
+ if (current) history.future.push(current)
1243
+ setActivePath(previous)
1244
+ setOpenTabs((tabs) => (tabs.includes(previous) ? tabs : tabs.concat(previous)))
1245
+ void loadFile(previous)
1246
+ } else {
1247
+ const next = history.future.pop()
1248
+ if (!next) return
1249
+ if (current) history.past.push(current)
1250
+ setActivePath(next)
1251
+ setOpenTabs((tabs) => (tabs.includes(next) ? tabs : tabs.concat(next)))
1252
+ void loadFile(next)
1253
+ }
1254
+ }, [activePath, loadFile])
1255
+
1256
+ /** Reveal a change group by index. */
1257
+ const goToChange = React.useCallback((index) => {
1258
+ if (!regions.length) return
1259
+ const clamped = ((index % regions.length) + regions.length) % regions.length
1260
+ setHunkIndex(clamped)
1261
+ setReveal((previous) => ({ line: regions[clamped].start, nonce: previous.nonce + 1 }))
1262
+ }, [regions])
1263
+
1264
+ /** Open a file (defaults to the active one) in the builtin DSH preview. */
1265
+ const openInPreview = React.useCallback((targetPath) => {
1266
+ const path = typeof targetPath === 'string' && targetPath !== '' ? targetPath : activePath
1267
+ if (!path) return
1268
+ const ok = openResourceSafe(right, fileAddressOf(sessionId, path))
1269
+ if (!ok) notify(t('error.openFailed', { message: 'preview' }), 'warn')
1270
+ }, [activePath, sessionId, right, notify, t])
1271
+
1272
+ /** Copy the active file's workspace-relative path. */
1273
+ const copyActivePath = React.useCallback(async () => {
1274
+ if (!activePath) return
1275
+ const ok = await copyText(activePath)
1276
+ notify(ok ? t('common.copied') : t('action.copyPath'), ok ? 'info' : 'warn')
1277
+ if (copiedTimer) clearTimeout(copiedTimer)
1278
+ copiedTimer = setTimeout(() => { copiedTimer = null }, 2000)
1279
+ }, [activePath, notify, t])
1280
+
1281
+ /** Refresh everything currently shown (the single toolbar refresh button). */
1282
+ const refreshAll = React.useCallback(() => {
1283
+ cacheGenerationRef.current += 1
1284
+ prefetchAbortRef.current?.abort()
1285
+ fileCacheRef.current = new Map()
1286
+ diffCacheRef.current = new Map()
1287
+ void refreshLatest()
1288
+ void loadGitStatus()
1289
+ if (activePath) void loadFile(activePath, { force: true })
1290
+ setTreeRefreshToken((value) => value + 1)
1291
+ // Reload the revision comparison (and therefore the tree's file list).
1292
+ setRevisionRefreshToken((value) => value + 1)
1293
+ }, [refreshLatest, loadGitStatus, activePath, loadFile])
1294
+
1295
+ /** Apply one editor font size (clamped) to the live editor, if any. */
1296
+ const applyFontSize = React.useCallback((size) => {
1297
+ const next = Math.min(28, Math.max(9, Math.round(Number(size) || 13)))
1298
+ fontSizeRef.current = next
1299
+ setFontSize(next)
1300
+ try { localStorage.setItem('dsh-git-changes.fontSize', String(next)) } catch (error) { /* optional */ }
1301
+ editorControlsRef.current?.setFontSize?.(next)
1302
+ }, [])
1303
+
1304
+ /** Open the picker overlay in one mode. The revisions picker keeps its scope. */
1305
+ const openPicker = React.useCallback((targetMode) => {
1306
+ setMenuOpen(false)
1307
+ if (targetMode === 'revisions') {
1308
+ setPicker({ mode: 'revisions', scope: revision && revision.scope === 'since' ? 'since' : 'commit' })
1309
+ return
1310
+ }
1311
+ setPicker({ mode: targetMode })
1312
+ }, [revision])
1313
+
1314
+ /**
1315
+ * Switch the page to one git revision baseline. `HEAD` is the one-click
1316
+ * latest-commit shortcut; the host resolves it to the real commit.
1317
+ * @param {object} next revision-ish record (`sha`, `short`, `subject`, `date`)
1318
+ * @param {'commit'|'since'} [scope] comparison scope
1319
+ */
1320
+ const applyRevision = React.useCallback((next, scope) => {
1321
+ if (!next || typeof next.sha !== 'string' || next.sha === '') return
1322
+ setRevision({
1323
+ sha: next.sha,
1324
+ short: next.short ? String(next.short) : next.sha,
1325
+ subject: next.subject ? String(next.subject) : '',
1326
+ date: next.date ? String(next.date) : undefined,
1327
+ scope: scope === 'since' ? 'since' : 'commit',
1328
+ })
1329
+ setMode('revision')
1330
+ setModeTouched(true)
1331
+ }, [])
1332
+
1333
+ /** Change the revisions-picker scope (commit vs since) without closing it. */
1334
+ const changeRevisionScope = React.useCallback((scope) => {
1335
+ const next = scope === 'since' ? 'since' : 'commit'
1336
+ setPicker((current) => (current && current.mode === 'revisions' ? { ...current, scope: next } : current))
1337
+ }, [])
1338
+
1339
+ /**
1340
+ * Apply one picked revision from the picker. A raw typed expression is
1341
+ * resolved through the host (`gitRevisions` with `q`); failures keep the
1342
+ * picker open and surface a warning.
1343
+ * @param {object} picked commit record or `{raw:string}`
1344
+ */
1345
+ const pickRevision = React.useCallback(async (picked) => {
1346
+ if (!picked) return
1347
+ const scope = picker && picker.mode === 'revisions' && picker.scope === 'since' ? 'since' : 'commit'
1348
+ if (typeof picked.raw === 'string') {
1349
+ const raw = picked.raw.trim()
1350
+ if (!raw) return
1351
+ try {
1352
+ const value = await apiGet('gitRevisions', { sessionId, q: raw })
1353
+ const resolved = value && value.resolved
1354
+ if (!resolved) {
1355
+ const reason = value && (value.resolveError || value.reason)
1356
+ notify(t('picker.revisions.resolveFailed', { message: reason || raw }), 'warn')
1357
+ return
1358
+ }
1359
+ applyRevision(resolved, scope)
1360
+ setPicker(null)
1361
+ } catch (failure) {
1362
+ notify(t('picker.revisions.resolveFailed', { message: failure && failure.message ? failure.message : String(failure) }), 'warn')
1363
+ }
1364
+ return
1365
+ }
1366
+ applyRevision(picked, scope)
1367
+ setPicker(null)
1368
+ }, [picker, sessionId, notify, t, applyRevision])
1369
+
1370
+ // In-page shortcut fallback: when the DSH shortcut dispatcher does not consume
1371
+ // the chord (or the binding could not be registered), handle the VS Code–style
1372
+ // keys while focus is inside the review page. Bubble phase + defaultPrevented
1373
+ // keeps native bindings (e.g. Monaco's own Ctrl+G) authoritative.
1374
+ React.useEffect(() => {
1375
+ if (picker) return undefined
1376
+ const onKeyDown = (event) => {
1377
+ try {
1378
+ if (event.defaultPrevented) return
1379
+ const root = rootRef.current
1380
+ if (!root || !(event.target instanceof Node) || !root.contains(event.target)) return
1381
+ const primary = event.ctrlKey || event.metaKey
1382
+ const key = String(event.key || '').toLowerCase()
1383
+ const alt = event.altKey
1384
+ const shift = event.shiftKey
1385
+ if (primary && !alt && !shift && key === 'p') { event.preventDefault(); openPicker('quickopen'); return }
1386
+ if (primary && !alt && shift && key === 'p') { event.preventDefault(); openPicker('commands'); return }
1387
+ if (primary && !alt && shift && key === 'f') { event.preventDefault(); openPicker('search'); return }
1388
+ if (primary && !alt && !shift && key === 'g') { event.preventDefault(); openPicker('line'); return }
1389
+ if (primary && !alt && shift && key === 'e') { event.preventDefault(); setTreeOpen((value) => !value) }
1390
+ if (primary && !alt && shift && key === 'o') { event.preventDefault(); void handlersRef.current.outline?.() }
1391
+ } catch (error) {
1392
+ log('warn', 'key fallback failed', error)
1393
+ }
1394
+ }
1395
+ window.addEventListener('keydown', onKeyDown)
1396
+ return () => window.removeEventListener('keydown', onKeyDown)
1397
+ }, [picker, openPicker])
1398
+
1399
+ /** Open a picked file (optionally revealing a line from a search hit). */
1400
+ const pickFile = React.useCallback((path, line, column, range) => {
1401
+ setPicker(null)
1402
+ const rel = normalizeRel(path)
1403
+ if (!rel) return
1404
+ const target = typeof line === 'number' && line > 0 ? { path: rel, line, column: column || 1, endLine: range?.endLine, endColumn: range?.endColumn } : null
1405
+ pendingRevealRef.current = target
1406
+ if (target && rel === activePath && file?.status === 'ready') {
1407
+ setReveal((previous) => ({ ...target, nonce: previous.nonce + 1 }))
1408
+ return
1409
+ }
1410
+ openPath(rel, { location: target })
1411
+ }, [openPath, activePath, file?.status])
1412
+
1413
+ // Host-version self-check: a stale Host half (LSP or git-version routes not
1414
+ // loaded yet) would otherwise fail silently as "no definition found" or an
1415
+ // empty version picker.
1416
+ React.useEffect(() => {
1417
+ let cancelled = false
1418
+ apiGet('config')
1419
+ .then((value) => {
1420
+ if (cancelled || !value) return
1421
+ const features = value.features || {}
1422
+ if (features.lsp !== true || Number(value.phase) < 3) {
1423
+ notify('插件 Host 端仍是旧版本(phase ' + String(value.phase) + '):LSP 路由不可用,请重启 DSH 后再试', 'warn')
1424
+ } else if (features.gitRevisions !== true) {
1425
+ notify(t('error.hostOutdated'), 'warn')
1426
+ }
1427
+ })
1428
+ .catch(() => { /* config probe is optional */ })
1429
+ return () => { cancelled = true }
1430
+ }, [notify, t])
1431
+ // Register the language-server providers once Monaco is ready, and feed the
1432
+ // bridge the active document whenever the model changes.
1433
+ React.useEffect(() => {
1434
+ if (!monaco) return
1435
+ lspRegisterProviders(monaco, {
1436
+ notify: (text, kind) => notify(text, kind),
1437
+ onMissing: (missing) => setLspMissing(missing),
1438
+ onUnsupported: (language) => notify(t('lsp.unsupported', { language }), 'warn'),
1439
+ openLocation: (path, line, column, range) => {
1440
+ // Historical content is shown at its commit, but navigation locations
1441
+ // for other files are computed against the worktree; the reveal line is
1442
+ // therefore only approximate there.
1443
+ const crossFile = historical && normalizeRel(path) !== activePath
1444
+ pickFile(path, line, column, range)
1445
+ if (crossFile) notify(t('lsp.historicalCrossFile'), 'info')
1446
+ },
1447
+ showLocations: (locations, kind) => { setMenuOpen(false); setPicker({ mode: 'locations', locations, kind }) },
1448
+ })
1449
+ }, [monaco, notify, pickFile, historical, activePath, t])
1450
+
1451
+ /** One click = confirmation: send the install request to the Agent. */
1452
+ const installLspFromAgent = React.useCallback(async () => {
1453
+ const missing = lspMissing
1454
+ if (!missing) return
1455
+ const result = await lspAskAgentToInstall(missing.install, missing.language)
1456
+ if (result === 'sent') {
1457
+ lspForgetMissing()
1458
+ setLspMissing(null)
1459
+ notify(t('lsp.sent'), 'info')
1460
+ return
1461
+ }
1462
+ if (result === 'draft') {
1463
+ notify(t('lsp.inserted'), 'info')
1464
+ return
1465
+ }
1466
+ const ok = await copyText(missing.install)
1467
+ notify(ok ? t('lsp.copied') : missing.install, ok ? 'info' : 'warn')
1468
+ }, [lspMissing, notify, t])
1469
+
1470
+ /** Copy the install command for manual installation. */
1471
+ const copyLspInstall = React.useCallback(async () => {
1472
+ const missing = lspMissing
1473
+ if (!missing) return
1474
+ const ok = await copyText(missing.install)
1475
+ notify(ok ? t('lsp.copied') : missing.install, ok ? 'info' : 'warn')
1476
+ }, [lspMissing, notify, t])
1477
+
1478
+ /** Show the live LSP state (debug aid for missing-server/route problems). */
1479
+ const showLspDiagnostics = React.useCallback(async () => {
1480
+ setMenuOpen(false)
1481
+ const rows = await lspDiagnostics()
1482
+ notify(rows.join(' | '), 'info')
1483
+ try { console.log('[dsh-git-changes lsp]', rows) } catch (error) { /* ignore */ }
1484
+ }, [notify])
1485
+
1486
+ /** Fetch the document outline and list it in the picker. */
1487
+ const openOutline = React.useCallback(async () => {
1488
+ setMenuOpen(false)
1489
+ const symbols = await lspSymbols()
1490
+ if (!Array.isArray(symbols) || symbols.length === 0) {
1491
+ notify(t('lsp.noSymbols'), 'info')
1492
+ return
1493
+ }
1494
+ setPicker({ mode: 'outline', symbols })
1495
+ }, [notify, t])
1496
+
1497
+ // F12 / Shift+F12 in capture phase: Monaco's built-in goToReferences action
1498
+ // otherwise swallows Shift+F12 (it has no provider for our models) and only
1499
+ // F12 would reach our editor command. Capture also covers the non-editor area
1500
+ // of the page, where the editor never sees the key at all.
1501
+ React.useEffect(() => {
1502
+ const onF12 = (event) => {
1503
+ if (event.key !== 'F12' || event.altKey || event.ctrlKey || event.metaKey) return
1504
+ if (picker || event.repeat) return
1505
+ try {
1506
+ const root = rootRef.current
1507
+ if (!root || !(event.target instanceof Node) || !root.contains(event.target)) return
1508
+ event.preventDefault()
1509
+ event.stopPropagation()
1510
+ let position = null
1511
+ try {
1512
+ const editor = editorControlsRef.current?.getEditor?.()
1513
+ position = lspPosition(editor)
1514
+ } catch (error) { /* disposed editor */ }
1515
+ if (!position || !lspRuntime.model) { notify('请先打开代码文件并选中要查询的符号', 'info'); return }
1516
+ if (event.shiftKey) void lspReferences(position.lineNumber, position.column)
1517
+ else void lspDefinition(position.lineNumber, position.column)
1518
+ } catch (error) {
1519
+ log('warn', 'F12 handler failed', error)
1520
+ }
1521
+ }
1522
+ window.addEventListener('keydown', onF12, true)
1523
+ return () => window.removeEventListener('keydown', onF12, true)
1524
+ }, [picker, notify])
1525
+
1526
+ // Palette entries: the plugin command catalog plus a few view actions.
1527
+ const paletteCommands = React.useMemo(() => {
1528
+ const list = COMMAND_SPECS.map((spec) => ({ id: spec.id, label: () => t(spec.message), hint: '' }))
1529
+ list.push(
1530
+ { id: 'mode:git', label: () => t('commands.switchGit'), hint: '' },
1531
+ { id: 'mode:turn', label: () => t('commands.switchTurn'), hint: '' },
1532
+ { id: 'mode:revision-head', label: () => t('commands.switchHead'), hint: '' },
1533
+ { id: 'mode:revision', label: () => t('commands.switchRevision'), hint: '' },
1534
+ { id: 'mode:code', label: () => t('commands.switchCode'), hint: '' },
1535
+ { id: 'shortcuts', label: () => t('action.shortcuts'), hint: 'keys' },
1536
+ )
1537
+ return list
1538
+ }, [t])
1539
+
1540
+ /** Run one command chosen from the palette or a location/outline picker. */
1541
+ const pickCommand = React.useCallback((id) => {
1542
+ const current = picker
1543
+ setPicker(null)
1544
+ if (typeof id === 'string' && id.startsWith('loc:') && current && Array.isArray(current.locations)) {
1545
+ const location = current.locations[Number(id.slice(4))]
1546
+ if (location) pickFile(location.path, location.line, location.column, location)
1547
+ return
1548
+ }
1549
+ if (typeof id === 'string' && id.startsWith('sym:') && current) {
1550
+ const symbol = current.symbols?.[Number(id.slice(4))]
1551
+ if (symbol) pickFile(activePath, symbol.line, symbol.column)
1552
+ return
1553
+ }
1554
+ const handler = handlersRef.current[id]
1555
+ if (typeof handler === 'function') handler()
1556
+ }, [picker, pickFile, activePath])
1557
+
1558
+ // Command handlers shared with the official-shortcut dispatcher.
1559
+ handlersRef.current = {
1560
+ nextChange: () => goToChange(hunkIndex + 1),
1561
+ prevChange: () => goToChange(hunkIndex - 1),
1562
+ nextTab: () => cycleTab(1),
1563
+ prevTab: () => cycleTab(-1),
1564
+ closeTab: () => { if (activePath) closeTab(activePath) },
1565
+ navBack: () => navigateHistory('back'),
1566
+ navForward: () => navigateHistory('forward'),
1567
+ toggleTree: () => setTreeOpen((value) => !value),
1568
+ fontLarger: () => applyFontSize(fontSizeRef.current + 1),
1569
+ fontSmaller: () => applyFontSize(fontSizeRef.current - 1),
1570
+ fontReset: () => applyFontSize(13),
1571
+ 'mode:turn': () => {
1572
+ if (!(latest && latest.available)) return
1573
+ setMode('turn')
1574
+ setModeTouched(true)
1575
+ },
1576
+ 'mode:git': () => {
1577
+ if (!(gitStatus && gitStatus.available === true)) return
1578
+ setMode('git')
1579
+ setModeTouched(true)
1580
+ },
1581
+ 'mode:revision-head': () => applyRevision({ sha: 'HEAD', short: 'HEAD', subject: t('mode.commit') }, 'commit'),
1582
+ 'mode:revision': () => {
1583
+ if (revision) {
1584
+ setMode('revision')
1585
+ setModeTouched(true)
1586
+ return
1587
+ }
1588
+ openPicker('revisions')
1589
+ },
1590
+ 'picker:revisions': () => openPicker('revisions'),
1591
+ 'mode:code': () => {
1592
+ setMode('code')
1593
+ setModeTouched(true)
1594
+ },
1595
+ refresh: refreshAll,
1596
+ copyPath: copyActivePath,
1597
+ openInPreview,
1598
+ openReview: () => { if (activePath) openPath(activePath) },
1599
+ outline: openOutline,
1600
+ lspDiagnostics: () => { void showLspDiagnostics() },
1601
+ lspRecheck: () => { setMenuOpen(false); void lspCheckAvailability(true) },
1602
+ quickOpen: () => openPicker('quickopen'),
1603
+ quickOpenAlt: () => openPicker('quickopen'),
1604
+ gotoLine: () => openPicker('line'),
1605
+ searchWorkspace: () => openPicker('search'),
1606
+ commandPalette: () => openPicker('commands'),
1607
+ shortcuts: () => openPicker('keys'),
1608
+ }
1609
+
1610
+ // Publish this page as the active command target.
1611
+ React.useEffect(() => {
1612
+ const controller = {
1613
+ sessionId,
1614
+ contains: (element) => Boolean(rootRef.current && element && rootRef.current.contains(element)),
1615
+ command: (id) => {
1616
+ const handler = handlersRef.current[id]
1617
+ if (typeof handler !== 'function') return false
1618
+ try {
1619
+ handler()
1620
+ return true
1621
+ } catch (error) {
1622
+ log('error', 'command failed', id, error)
1623
+ return false
1624
+ }
1625
+ },
1626
+ }
1627
+ runtimeState.page = controller
1628
+ runtimeState.sessionId = sessionId
1629
+ return () => {
1630
+ if (runtimeState.page === controller) runtimeState.page = null
1631
+ }
1632
+ }, [sessionId])
1633
+
1634
+ // Keep the latest session id available for the global "open review" command.
1635
+ React.useEffect(() => {
1636
+ if (sessionId) runtimeState.sessionId = sessionId
1637
+ }, [sessionId])
1638
+
1639
+ // Auto-refresh when the turn closes (workspace/changes was appended before
1640
+ // turn/end), and when the window regains focus.
1641
+ React.useEffect(() => {
1642
+ if (!sessionId) return undefined
1643
+ let dispose
1644
+ try {
1645
+ const conversation = runtimeState.conversation
1646
+ const binding = conversation && typeof conversation.binding === 'function' ? conversation.binding(sessionId) : undefined
1647
+ const source = binding && binding.openTurn
1648
+ if (source && typeof source.subscribe === 'function' && typeof source.getSnapshot === 'function') {
1649
+ let previous = source.getSnapshot()
1650
+ const listener = () => {
1651
+ const next = source.getSnapshot()
1652
+ if (previous !== undefined && (next === undefined || next !== previous)) void refreshLatest()
1653
+ previous = next
1654
+ }
1655
+ dispose = source.subscribe(listener)
1656
+ }
1657
+ } catch (error) {
1658
+ /* optional signal */
1659
+ }
1660
+ return () => {
1661
+ try {
1662
+ if (typeof dispose === 'function') dispose()
1663
+ } catch (error) { /* ignore */ }
1664
+ }
1665
+ }, [sessionId, refreshLatest])
1666
+
1667
+ React.useEffect(() => {
1668
+ // Window focus fires constantly while working across apps; a full refresh on
1669
+ // every focus repainted the editor and could visibly jump the diff, so it is
1670
+ // rate-limited to one check per few seconds.
1671
+ let last = 0
1672
+ const refresh = () => {
1673
+ const now = Date.now()
1674
+ if (now - last < 3000) return
1675
+ last = now
1676
+ void refreshLatest()
1677
+ }
1678
+ const onVisibility = () => { if (!document.hidden) refresh() }
1679
+ window.addEventListener('focus', refresh)
1680
+ document.addEventListener('visibilitychange', onVisibility)
1681
+ return () => {
1682
+ window.removeEventListener('focus', refresh)
1683
+ document.removeEventListener('visibilitychange', onVisibility)
1684
+ }
1685
+ }, [refreshLatest])
1686
+
1687
+ // Unmount: cancel in-flight reads.
1688
+ React.useEffect(() => () => {
1689
+ fileTokenRef.current += 1
1690
+ try { abortRef.current?.abort() } catch (error) { /* ignore */ }
1691
+ }, [])
1692
+
1693
+ // Tree width drag (rAF-throttled: one state update per frame).
1694
+ const startTreeResize = (event) => {
1695
+ try {
1696
+ event.preventDefault()
1697
+ } catch (error) { /* ignore */ }
1698
+ const startX = event.clientX
1699
+ const startWidth = treeWidth
1700
+ let frame = 0
1701
+ let pendingWidth = startWidth
1702
+ const flush = () => {
1703
+ frame = 0
1704
+ setTreeWidth(pendingWidth)
1705
+ }
1706
+ const onMove = (moveEvent) => {
1707
+ const delta = startX - moveEvent.clientX
1708
+ pendingWidth = Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, startWidth + delta))
1709
+ if (frame) return
1710
+ try { frame = requestAnimationFrame(flush) } catch (error) { flush() }
1711
+ }
1712
+ const onUp = () => {
1713
+ if (frame) {
1714
+ try { cancelAnimationFrame(frame) } catch (error) { /* ignore */ }
1715
+ frame = 0
1716
+ flush()
1717
+ }
1718
+ window.removeEventListener('mousemove', onMove)
1719
+ window.removeEventListener('mouseup', onUp)
1720
+ }
1721
+ window.addEventListener('mousemove', onMove)
1722
+ window.addEventListener('mouseup', onUp)
1723
+ }
1724
+
1725
+ // Close the overflow/dropdown menus on outside pointer or Escape. Pointer
1726
+ // presses on the trigger wrappers themselves are ignored: otherwise the
1727
+ // outside handler closes the menu and the trigger's click toggles it back on,
1728
+ // which reads as "the button sometimes does nothing".
1729
+ React.useEffect(() => {
1730
+ if (!menuOpen) return undefined
1731
+ const onPointer = (event) => {
1732
+ try {
1733
+ const target = event.target
1734
+ if (target instanceof Element) {
1735
+ if (target.closest('.drp-dropdown, .drp-dropdown-menu')) return
1736
+ }
1737
+ } catch (error) { /* ignore */ }
1738
+ setMenuOpen(false)
1739
+ }
1740
+ const onKey = (event) => {
1741
+ if (event.key === 'Escape') {
1742
+ setMenuOpen(false)
1743
+ }
1744
+ }
1745
+ window.addEventListener('mousedown', onPointer)
1746
+ window.addEventListener('keydown', onKey)
1747
+ return () => {
1748
+ window.removeEventListener('mousedown', onPointer)
1749
+ window.removeEventListener('keydown', onKey)
1750
+ }
1751
+ }, [menuOpen])
1752
+
1753
+ const monacoReady = Boolean(monaco) && !monacoError
1754
+ const comparisonCurrent = comparison && comparison.path === activePath
1755
+ && comparison.snapshotKey === snapshotKey && comparison.generation === cacheGenerationRef.current
1756
+ && comparison.key === diffCacheKey(activePath)
1757
+ const deletedReady = fileCurrent && file.status === 'missing' && comparisonCurrent
1758
+ && comparison.status === 'ready' && comparison.value && comparison.value.kind === 'text'
1759
+ && comparison.value.before === true && comparison.value.after === false
1760
+ && Array.isArray(comparison.value.hunks)
1761
+ const reviewedFile = fileCurrent && file.status === 'ready' ? file
1762
+ : deletedReady ? { ...file, status: 'ready' } : null
1763
+ const fileReady = Boolean(reviewedFile)
1764
+ const missingPending = fileCurrent && file.status === 'missing' && mode !== 'code'
1765
+ && (!comparisonCurrent || comparison.status === 'loading')
1766
+ React.useEffect(() => {
1767
+ // Even a formerly empty document can be deleted without changing the
1768
+ // Monaco model's text, so do not rely only on onModelReady for teardown.
1769
+ if (deletedReady) lspClearDocument()
1770
+ }, [deletedReady, activePath])
1771
+ // Historical (= commit) content is a real document for the language server:
1772
+ // the Host bridge overlays the shown text, so navigation keeps working. Both
1773
+ // directions re-announce it through MonacoHost (the model text can be
1774
+ // byte-identical even when the snapshot kind changed).
1775
+ const historicalRef = React.useRef(false)
1776
+ React.useEffect(() => {
1777
+ if (historicalRef.current === historical) return
1778
+ historicalRef.current = historical
1779
+ setDocumentEpoch((value) => value + 1)
1780
+ }, [historical])
1781
+ const tabsRow = openTabs.length > 0
1782
+ ? h('div', { className: 'drp-tabs', role: 'tablist' }, openTabs.map((path, index) => {
1783
+ const isActive = path === activePath
1784
+ const showDivider = !isActive && index > 0 && openTabs[index - 1] !== activePath
1785
+ return h(
1786
+ React.Fragment,
1787
+ { key: path },
1788
+ showDivider ? h('span', { className: 'drp-tab-divider' }) : null,
1789
+ h(
1790
+ 'div',
1791
+ {
1792
+ className: cx('drp-tab', isActive && 'drp-tab-active'),
1793
+ role: 'tab',
1794
+ 'aria-selected': isActive,
1795
+ title: path,
1796
+ onClick: () => openPath(path),
1797
+ onAuxClick: (event) => { if (event.button === 1) closeTab(path) },
1798
+ },
1799
+ h('span', { className: 'drp-tab-fill' }),
1800
+ h(FileIcon, { path, scheme }),
1801
+ h('span', { className: 'drp-tab-name' }, basename(path)),
1802
+ mode === 'git' && isActive
1803
+ ? h('span', { className: 'drp-tab-scope' }, t(changeScope === 'staged' ? 'changes.tag.staged' : 'changes.tag.unstaged'))
1804
+ : null,
1805
+ h('span', { className: 'drp-tab-actions' },
1806
+ h('button', {
1807
+ type: 'button',
1808
+ className: 'drp-tab-close',
1809
+ title: t('action.closeTab'),
1810
+ 'aria-label': t('action.closeTab'),
1811
+ onClick: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } closeTab(path) },
1812
+ }, h(Codicon, { char: 'close' }))),
1813
+ ),
1814
+ )
1815
+ }))
1816
+ : null
1817
+
1818
+ const menu = menuOpen
1819
+ ? h(
1820
+ 'div',
1821
+ {
1822
+ className: 'drp-dropdown-menu',
1823
+ role: 'menu',
1824
+ onMouseDown: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } },
1825
+ },
1826
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(fontSizeRef.current + 1); } }, t('action.fontLarger') + ' +'),
1827
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(fontSizeRef.current - 1); } }, t('action.fontSmaller') + ' −'),
1828
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(13); setMenuOpen(false) } }, t('action.fontReset') + ' (' + String(fontSize) + 'px)'),
1829
+ h('div', { className: 'drp-menu-sep' }),
1830
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => openPicker('keys') }, t('action.shortcuts')),
1831
+ )
1832
+ : null
1833
+
1834
+ const pickerItems = React.useMemo(() => {
1835
+ if (!picker) return []
1836
+ if (picker.mode === 'locations') {
1837
+ return (picker.locations || []).map((location, index) => ({
1838
+ id: 'loc:' + index,
1839
+ label: () => location.path + ':' + location.line + ':' + location.column,
1840
+ hint: '',
1841
+ }))
1842
+ }
1843
+ if (picker.mode === 'outline') {
1844
+ return (picker.symbols || []).map((symbol, index) => ({
1845
+ id: 'sym:' + index,
1846
+ label: () => ' '.repeat(Math.max(0, symbol.depth || 0)) + symbol.name,
1847
+ hint: symbol.detail || '',
1848
+ }))
1849
+ }
1850
+ return null
1851
+ }, [picker])
1852
+
1853
+ const pickerNode = picker
1854
+ ? h(PickerOverlay, {
1855
+ mode: picker.mode,
1856
+ locationKind: picker.kind,
1857
+ t,
1858
+ sessionId,
1859
+ commands: pickerItems || (picker.mode === 'keys' ? buildKeyHelp(t) : paletteCommands),
1860
+ activePath,
1861
+ lineCount: fileReady ? file.lines : 0,
1862
+ // Revisions picker contract (optional props for every other mode).
1863
+ revisionScope: picker.mode === 'revisions' ? (picker.scope || 'commit') : undefined,
1864
+ onRevisionScopeChange: changeRevisionScope,
1865
+ onPickRevision: pickRevision,
1866
+ onPickFile: pickFile,
1867
+ onPickCommand: pickCommand,
1868
+ onClose: () => setPicker(null),
1869
+ })
1870
+ : null
1871
+
1872
+ // VS Code–style notification: one click on 安装 confirms and sends the install
1873
+ // request to the Agent; the card closes once the request is submitted.
1874
+ const lspBanner = lspMissing
1875
+ ? h('div', { className: 'drp-lsp-toast', role: 'alertdialog', 'aria-label': t('lsp.askAgent') },
1876
+ h('div', { className: 'drp-lsp-toast-head' },
1877
+ h('span', { className: 'drp-lsp-toast-title' }, t('lsp.toastTitle', { language: lspMissing.language })),
1878
+ h('button', { type: 'button', className: 'drp-icon-btn', title: t('common.close'), onClick: () => { lspForgetMissing(); setLspMissing(null) } }, '✕')),
1879
+ h('div', { className: 'drp-lsp-toast-body' }, t('lsp.toastBody')),
1880
+ h('code', { className: 'drp-lsp-toast-cmd', title: lspMissing.install }, lspMissing.install),
1881
+ h('div', { className: 'drp-lsp-toast-actions' },
1882
+ h('button', { type: 'button', className: 'drp-btn drp-btn-primary', onClick: () => { void installLspFromAgent() } }, t('lsp.install')),
1883
+ h('button', { type: 'button', className: 'drp-btn', onClick: () => { void copyLspInstall() } }, t('lsp.copyInstall')),
1884
+ h('button', { type: 'button', className: 'drp-btn', onClick: () => { void lspCheckAvailability(true) } }, t('lsp.recheck')),
1885
+ ),
1886
+ )
1887
+ : null
1888
+
1889
+ // The Monaco host stays mounted for the whole page lifetime. A file switch only
1890
+ // swaps the model inside the existing editor, so the previous file's scroll
1891
+ // position survives and the new content/diff paints immediately (the old code
1892
+ // unmounted the editor on every switch, recreating it and jumping to line 1).
1893
+ // The language server must see the same document in both layout modes.
1894
+ const handleModelReady = (info) => {
1895
+ if (reviewedFile && reviewedFile.missing) {
1896
+ lspClearDocument()
1897
+ return
1898
+ }
1899
+ // Derive the LSP language from the file extension (Monaco's model
1900
+ // language depends on which tokenizers are registered and can be
1901
+ // plaintext); the bridge needs the language server's language id.
1902
+ // Historical content is handed over too: the bridge overlays this text,
1903
+ // so go-to-definition/references/hover/symbols work on the old snapshot
1904
+ // (diagnostics stay suppressed for it, see lspSetDocument).
1905
+ lspSetDocument({
1906
+ path: info.path,
1907
+ text: info.text,
1908
+ languageId: languageOf(info.path),
1909
+ monaco: info.monaco,
1910
+ model: info.model,
1911
+ sessionId,
1912
+ cwd: cwdFromStore,
1913
+ historical,
1914
+ })
1915
+ void lspCheckAvailability()
1916
+ }
1917
+ // Below 900px of editor width the side-by-side view stops reading well
1918
+ // (VS Code uses the same breakpoint for its automatic layout).
1919
+ const viewportWidth = typeof window !== 'undefined' && Number(window.innerWidth) > 0 ? Number(window.innerWidth) : 1280
1920
+ const editorWidth = Math.max(320, viewportWidth - (treeVisible && !narrow ? treeWidth : 0))
1921
+ const effectiveDiffLayout = mode === 'code' ? 'inline' : resolveDiffLayout(diffLayout, editorWidth)
1922
+ const editorCard = effectiveDiffLayout === 'side'
1923
+ ? h(MonacoDiffHost, {
1924
+ monaco,
1925
+ file: reviewedFile,
1926
+ regions: fileReady && comparisonCurrent ? regions : [],
1927
+ sessionId,
1928
+ blame: historical ? null : blameLines,
1929
+ t,
1930
+ fontSize,
1931
+ reveal,
1932
+ scheme,
1933
+ documentEpoch,
1934
+ onCursor: (position) => cursorStore.set(position),
1935
+ onModelReady: handleModelReady,
1936
+ onReady: (controls) => {
1937
+ editorControlsRef.current = controls
1938
+ if (controls && typeof controls.setFontSize === 'function') controls.setFontSize(fontSizeRef.current)
1939
+ },
1940
+ onError: (error) => setMonacoError(error),
1941
+ })
1942
+ : h(MonacoHost, {
1943
+ monaco,
1944
+ file: reviewedFile,
1945
+ regions: fileReady && comparisonCurrent ? regions : [],
1946
+ sessionId,
1947
+ blame: historical ? null : blameLines,
1948
+ t,
1949
+ wrap: false,
1950
+ minimap: false,
1951
+ fontSize,
1952
+ reveal,
1953
+ scheme,
1954
+ documentEpoch,
1955
+ onCursor: (position) => cursorStore.set(position),
1956
+ onRequestLine: () => openPicker('line'),
1957
+ onRequestOutline: () => { void openOutline() },
1958
+ onModelReady: handleModelReady,
1959
+ onLsp: (kind, line, column) => { if (kind === 'definition') void lspDefinition(line, column); else if (kind === 'references') void lspReferences(line, column) },
1960
+ onReady: (controls) => {
1961
+ editorControlsRef.current = controls
1962
+ if (controls && typeof controls.setFontSize === 'function') controls.setFontSize(fontSizeRef.current)
1963
+ },
1964
+ onError: (error) => setMonacoError(error),
1965
+ })
1966
+
1967
+ const codeArea = !activePath
1968
+ ? h(EmptyState, { t, latest })
1969
+ : fileCurrent && file.status === 'handoff'
1970
+ ? h(HandoffCard, {
1971
+ sessionId,
1972
+ path: activePath,
1973
+ kind: file.kind,
1974
+ t,
1975
+ right,
1976
+ absolutePath: file.absolutePath,
1977
+ bytes: file.bytes,
1978
+ })
1979
+ : fileCurrent && (file.status === 'error' || (file.status === 'missing' && !deletedReady && !missingPending))
1980
+ ? h('div', { className: 'drp-empty' },
1981
+ h('div', { className: 'drp-empty-title' }, t('empty.error')),
1982
+ h('div', { className: 'drp-empty-body' }, file.error || ''),
1983
+ h('button', { type: 'button', className: 'drp-btn', onClick: () => loadFile(activePath, { force: true }) }, t('empty.retry')))
1984
+ : monacoError
1985
+ ? h('div', { className: 'drp-empty' },
1986
+ h('div', { className: 'drp-empty-title' }, t('empty.error')),
1987
+ h('div', { className: 'drp-empty-body' }, String(monacoError && monacoError.message ? monacoError.message : monacoError)))
1988
+ : h(React.Fragment, null,
1989
+ monacoReady ? editorCard : null,
1990
+ !monacoReady || !fileReady
1991
+ ? h('div', { className: 'drp-loading drp-loading-overlay' },
1992
+ h('span', { className: 'drp-spinner' }),
1993
+ latestLoading || (file && file.status === 'loading') ? t('status.loadingFile') : t('common.loading'))
1994
+ : null,
1995
+ )
1996
+
1997
+ const revisionFileCount = revisionData && revisionData.available === true && Array.isArray(revisionData.files) ? revisionData.files.length : 0
1998
+ const revisionStatusText = revisionLoading || !revisionData
1999
+ ? t('status.revision.loading')
2000
+ : revisionData.available !== true
2001
+ ? t('status.revision.unavailable')
2002
+ : revisionData.scope === 'since'
2003
+ ? t('status.revision.since', {
2004
+ base: revisionData.base ? revisionData.base.short : '',
2005
+ count: String(revisionFileCount),
2006
+ })
2007
+ : t('status.revision.commit', {
2008
+ ref: revisionData.target ? revisionData.target.short : '',
2009
+ base: revisionData.base ? revisionData.base.short : '',
2010
+ count: String(revisionFileCount),
2011
+ historical: historical ? t('status.revision.historical') : t('status.revision.worktree'),
2012
+ })
2013
+
2014
+ const statusText = status
2015
+ ? status.text
2016
+ : file && file.status === 'loading'
2017
+ ? t('status.loadingFile')
2018
+ : comparison && comparison.status === 'loading'
2019
+ ? t('status.diffLoading')
2020
+ : comparisonCurrent && comparison.status === 'unavailable' && comparison.kind === 'oversized'
2021
+ ? t('status.diffOversized')
2022
+ : mode === 'turn'
2023
+ ? (latest && latest.available
2024
+ ? t('status.mode.turn', { turn: String(latest.turn), seq: String(latest.seq) })
2025
+ : t('status.mode.turn.stale'))
2026
+ : mode === 'git'
2027
+ ? t('status.mode.git')
2028
+ : mode === 'revision'
2029
+ ? revisionStatusText
2030
+ : t('status.mode.code')
2031
+
2032
+ const gitStatusText = mode === 'revision'
2033
+ ? (historical ? t('status.revision.historical') : t('status.revision.worktree'))
2034
+ : mode === 'git' && gitStatus
2035
+ ? (gitStatus.available === true
2036
+ ? t('status.git', { count: String((gitStatus.files || []).length) })
2037
+ : t('status.git.unavailable'))
2038
+ : null
2039
+
2040
+ // Revision browsing always needs the tree (there is no staged state to show).
2041
+ const effectivePanelMode = mode === 'revision' ? 'tree' : panelMode
2042
+ const treeNode = effectivePanelMode === 'changes'
2043
+ ? h(ChangesPanel, {
2044
+ sessionId,
2045
+ activePath,
2046
+ initialStatus: gitStatus,
2047
+ onOpenChangeDiff: openChangeDiff,
2048
+ onOpenCommit: (commit) => applyRevision(commit, 'commit'),
2049
+ onOpenFile: (path) => {
2050
+ setMode('code')
2051
+ setModeTouched(true)
2052
+ openPath(normalizeRel(path))
2053
+ },
2054
+ onWorkspaceChanged: () => setDocumentEpoch((previous) => previous + 1),
2055
+ onNotify: (text, kind) => notify(text, kind),
2056
+ scheme,
2057
+ t,
2058
+ })
2059
+ : h(FileTree, {
2060
+ sessionId,
2061
+ cwd: cwdFromStore,
2062
+ summaryFiles: summary ? summary.files : null,
2063
+ gitStatus: mode === 'revision' ? revisionData : gitStatus,
2064
+ activePath,
2065
+ treeMode: mode === 'revision' ? 'revision' : mode,
2066
+ // Opening from the project tree is plain viewing/editing; only the
2067
+ // revision tree keeps the historical diff baseline.
2068
+ onOpen: (path) => {
2069
+ if (mode !== 'revision') {
2070
+ setMode('code')
2071
+ setModeTouched(true)
2072
+ }
2073
+ openPath(path)
2074
+ },
2075
+ reveal: null,
2076
+ refreshToken: treeRefreshToken,
2077
+ onPreview: (path) => openInPreview(path),
2078
+ onNotify: (text, kind) => notify(text, kind),
2079
+ t,
2080
+ scheme,
2081
+ })
2082
+
2083
+ // Solid popover surfaces per scheme: DSH menu/overlay tokens are translucent,
2084
+ // which reads as washed-out over the code area (user report). All plugin
2085
+ // popovers therefore use opaque plugin-owned colors derived from the scheme.
2086
+ const solidTheme = scheme === 'light'
2087
+ ? {
2088
+ '--drp-panel-bg': '#ffffff',
2089
+ '--drp-panel-bg-2': '#f3f4f6',
2090
+ '--drp-panel-border': 'rgba(31, 35, 40, 0.16)',
2091
+ '--drp-panel-text': '#1f2328',
2092
+ '--drp-git-M': '#9a6700',
2093
+ '--drp-git-A': '#1a7f37',
2094
+ '--drp-git-D': '#cf222e',
2095
+ '--drp-git-U': '#0969da',
2096
+ '--drp-git-R': '#0969da',
2097
+ }
2098
+ : {
2099
+ '--drp-panel-bg': '#1c2128',
2100
+ '--drp-panel-bg-2': '#22272e',
2101
+ '--drp-panel-border': 'rgba(240, 246, 252, 0.16)',
2102
+ '--drp-panel-text': '#e6edf3',
2103
+ '--drp-git-M': '#e3b341',
2104
+ '--drp-git-A': '#56d364',
2105
+ '--drp-git-D': '#ff7b72',
2106
+ '--drp-git-U': '#79c0ff',
2107
+ '--drp-git-R': '#79c0ff',
2108
+ }
2109
+
2110
+ return h(
2111
+ 'div',
2112
+ {
2113
+ className: cx('drp-root', narrow && 'drp-root-narrow'),
2114
+ ref: rootRef,
2115
+ 'data-drp-view': '1',
2116
+ 'data-dsh-git-changes-page': '1',
2117
+ 'data-drp-scheme': scheme,
2118
+ style: solidTheme,
2119
+ },
2120
+ h(
2121
+ 'div',
2122
+ { className: 'drp-main' },
2123
+ tabsRow,
2124
+ h(
2125
+ 'div',
2126
+ { className: 'drp-toolbar' },
2127
+ h(
2128
+ 'div',
2129
+ { className: 'drp-toolbar-row' },
2130
+ h(IconButton, { title: t('action.quickOpen'), label: h(Codicon, { char: 'search' }), onClick: () => openPicker('quickopen') }),
2131
+ h('span', { className: 'drp-path', title: activePath || t('tab.title') }, activePath ? ellipsisMiddle(activePath, narrow ? 24 : 46) : t('tab.title')),
2132
+ // Historical-baseline mode hides the changes panel, so keep an
2133
+ // explicit way back to the worktree baseline in the toolbar.
2134
+ mode === 'revision'
2135
+ ? h('button', {
2136
+ type: 'button',
2137
+ className: cx('drp-btn', 'drp-exit-revision'),
2138
+ title: t('action.exitRevision.hint'),
2139
+ 'aria-label': t('action.exitRevision.hint'),
2140
+ onClick: () => handlersRef.current['mode:git']?.(),
2141
+ }, '← ' + t('action.exitRevision'))
2142
+ : null,
2143
+ h('span', { className: 'drp-spacer' }),
2144
+ // The plugin reviews uncommitted changes; the active baseline is shown
2145
+ // as a plain label instead of a diff-mode picker.
2146
+ mode !== 'code' && comparisonCurrent
2147
+ ? h('button', {
2148
+ type: 'button',
2149
+ className: cx('drp-btn', 'drp-diff-layout'),
2150
+ title: t('diff.layout') + ': ' + t('diff.' + diffLayout),
2151
+ 'aria-label': t('diff.layout') + ': ' + t('diff.' + diffLayout),
2152
+ onClick: () => setDiffLayout((current) => current === 'auto' ? 'side' : current === 'side' ? 'inline' : 'auto'),
2153
+ }, t('diff.' + diffLayout))
2154
+ : null,
2155
+ regions.length > 0
2156
+ ? h('div', { className: 'drp-change-nav' },
2157
+ h(IconButton, {
2158
+ title: t('cmd.prevChange') + ' (Shift+F7)',
2159
+ label: '↑',
2160
+ onClick: () => goToChange(hunkIndex - 1),
2161
+ }),
2162
+ h('span', {
2163
+ className: 'drp-change-nav-count',
2164
+ title: t('status.hunks', { count: String(regions.length) }),
2165
+ }, String(hunkIndex + 1) + '/' + String(regions.length)),
2166
+ h(IconButton, {
2167
+ title: t('cmd.nextChange') + ' (F7)',
2168
+ label: '↓',
2169
+ onClick: () => goToChange(hunkIndex + 1),
2170
+ }))
2171
+ : null,
2172
+ h(IconButton, { title: t('action.refresh'), label: h(Codicon, { char: 'refresh' }), onClick: refreshAll }),
2173
+ h(IconButton, {
2174
+ title: (treeOpen ? t('tree.hide') : t('tree.show')) + ' (' + shortcutModifierLabel('primary') + '+Shift+E)',
2175
+ label: h(Codicon, { char: 'files' }),
2176
+ active: treeOpen,
2177
+ onClick: () => setTreeOpen((value) => !value),
2178
+ }),
2179
+ h(
2180
+ 'div',
2181
+ { className: 'drp-dropdown' },
2182
+ h(IconButton, { title: t('action.more'), label: h(Codicon, { char: 'ellipsis' }), onClick: () => { setMenuOpen((value) => !value) } }),
2183
+ menu,
2184
+ ),
2185
+ ),
2186
+ ),
2187
+ lspBanner,
2188
+ h('div', { className: 'drp-body' }, codeArea),
2189
+ h(ReviewStatusBar, { statusText, file: reviewedFile, gitText: gitStatusText, t, narrow }),
2190
+ pickerNode,
2191
+ ),
2192
+ narrow
2193
+ ? treeOpen
2194
+ ? h('div', { className: 'drp-drawer-overlay', onClick: () => setTreeOpen(false) },
2195
+ h('div', { className: 'drp-drawer', style: { width: Math.max(TREE_WIDTH_MIN, Math.min(TREE_WIDTH_MAX, treeWidth)) + 'px' }, onClick: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } } },
2196
+ treeNode,
2197
+ ))
2198
+ : null
2199
+ : treeVisible
2200
+ ? h(React.Fragment, null,
2201
+ h('div', { className: 'drp-tree-resizer', title: t('status.treeWidth', { width: String(treeWidth) }), onMouseDown: startTreeResize }),
2202
+ h('div', { className: 'drp-tree-panel', style: { width: treeWidth + 'px' } },
2203
+ mode === 'revision'
2204
+ ? null
2205
+ : h('div', { className: 'drp-panel-switch drp-seg', role: 'tablist' },
2206
+ ['changes', 'tree'].map((key) => h('button', {
2207
+ key,
2208
+ type: 'button',
2209
+ role: 'tab',
2210
+ 'aria-selected': effectivePanelMode === key,
2211
+ className: cx('drp-seg-btn', effectivePanelMode === key && 'drp-seg-btn-on'),
2212
+ title: t(key === 'changes' ? 'changes.mode.changes' : 'changes.mode.tree'),
2213
+ onClick: () => setPanelMode(key),
2214
+ }, t(key === 'changes' ? 'changes.mode.changes' : 'changes.mode.tree')))),
2215
+ treeNode),
2216
+ )
2217
+ : null,
2218
+ )
2219
+ }
2220
+
2221
+ /**
2222
+ * The tab-kind body: reads the tab record via `useTabInfo()` and renders the page.
2223
+ * Keyed by session: a different conversation gets a fresh page instance, whose
2224
+ * mount restores that session's remembered tabs/baseline from `pageSessionStore`.
2225
+ * @param {object} props slot props
2226
+ * @returns {any} React element
2227
+ */
2228
+ function ReviewTabBody(props) {
2229
+ const { sessionId, t, useSessions, useTabInfo, services } = props
2230
+ let tab = null
2231
+ try {
2232
+ if (typeof useTabInfo === 'function') tab = useTabInfo().tab
2233
+ } catch (error) {
2234
+ tab = null
2235
+ }
2236
+ const address = tab && tab.contentId ? tab.contentId : null
2237
+ return h(ReviewPage, {
2238
+ key: sessionId,
2239
+ sessionId,
2240
+ t,
2241
+ useSessions,
2242
+ useTabInfo,
2243
+ services,
2244
+ address,
2245
+ })
2246
+ }
2247
+
2248
+ /**
2249
+ * Degraded central-view body (when the right sidebar services are unavailable).
2250
+ * @param {object} props slot props
2251
+ * @returns {any} React element
2252
+ */
2253
+ function ReviewViewBody(props) {
2254
+ const { sessionId, t, useSessions, services } = props
2255
+ return h('div', { className: 'drp-view-wrap' },
2256
+ h('div', { className: 'drp-view-hint' }, t('error.sidebarUnavailable')),
2257
+ h(ReviewPage, { key: sessionId, sessionId, t, useSessions, services, address: null }),
2258
+ )
2259
+ }