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,1404 @@
1
+ /* dsh-git-changes client half — Monaco AMD loader, inline diff renderer, editor host. */
2
+
3
+ /** Singleton Monaco promise; reset on failure so a later mount can retry. */
4
+ let monacoPromise = null
5
+
6
+ /**
7
+ * Load the vendored Monaco AMD build from the authenticated vendor route.
8
+ * Adapted from the MIT `dsh-vscode-mode` loader (global `module`/`exports`
9
+ * pollution guard included: other plugins may define them, which makes the
10
+ * AMD loader believe it runs under Node).
11
+ * @returns {Promise<any>} the `monaco` namespace
12
+ */
13
+ function loadMonaco() {
14
+ if (monacoPromise) return monacoPromise
15
+ monacoPromise = new Promise((resolve, reject) => {
16
+ const base = appUrl(VENDOR_BASE)
17
+ const root = typeof globalThis !== 'undefined' ? globalThis : {}
18
+ const hasModule = Object.prototype.hasOwnProperty.call(root, 'module')
19
+ const hasExports = Object.prototype.hasOwnProperty.call(root, 'exports')
20
+ const savedModule = root.module
21
+ const savedExports = root.exports
22
+ const hideNodeGlobals = () => {
23
+ try { delete root.module } catch (error) { /* read-only global */ }
24
+ try { delete root.exports } catch (error) { /* read-only global */ }
25
+ }
26
+ const restoreNodeGlobals = () => {
27
+ if (hasModule) root.module = savedModule
28
+ else { try { delete root.module } catch (error) { /* ignore */ } }
29
+ if (hasExports) root.exports = savedExports
30
+ else { try { delete root.exports } catch (error) { /* ignore */ } }
31
+ }
32
+ const loaderTagSelector = 'script[data-drp-monaco-loader]'
33
+ const removeLoaderTag = () => {
34
+ const existing = document.querySelector(loaderTagSelector)
35
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing)
36
+ }
37
+ const fail = (error) => {
38
+ monacoPromise = null
39
+ removeLoaderTag()
40
+ restoreNodeGlobals()
41
+ reject(error instanceof Error ? error : new Error(String(error)))
42
+ }
43
+ const boot = () => {
44
+ restoreNodeGlobals()
45
+ try {
46
+ if (typeof window.require !== 'function' || typeof window.require.config !== 'function') {
47
+ fail(new Error('Monaco AMD loader did not expose window.require'))
48
+ return
49
+ }
50
+ window.require.config({ paths: { vs: base } })
51
+ window.require(['vs/editor/editor.main'], () => {
52
+ const monaco = window.monaco
53
+ if (!monaco || !monaco.editor) {
54
+ fail(new Error('Monaco editor namespace missing'))
55
+ return
56
+ }
57
+ registerMonacoThemes(monaco)
58
+ resolve(monaco)
59
+ }, (error) => fail(new Error('Monaco module load failed: ' + String(error))))
60
+ } catch (error) {
61
+ fail(error)
62
+ }
63
+ }
64
+ try {
65
+ const existing = document.querySelector(loaderTagSelector)
66
+ if (existing && typeof window.require === 'function' && typeof window.require.config === 'function') {
67
+ boot()
68
+ return
69
+ }
70
+ if (existing) removeLoaderTag()
71
+ const script = document.createElement('script')
72
+ script.src = base + '/loader.js'
73
+ script.dataset.didMonacoLoader = '1'
74
+ script.onload = boot
75
+ script.onerror = () => fail(new Error('Monaco loader script failed to load'))
76
+ hideNodeGlobals()
77
+ document.head.appendChild(script)
78
+ } catch (error) {
79
+ fail(error)
80
+ }
81
+ })
82
+ return monacoPromise
83
+ }
84
+
85
+ /**
86
+ * Compute git-changes regions from a comparison against the current file text.
87
+ *
88
+ * A region is one contiguous change group. `start`/`end` are 1-based new-file
89
+ * line numbers (`end` exclusive); `oldStart`/`oldLines` describe the deleted
90
+ * original lines shown as an inline block above `start`.
91
+ *
92
+ * @param {any} comparison WorkspaceFileDiff text comparison
93
+ * @param {string} text current file text
94
+ * @returns {{regions: Array<object>, total: number, added: number, deleted: number}}
95
+ */
96
+ function buildRegions(comparison, text) {
97
+ const lines = splitLines(text)
98
+ const lineCount = lines.length
99
+ const empty = { regions: [], total: 0, added: 0, deleted: 0 }
100
+ if (!comparison || comparison.kind !== 'text' || !Array.isArray(comparison.hunks)) return empty
101
+ if (comparison.coarse) {
102
+ if (lineCount === 0) return empty
103
+ const region = { start: 1, end: Math.min(lineCount, MAX_RENDER_LINES) + 1, oldStart: 1, oldLines: [], addedLines: Math.min(lineCount, MAX_RENDER_LINES) }
104
+ return { regions: [region], total: 1, added: lineCount, deleted: 0 }
105
+ }
106
+ const regions = []
107
+ let added = 0
108
+ let deleted = 0
109
+ for (const hunk of comparison.hunks) {
110
+ if (!hunk || !Array.isArray(hunk.lines)) continue
111
+ let newLine = Math.max(1, Number(hunk.newStart) || 1)
112
+ let oldLine = Math.max(1, Number(hunk.oldStart) || 1)
113
+ let index = 0
114
+ while (index < hunk.lines.length) {
115
+ const raw = String(hunk.lines[index])
116
+ const prefix = raw.charAt(0)
117
+ if (prefix === ' ') {
118
+ newLine += 1
119
+ oldLine += 1
120
+ index += 1
121
+ continue
122
+ }
123
+ const start = newLine
124
+ const oldStart = oldLine
125
+ const oldLines = []
126
+ let addedCount = 0
127
+ while (index < hunk.lines.length) {
128
+ const line = String(hunk.lines[index])
129
+ const kind = line.charAt(0)
130
+ if (kind === ' ') break
131
+ if (kind === '-') {
132
+ oldLines.push(line.slice(1))
133
+ oldLine += 1
134
+ deleted += 1
135
+ } else if (kind === '+') {
136
+ addedCount += 1
137
+ added += 1
138
+ }
139
+ index += 1
140
+ }
141
+ if (addedCount === 0 && oldLines.length === 0) continue
142
+ const clampedStart = Math.min(start, Math.max(1, lineCount + 1))
143
+ const clampedAdded = Math.min(addedCount, Math.max(0, lineCount - clampedStart + 1))
144
+ regions.push({
145
+ start: clampedStart,
146
+ end: clampedStart + clampedAdded,
147
+ oldStart,
148
+ oldLines,
149
+ addedLines: clampedAdded,
150
+ })
151
+ newLine += addedCount
152
+ }
153
+ }
154
+ regions.sort((a, b) => a.start - b.start || a.oldStart - b.oldStart)
155
+ return { regions, total: regions.length, added, deleted }
156
+ }
157
+
158
+ /**
159
+ * Per-editor inline diff renderer: whole-line decorations for added lines and
160
+ * Monaco view zones for deleted lines (with their original line numbers).
161
+ * Browser-only; owns its decorations/zones and releases them on dispose.
162
+ * @param {any} monaco Monaco namespace
163
+ * @param {any} editor Monaco editor
164
+ * @returns {{render:Function, refresh:Function, reflow:Function, dispose:Function}}
165
+ */
166
+ function createDiffRenderer(monaco, editor) {
167
+ let zones = []
168
+ let listeners = []
169
+ let lastRegions = null
170
+ let generation = 0
171
+ let disposed = false
172
+
173
+ const colorVars = () => diffCssVarsOf(themeRuntime.scheme || detectScheme())
174
+
175
+ /**
176
+ * Apply the diff color variables to the editor DOM.
177
+ * @param {HTMLElement} node target node
178
+ */
179
+ const setColors = (node) => {
180
+ if (!node || !node.style) return
181
+ const vars = colorVars()
182
+ node.style.setProperty('--drp-diff-add', vars.add)
183
+ node.style.setProperty('--drp-diff-del', vars.del)
184
+ node.style.setProperty('--drp-diff-fg', vars.fg)
185
+ node.style.setProperty('--drp-diff-number', vars.number)
186
+ }
187
+
188
+ /** Remove every decoration and view zone this renderer owns. */
189
+ const clear = () => {
190
+ generation += 1
191
+ lastRegions = null
192
+ try {
193
+ if (decorations.length > 0) decorations = editor.deltaDecorations(decorations, [])
194
+ } catch (error) {
195
+ decorations = []
196
+ }
197
+ try {
198
+ if (zones.length > 0) {
199
+ editor.changeViewZones((accessor) => {
200
+ for (const zone of zones) {
201
+ try { accessor.removeZone(zone.id) } catch (error) { /* already gone */ }
202
+ }
203
+ })
204
+ }
205
+ } catch (error) { /* editor may be disposed */ }
206
+ zones = []
207
+ }
208
+
209
+ /**
210
+ * Map a pointer position onto a caret inside a deleted-line zone. Coordinates
211
+ * outside the zone are clamped to its first/last row so a drag that starts in
212
+ * the zone can never create a page-wide native selection.
213
+ * @param {object} zone zone record
214
+ * @param {number} x client X
215
+ * @param {number} y client Y
216
+ * @returns {{node:Node, offset:number} | null}
217
+ */
218
+ const zoneCaretPoint = (zone, x, y) => {
219
+ const rows = zone.domNode.querySelectorAll('.drp-del-row')
220
+ if (!rows.length) return null
221
+ const first = rows[0]
222
+ const last = rows[rows.length - 1]
223
+ const firstRect = first.getBoundingClientRect()
224
+ const lastRect = last.getBoundingClientRect()
225
+ const firstText = zone.rows[0] && zone.rows[0].firstChild
226
+ const lastText = zone.rows[zone.rows.length - 1] && zone.rows[zone.rows.length - 1].firstChild
227
+ if (y <= firstRect.top) return firstText ? { node: firstText, offset: 0 } : null
228
+ if (y >= lastRect.bottom) return lastText ? { node: lastText, offset: lastText.textContent.length } : null
229
+ let caret = null
230
+ try { caret = document.caretRangeFromPoint(x, y) } catch (error) { caret = null }
231
+ if (caret && zone.domNode.contains(caret.startContainer)) {
232
+ return { node: caret.startContainer, offset: caret.startOffset }
233
+ }
234
+ const rowCount = Math.max(1, rows.length)
235
+ const step = rowCount > 1 ? (lastRect.top - firstRect.top) / (rowCount - 1) : 20
236
+ const index = Math.max(0, Math.min(rowCount - 1, Math.floor((y - firstRect.top) / Math.max(1, step))))
237
+ const text = zone.rows[index] && zone.rows[index].firstChild
238
+ if (!text) return null
239
+ const right = rows[index].getBoundingClientRect().right
240
+ return { node: text, offset: x >= right ? text.textContent.length : 0 }
241
+ }
242
+
243
+ /** Zone DOM node → zone record (mouse selection needs the text rows). */
244
+ const zoneByNode = new WeakMap()
245
+ let zoneMouseInstalled = false
246
+
247
+ /** Apply one restricted Range selection inside a deleted-line zone. */
248
+ const applyZoneRange = (anchor, focus) => {
249
+ try {
250
+ const anchorFirst = anchor.node === focus.node
251
+ ? anchor.offset <= focus.offset
252
+ : Boolean(anchor.node.compareDocumentPosition(focus.node) & 4)
253
+ const start = anchorFirst ? anchor : focus
254
+ const end = anchorFirst ? focus : anchor
255
+ const range = document.createRange()
256
+ range.setStart(start.node, start.offset)
257
+ range.setEnd(end.node, end.offset)
258
+ const selection = window.getSelection()
259
+ if (!selection) return
260
+ selection.removeAllRanges()
261
+ selection.addRange(range)
262
+ } catch (error) { /* ignore */ }
263
+ }
264
+
265
+ /**
266
+ * Install one document-level capture handler for deleted-line zones. Capture is
267
+ * required: Monaco's own mousedown handling would otherwise clear the native
268
+ * Range we create for the zone (the editor normally keeps native selection off).
269
+ */
270
+ const installZoneMouse = () => {
271
+ if (zoneMouseInstalled) return
272
+ zoneMouseInstalled = true
273
+ document.addEventListener('mousedown', (event) => {
274
+ if (event.button !== 0) return
275
+ const target = event.target
276
+ if (!(target instanceof Element) || !target.closest) return
277
+ const zoneEl = target.closest('.drp-del-zone')
278
+ if (!zoneEl) return
279
+ if (target.closest('.drp-del-copy')) return
280
+ const zone = zoneByNode.get(zoneEl)
281
+ if (!zone) return
282
+ const anchor = zoneCaretPoint(zone, event.clientX, event.clientY)
283
+ if (!anchor) return
284
+ try {
285
+ event.preventDefault()
286
+ event.stopPropagation()
287
+ } catch (error) { /* ignore */ }
288
+ applyZoneRange(anchor, anchor)
289
+ const onMove = (moveEvent) => {
290
+ if (moveEvent.buttons === 0) return
291
+ const focus = zoneCaretPoint(zone, moveEvent.clientX, moveEvent.clientY)
292
+ if (focus) applyZoneRange(anchor, focus)
293
+ try { moveEvent.preventDefault(); moveEvent.stopPropagation() } catch (error) { /* ignore */ }
294
+ }
295
+ const onUp = (upEvent) => {
296
+ document.removeEventListener('mousemove', onMove, true)
297
+ document.removeEventListener('mouseup', onUp, true)
298
+ try { upEvent.stopPropagation() } catch (error) { /* ignore */ }
299
+ }
300
+ document.addEventListener('mousemove', onMove, true)
301
+ document.addEventListener('mouseup', onUp, true)
302
+ }, true)
303
+ }
304
+
305
+ /**
306
+ * Build one deleted-line view zone (body + gutter twin).
307
+ * @param {object} region change group
308
+ * @returns {object} zone record
309
+ */
310
+ const makeZone = (region) => {
311
+ const domNode = document.createElement('div')
312
+ domNode.className = 'drp-del-zone'
313
+ const marginDomNode = document.createElement('div')
314
+ marginDomNode.className = 'drp-del-margin'
315
+ const rows = []
316
+ for (let index = 0; index < region.oldLines.length; index += 1) {
317
+ const row = document.createElement('div')
318
+ row.className = 'drp-del-row'
319
+ const code = document.createElement('span')
320
+ code.className = 'drp-del-text'
321
+ code.textContent = region.oldLines[index]
322
+ row.appendChild(code)
323
+ domNode.appendChild(row)
324
+ rows.push(code)
325
+ const marginRow = document.createElement('div')
326
+ marginRow.className = 'drp-del-margin-row'
327
+ const number = document.createElement('span')
328
+ number.className = 'drp-del-number'
329
+ number.textContent = String((region.oldStart || 1) + index)
330
+ const marker = document.createElement('span')
331
+ marker.className = 'drp-del-marker'
332
+ marker.textContent = '−'
333
+ marginRow.appendChild(number)
334
+ marginRow.appendChild(marker)
335
+ marginDomNode.appendChild(marginRow)
336
+ }
337
+ const zone = {
338
+ domNode,
339
+ marginDomNode,
340
+ rows,
341
+ lines: region.oldLines,
342
+ after: Math.max(0, (Number(region.start) || 1) - 1),
343
+ }
344
+ // One-click copy for the whole deleted block, placed in the gutter so it
345
+ // never covers the deleted code text itself.
346
+ const copyBtn = document.createElement('button')
347
+ copyBtn.type = 'button'
348
+ copyBtn.className = 'drp-del-copy'
349
+ copyBtn.title = '复制删除的行'
350
+ copyBtn.textContent = '\u29C9'
351
+ copyBtn.addEventListener('mousedown', (event) => {
352
+ try { event.preventDefault(); event.stopPropagation() } catch (error) { /* ignore */ }
353
+ })
354
+ copyBtn.addEventListener('click', (event) => {
355
+ try { event.preventDefault(); event.stopPropagation() } catch (error) { /* ignore */ }
356
+ const text = zone.lines.join('\n')
357
+ Promise.resolve(typeof copyText === 'function' ? copyText(text) : false)
358
+ .then((ok) => {
359
+ copyBtn.textContent = ok ? '\u2713' : '!'
360
+ setTimeout(() => { copyBtn.textContent = '\u29C9' }, 1300)
361
+ })
362
+ .catch(() => { copyBtn.textContent = '!' })
363
+ })
364
+ marginDomNode.appendChild(copyBtn)
365
+ zoneByNode.set(domNode, zone)
366
+ installZoneMouse()
367
+ return zone
368
+ }
369
+
370
+ /**
371
+ * Align a zone with the editor's font, gutter geometry and theme.
372
+ * @param {object} zone zone record
373
+ * @returns {number} minimum width in px for horizontal scrolling
374
+ */
375
+ const layoutZone = (zone) => {
376
+ const vars = colorVars()
377
+ try { editor.applyFontInfo?.(zone.domNode) } catch (error) { /* optional */ }
378
+ try { editor.applyFontInfo?.(zone.marginDomNode) } catch (error) { /* optional */ }
379
+ setColors(zone.domNode)
380
+ setColors(zone.marginDomNode)
381
+ let lineHeight = 20
382
+ let layout = { lineNumbersLeft: 0, lineNumbersWidth: 0, decorationsLeft: 0, decorationsWidth: 0 }
383
+ let fontInfo = null
384
+ try {
385
+ lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight) || 20
386
+ layout = editor.getLayoutInfo() || layout
387
+ fontInfo = editor.getOption(monaco.editor.EditorOption.fontInfo)
388
+ } catch (error) { /* defaults */ }
389
+ const tabSize = (zone.domNode && zone.domNode.style && Number(zone.domNode.style.tabSize)) || 2
390
+ for (const node of [zone.domNode, zone.marginDomNode]) {
391
+ node.style.setProperty('--drp-line-height', lineHeight + 'px')
392
+ }
393
+ zone.domNode.style.tabSize = String(tabSize || 2)
394
+ zone.marginDomNode.style.setProperty('--drp-number-left', layout.lineNumbersLeft + 'px')
395
+ zone.marginDomNode.style.setProperty('--drp-number-width', layout.lineNumbersWidth + 'px')
396
+ zone.marginDomNode.style.setProperty('--drp-marker-left', layout.decorationsLeft + 'px')
397
+ zone.marginDomNode.style.setProperty('--drp-marker-width', layout.decorationsWidth + 'px')
398
+ zone.marginDomNode.style.setProperty('--drp-diff-number', vars.number)
399
+ let width = 0
400
+ for (const line of zone.lines) width = Math.max(width, String(line).replace(/\t/g, ' '.repeat(4)).length)
401
+ const charWidth = Math.max(
402
+ (fontInfo && fontInfo.typicalHalfwidthCharacterWidth) || 8,
403
+ (fontInfo && fontInfo.typicalFullwidthCharacterWidth) || 0,
404
+ )
405
+ return width * charWidth + 24
406
+ }
407
+
408
+ /**
409
+ * Asynchronously syntax-color deleted lines with Monaco's own tokenizer.
410
+ * @param {object} zone zone record
411
+ * @param {number} epoch render generation
412
+ */
413
+ const colorizeZone = async (zone, epoch) => {
414
+ try {
415
+ if (typeof monaco.editor.colorize !== 'function') return
416
+ const model = editor.getModel()
417
+ if (!model) return
418
+ const language = model.getLanguageId()
419
+ const tabSize = (model.getOptions && model.getOptions().tabSize) || 2
420
+ const html = await monaco.editor.colorize(zone.lines.join('\n'), language, { tabSize })
421
+ if (disposed || epoch !== generation || editor.getModel() !== model) return
422
+ const rows = String(html).split(/<br\s*\/?\s*>/i)
423
+ if (rows.length === zone.rows.length + 1 && rows[rows.length - 1] === '') rows.pop()
424
+ if (rows.length !== zone.rows.length) return
425
+ for (let index = 0; index < rows.length; index += 1) zone.rows[index].innerHTML = rows[index]
426
+ } catch (error) {
427
+ /* keep the plain-text fallback visible */
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Paint regions onto the current model.
433
+ * @param {Array<object>} regions change groups (see buildRegions)
434
+ */
435
+ const paint = (regions) => {
436
+ lastRegions = regions
437
+ clear()
438
+ lastRegions = regions
439
+ if (!editor || disposed) return
440
+ const model = editor.getModel()
441
+ if (!model) return
442
+ setColors(editor.getDomNode())
443
+ const decorationsSpec = []
444
+ const zoneSpecs = []
445
+ for (const region of regions) {
446
+ if (region.addedLines > 0 && region.start >= 1) {
447
+ decorationsSpec.push({
448
+ range: new monaco.Range(region.start, 1, Math.max(region.start, region.end - 1), 1),
449
+ options: {
450
+ isWholeLine: true,
451
+ className: 'drp-add-line',
452
+ marginClassName: 'drp-add-line',
453
+ linesDecorationsClassName: 'drp-add-gutter',
454
+ },
455
+ })
456
+ }
457
+ if (region.oldLines.length > 0) zoneSpecs.push(makeZone(region))
458
+ }
459
+ try {
460
+ decorations = editor.deltaDecorations([], decorationsSpec)
461
+ } catch (error) {
462
+ decorations = []
463
+ }
464
+ if (zoneSpecs.length > 0) {
465
+ try {
466
+ editor.changeViewZones((accessor) => {
467
+ for (const zone of zoneSpecs) {
468
+ const minWidthInPx = layoutZone(zone)
469
+ zone.id = accessor.addZone({
470
+ afterLineNumber: zone.after,
471
+ heightInLines: Math.max(1, zone.lines.length),
472
+ domNode: zone.domNode,
473
+ marginDomNode: zone.marginDomNode,
474
+ minWidthInPx,
475
+ // Deleted lines are plain DOM (they are not part of the Monaco
476
+ // text model). Suppressing the mouse would make the whole block
477
+ // unselectable and uncopyable, so let the browser handle drag
478
+ // selection inside the zone like normal text.
479
+ suppressMouseDown: false,
480
+ })
481
+ zones.push(zone)
482
+ void colorizeZone(zone, generation)
483
+ }
484
+ })
485
+ } catch (error) {
486
+ zones = []
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Re-layout zones after a theme or geometry change.
493
+ * @returns {void}
494
+ */
495
+ const reflow = () => {
496
+ if (disposed) return
497
+ setColors(editor.getDomNode())
498
+ for (const zone of zones) {
499
+ try { layoutZone(zone) } catch (error) { /* ignore */ }
500
+ }
501
+ for (const zone of zones) void colorizeZone(zone, generation)
502
+ }
503
+
504
+ // Layout changes fire on every splitter frame. Only the geometry of the
505
+ // existing zones needs to follow (gutter offsets, line height); re-tokenizing
506
+ // the deleted lines on every frame is what made the DSH divider drag stutter.
507
+ let layoutFrame = 0
508
+ const onLayout = () => {
509
+ if (disposed || layoutFrame) return
510
+ layoutFrame = requestAnimationFrame(() => {
511
+ layoutFrame = 0
512
+ if (disposed) return
513
+ try {
514
+ setColors(editor.getDomNode())
515
+ for (const zone of zones) layoutZone(zone)
516
+ } catch (error) { /* ignore */ }
517
+ })
518
+ }
519
+ try { listeners.push(editor.onDidLayoutChange(onLayout)) } catch (error) { /* ignore */ }
520
+ try { listeners.push(editor.onDidChangeModel(() => clear())) } catch (error) { /* ignore */ }
521
+
522
+ return {
523
+ render(regions) {
524
+ paint(Array.isArray(regions) ? regions : [])
525
+ },
526
+ refresh() {
527
+ paint(lastRegions || [])
528
+ },
529
+ reflow,
530
+ dispose() {
531
+ disposed = true
532
+ if (layoutFrame) {
533
+ try { cancelAnimationFrame(layoutFrame) } catch (error) { /* ignore */ }
534
+ layoutFrame = 0
535
+ }
536
+ clear()
537
+ for (const listener of listeners) {
538
+ try { listener.dispose() } catch (error) { /* ignore */ }
539
+ }
540
+ listeners = []
541
+ },
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Read-only Monaco editor host: one editor instance, one model per reviewed file,
547
+ * per-file view state, inline diff decorations refreshed from `regions`.
548
+ * @param {object} props component props
549
+ * @returns {any} React element
550
+ */
551
+ /** Minimum editor width that still reads well side by side (VS Code default). */
552
+ const DIFF_SIDE_MIN_WIDTH = 900
553
+
554
+ /**
555
+ * Resolve the effective diff layout for the current viewport.
556
+ * @param {string} preference stored preference ('auto' | 'inline' | 'side')
557
+ * @param {number} width available editor width in CSS pixels
558
+ * @returns {'inline' | 'side'}
559
+ */
560
+ function resolveDiffLayout(preference, width) {
561
+ if (preference === 'side') return 'side'
562
+ if (preference === 'inline') return 'inline'
563
+ return Number(width) >= DIFF_SIDE_MIN_WIDTH ? 'side' : 'inline'
564
+ }
565
+
566
+ /**
567
+ * Rebuild the "before" side of a diff from the current text and the built regions.
568
+ *
569
+ * `buildRegions` already carries each changed block's old lines, so the original
570
+ * document can be reconstructed without another Host round trip.
571
+ * @param {string} text current ("after") document text
572
+ * @param {Array<object>} regions built diff regions
573
+ * @returns {string} original ("before") document text
574
+ */
575
+ function originalTextFromRegions(text, regions) {
576
+ const current = text === '' ? [] : String(text || '').split('\n')
577
+ const list = Array.isArray(regions) ? regions.slice() : []
578
+ list.sort((a, b) => (Number(a.start) || 0) - (Number(b.start) || 0) || (Number(a.oldStart) || 0) - (Number(b.oldStart) || 0))
579
+ const out = []
580
+ let cursor = 0
581
+ for (const region of list) {
582
+ const start = Math.max(1, Number(region.start) || 1) - 1
583
+ const added = Math.max(0, Number(region.addedLines) || 0)
584
+ const target = Math.min(current.length, start)
585
+ for (let index = cursor; index < target; index += 1) out.push(current[index])
586
+ for (const line of (Array.isArray(region.oldLines) ? region.oldLines : [])) out.push(String(line))
587
+ cursor = Math.min(current.length, target + added)
588
+ }
589
+ for (let index = cursor; index < current.length; index += 1) out.push(current[index])
590
+ return out.join('\n')
591
+ }
592
+
593
+ /**
594
+ * Read-only side-by-side diff host backed by `monaco.editor.createDiffEditor`.
595
+ *
596
+ * Kept separate from `MonacoHost` on purpose: the inline renderer owns the
597
+ * single-editor path (decorations, LSP announcements, view-state caching), while
598
+ * this host only exists to show Cursor-style two-column diffs. Switching layouts
599
+ * therefore disposes one host and creates the other, which matches the design
600
+ * decision to rebuild the editor instead of mutating it in place.
601
+ * @param {object} props component props
602
+ * @returns {any} React element
603
+ */
604
+ function MonacoDiffHost(props) {
605
+ const { monaco, file, regions, sessionId, scheme, fontSize, reveal, blame, documentEpoch, onCursor, onReady, onError, onModelReady, t } = props
606
+ const hostRef = React.useRef(null)
607
+ const editorRef = React.useRef(null)
608
+ const modelsRef = React.useRef([])
609
+ const blameDwellRef = React.useRef(null)
610
+ const blameRef = React.useRef(blame)
611
+ blameRef.current = blame
612
+ const fileRef = React.useRef(file)
613
+ fileRef.current = file
614
+ const tRef = React.useRef(t)
615
+ tRef.current = t
616
+ const callbacksRef = React.useRef({ onCursor, onReady, onError, onModelReady })
617
+ callbacksRef.current = { onCursor, onReady, onError, onModelReady }
618
+ // The custom themes carry the git diff colours (inserted/removed line and
619
+ // word backgrounds); the built-in vs/vs-dark themes do not, which made the
620
+ // side-by-side view look like plain code with no highlights.
621
+ const themeOf = () => themeRuntime.scheme === 'light' ? MONACO_THEME.light : MONACO_THEME.dark
622
+
623
+ /**
624
+ * Paint the diff tints through CSS variables on the host node. The custom
625
+ * Monaco theme carries the same colors, but pinning them here guarantees the
626
+ * green/red backgrounds survive even when the theme falls back to a base
627
+ * palette (or an older Monaco build ignores the diff color keys).
628
+ * @param {HTMLElement | null} node host node
629
+ */
630
+ const setDiffColors = (node) => {
631
+ if (!node || !node.style) return
632
+ const vars = diffCssVarsOf(themeRuntime.scheme === 'light' ? 'light' : 'dark')
633
+ node.style.setProperty('--drp-diff-add', vars.add)
634
+ node.style.setProperty('--drp-diff-del', vars.del)
635
+ node.style.setProperty('--drp-diff-add-word', vars.addWord)
636
+ node.style.setProperty('--drp-diff-del-word', vars.delWord)
637
+ node.style.setProperty('--vscode-diffEditor-insertedLineBackground', vars.add)
638
+ node.style.setProperty('--vscode-diffEditor-removedLineBackground', vars.del)
639
+ node.style.setProperty('--vscode-diffEditor-insertedTextBackground', vars.addWord)
640
+ node.style.setProperty('--vscode-diffEditor-removedTextBackground', vars.delWord)
641
+ }
642
+
643
+ /** Create the diff editor once the host node and Monaco both exist. */
644
+ const attach = React.useCallback((node) => {
645
+ hostRef.current = node
646
+ if (!node || editorRef.current || !monaco) return
647
+ try {
648
+ // Register/apply the plugin themes before the diff editor is built so the
649
+ // inserted/removed backgrounds are present from the first paint.
650
+ try { applyMonacoTheme(monaco) } catch (error) { /* keep whatever theme is active */ }
651
+ setDiffColors(node)
652
+ const editor = monaco.editor.createDiffEditor(node, {
653
+ theme: themeOf(),
654
+ readOnly: true,
655
+ originalEditable: false,
656
+ renderSideBySide: true,
657
+ enableSplitViewResizing: true,
658
+ renderIndicators: true,
659
+ ignoreTrimWhitespace: false,
660
+ diffAlgorithm: 'advanced',
661
+ renderOverviewRuler: false,
662
+ automaticLayout: true,
663
+ scrollBeyondLastLine: false,
664
+ minimap: { enabled: false },
665
+ glyphMargin: false,
666
+ lineNumbers: 'on',
667
+ folding: true,
668
+ matchBrackets: 'always',
669
+ renderLineHighlight: 'none',
670
+ smoothScrolling: false,
671
+ mouseWheelZoom: true,
672
+ fixedOverflowWidgets: true,
673
+ fontSize: Math.min(28, Math.max(9, Math.round(Number(fontSize) || 13))),
674
+ lineHeight: 20,
675
+ fontFamily: 'var(--ds-font-family-code, var(--dsh-code-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace))',
676
+ tabSize: 2,
677
+ padding: { top: 8, bottom: 24 },
678
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
679
+ })
680
+ editorRef.current = editor
681
+ const modified = editor.getModifiedEditor()
682
+ try {
683
+ modified.onDidChangeCursorPosition((event) => {
684
+ callbacksRef.current.onCursor?.({ line: event.position.lineNumber, column: event.position.column })
685
+ })
686
+ } catch (error) { /* optional */ }
687
+ const dwells = []
688
+ const blameOptions = () => ({ getBlame: () => blameRef.current, sessionId, getPath: () => (fileRef.current && fileRef.current.path) || '', t: tRef.current })
689
+ try {
690
+ const original = typeof editor.getOriginalEditor === 'function' ? editor.getOriginalEditor() : null
691
+ if (original) dwells.push(attachBlameDwell(original, monaco, blameOptions(), (typeof original.getDomNode === 'function' ? original.getDomNode() : null) || hostRef.current))
692
+ } catch (error) { /* optional */ }
693
+ try { dwells.push(attachBlameDwell(modified, monaco, blameOptions(), hostRef.current)) } catch (error) { /* optional */ }
694
+ blameDwellRef.current = { dispose: () => { for (const dwell of dwells) { try { dwell.dispose?.() } catch (error) { /* ignore */ } } } }
695
+ callbacksRef.current.onReady?.({
696
+ revealLine: (line) => {
697
+ try {
698
+ const target = Math.max(1, Number(line) || 1)
699
+ modified.revealLineInCenter?.(target)
700
+ modified.setPosition({ lineNumber: target, column: 1 })
701
+ } catch (error) { /* ignore */ }
702
+ },
703
+ focus: () => { try { modified.focus() } catch (error) { /* ignore */ } },
704
+ layout: () => { try { editor.layout() } catch (error) { /* ignore */ } },
705
+ getEditor: () => editor,
706
+ setFontSize: (size) => {
707
+ try { editor.updateOptions({ fontSize: Math.min(28, Math.max(9, Math.round(Number(size) || 13))) }) } catch (error) { /* ignore */ }
708
+ },
709
+ })
710
+ } catch (error) {
711
+ log('error', 'monaco diff editor creation failed', error)
712
+ callbacksRef.current.onError?.(error)
713
+ }
714
+ }, [monaco])
715
+
716
+ // Disposal.
717
+ React.useEffect(() => () => {
718
+ try { blameDwellRef.current?.dispose?.() } catch (error) { /* ignore */ }
719
+ blameDwellRef.current = null
720
+ const editor = editorRef.current
721
+ editorRef.current = null
722
+ if (editor) { try { editor.dispose() } catch (error) { /* ignore */ } }
723
+ for (const model of modelsRef.current) { try { model.dispose() } catch (error) { /* ignore */ } }
724
+ modelsRef.current = []
725
+ }, [])
726
+
727
+ // (Re)build both models whenever the file, its text or the regions change.
728
+ React.useEffect(() => {
729
+ const editor = editorRef.current
730
+ if (!editor || !monaco || !file || file.kind !== 'text' || typeof file.text !== 'string') return
731
+ try {
732
+ for (const model of modelsRef.current) { try { model.dispose() } catch (error) { /* ignore */ } }
733
+ modelsRef.current = []
734
+ const path = file.path || ''
735
+ const base = 'did:///' + encodeURIComponent(sessionId || 'workspace') + '/' + path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/')
736
+ const language = file.language || 'plaintext'
737
+ const original = monaco.editor.createModel(originalTextFromRegions(file.text, regions), language, monaco.Uri.parse(base + '?original'))
738
+ const modified = monaco.editor.createModel(file.text, language, monaco.Uri.parse(base + '?modified'))
739
+ modelsRef.current = [original, modified]
740
+ editor.setModel({ original, modified })
741
+ try { editor.layout() } catch (error) { /* ignore */ }
742
+ callbacksRef.current.onModelReady?.({ path, text: file.text, language, monaco, model: modified })
743
+ } catch (error) {
744
+ log('error', 'monaco diff model switch failed', error)
745
+ }
746
+ }, [monaco, sessionId, file && file.path, file && file.text, file && file.language, file && file.kind, regions])
747
+
748
+ // Theme + font size are prop-driven like the inline host.
749
+ React.useEffect(() => {
750
+ const editor = editorRef.current
751
+ if (!editor || !monaco) return
752
+ try {
753
+ applyMonacoTheme(monaco)
754
+ editor.updateOptions({ theme: themeOf() })
755
+ setDiffColors(hostRef.current)
756
+ } catch (error) { /* ignore */ }
757
+ }, [monaco, scheme])
758
+
759
+ React.useEffect(() => {
760
+ const editor = editorRef.current
761
+ if (!editor) return
762
+ try { editor.updateOptions({ fontSize: Math.min(28, Math.max(9, Math.round(Number(fontSize) || 13))) }) } catch (error) { /* ignore */ }
763
+ }, [fontSize, file && file.path])
764
+
765
+ // Reveal requests: both panes follow the modified line number.
766
+ React.useEffect(() => {
767
+ const editor = editorRef.current
768
+ if (!editor || !reveal || !reveal.line) return
769
+ try {
770
+ const target = Math.max(1, Number(reveal.line) || 1)
771
+ const modified = editor.getModifiedEditor()
772
+ modified.revealLineInCenter(target)
773
+ modified.setPosition({ lineNumber: target, column: 1 })
774
+ modified.focus()
775
+ } catch (error) { /* ignore */ }
776
+ }, [reveal && reveal.line, reveal && reveal.nonce])
777
+
778
+ return h('div', { className: 'drp-monaco-host drp-monaco-diff', ref: attach })
779
+ }
780
+
781
+ /** How long the cursor must rest on a line before its inline blame appears. */
782
+ const BLAME_DWELL_MS = 900
783
+ /** Grace period before the commit popover opens on the annotation. */
784
+ const BLAME_POPOVER_DELAY_MS = 120
785
+
786
+ /** Human-readable age of a commit (falls back to a plain date). */
787
+ function blameAge(time, t) {
788
+ if (!Number.isFinite(time)) return ''
789
+ const delta = Date.now() - time
790
+ if (delta < 60 * 1000) return t ? t('blame.justNow') : '刚刚'
791
+ if (delta < 60 * 60 * 1000) return t ? t('blame.minutesAgo', { count: String(Math.floor(delta / 60000)) }) : Math.floor(delta / 60000) + ' 分钟前'
792
+ if (delta < 24 * 60 * 60 * 1000) return t ? t('blame.hoursAgo', { count: String(Math.floor(delta / 3600000)) }) : Math.floor(delta / 3600000) + ' 小时前'
793
+ if (delta < 30 * 24 * 60 * 60 * 1000) return t ? t('blame.daysAgo', { count: String(Math.floor(delta / 86400000)) }) : Math.floor(delta / 86400000) + ' 天前'
794
+ try { return new Date(time).toLocaleDateString() } catch (error) { return '' }
795
+ }
796
+
797
+ /** One-line annotation shown at the end of the dwelled line. */
798
+ function blameAnnotationText(row, t) {
799
+ if (!row) return ''
800
+ if (row.uncommitted) return t ? t('blame.uncommitted') : '未提交'
801
+ const who = row.author || 'unknown'
802
+ const age = blameAge(row.authorTime, t)
803
+ const summary = row.summary || ''
804
+ return who + (age ? ', ' + age : '') + (summary ? ' · ' + summary : '')
805
+ }
806
+
807
+ /**
808
+ * Build the floating commit popover shown when the annotation is hovered.
809
+ * @param {HTMLElement} node editor root node used for positioning
810
+ * @param {object} t translator
811
+ * @returns {object} popover controller
812
+ */
813
+ function createBlamePopover(node, t) {
814
+ const dom = node.ownerDocument.createElement('div')
815
+ dom.className = 'drp-blame-popover'
816
+ dom.style.display = 'none'
817
+ const head = node.ownerDocument.createElement('div')
818
+ head.className = 'drp-blame-popover-head'
819
+ const author = node.ownerDocument.createElement('span')
820
+ author.className = 'drp-blame-popover-author'
821
+ const when = node.ownerDocument.createElement('span')
822
+ when.className = 'drp-blame-popover-when'
823
+ head.appendChild(author)
824
+ head.appendChild(when)
825
+ const subject = node.ownerDocument.createElement('div')
826
+ subject.className = 'drp-blame-popover-subject'
827
+ const body = node.ownerDocument.createElement('pre')
828
+ body.className = 'drp-blame-popover-body'
829
+ const foot = node.ownerDocument.createElement('div')
830
+ foot.className = 'drp-blame-popover-foot'
831
+ const sha = node.ownerDocument.createElement('code')
832
+ sha.className = 'drp-blame-popover-sha'
833
+ const copy = node.ownerDocument.createElement('button')
834
+ copy.type = 'button'
835
+ copy.className = 'drp-btn drp-blame-popover-copy'
836
+ copy.textContent = t ? t('blame.copySha') : '复制提交号'
837
+ foot.appendChild(sha)
838
+ foot.appendChild(copy)
839
+ dom.appendChild(head)
840
+ dom.appendChild(subject)
841
+ dom.appendChild(body)
842
+ dom.appendChild(foot)
843
+ node.appendChild(dom)
844
+ let current = null
845
+ return {
846
+ dom,
847
+ show(row, rect, commit) {
848
+ current = row
849
+ author.textContent = commit && commit.author ? commit.author : (row.author || 'unknown')
850
+ const age = blameAge(row.authorTime, t)
851
+ const absolute = row.authorTime ? new Date(row.authorTime).toLocaleString() : ''
852
+ when.textContent = age + (absolute ? ' (' + absolute + ')' : '')
853
+ subject.textContent = commit && commit.summary ? commit.summary : (row.summary || '')
854
+ body.textContent = commit && commit.body && commit.body !== subject.textContent ? commit.body : ''
855
+ body.style.display = body.textContent ? '' : 'none'
856
+ sha.textContent = row.short || String(row.sha || '').slice(0, 7)
857
+ dom.style.display = ''
858
+ const nodeRect = node.getBoundingClientRect()
859
+ const width = Math.min(420, Math.max(240, nodeRect.width - 24))
860
+ dom.style.width = width + 'px'
861
+ const left = Math.max(6, Math.min(rect.left - nodeRect.left, nodeRect.width - width - 6))
862
+ dom.style.left = left + 'px'
863
+ const height = dom.offsetHeight || 120
864
+ let top = rect.top - nodeRect.top - height - 6
865
+ if (top < 4) top = rect.bottom - nodeRect.top + 4
866
+ dom.style.top = top + 'px'
867
+ },
868
+ hide() {
869
+ current = null
870
+ dom.style.display = 'none'
871
+ },
872
+ isShowing() {
873
+ return dom.style.display !== 'none'
874
+ },
875
+ getRow() {
876
+ return current
877
+ },
878
+ contains(target) {
879
+ return dom.contains(target)
880
+ },
881
+ dispose() {
882
+ try { dom.remove() } catch (error) { /* optional */ }
883
+ },
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Show the line's blame inline after the cursor rests on that line, and a
889
+ * commit popover when the annotation is hovered.
890
+ * @param {object} editor active Monaco editor
891
+ * @param {object} monaco monaco namespace
892
+ * @param {{ getBlame: () => Array, sessionId: string, t?: Function }} options data access
893
+ */
894
+ function attachBlameDwell(editor, monaco, options, fallbackNode) {
895
+ const opts = options || {}
896
+ const getBlame = typeof opts.getBlame === 'function' ? opts.getBlame : () => []
897
+ const t = opts.t
898
+ const sessionId = opts.sessionId
899
+ const getPath = typeof opts.getPath === 'function' ? opts.getPath : () => opts.path || ''
900
+ const path = getPath()
901
+ const node = (typeof editor.getContainerDomNode === 'function' && editor.getContainerDomNode())
902
+ || (typeof editor.getDomNode === 'function' && editor.getDomNode())
903
+ || fallbackNode
904
+ if (!node || typeof node.addEventListener !== 'function') {
905
+ return { cancel() {}, dispose() {} }
906
+ }
907
+ const popover = createBlamePopover(node, t)
908
+ const annotation = node.ownerDocument.createElement('span')
909
+ annotation.className = 'drp-blame-inline'
910
+ annotation.style.display = 'none'
911
+ node.appendChild(annotation)
912
+ let timer = 0
913
+ let hoverTimer = 0
914
+ let popoverHideTimer = 0
915
+ let decorations = []
916
+ let currentRow = null
917
+ let interacted = false
918
+ let fetchedRows = null
919
+ const infoCache = new Map()
920
+
921
+ const clearInline = () => {
922
+ if (timer) { clearTimeout(timer); timer = 0 }
923
+ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = 0 }
924
+ if (popoverHideTimer) { clearTimeout(popoverHideTimer); popoverHideTimer = 0 }
925
+ popover.hide()
926
+ currentRow = null
927
+ annotation.style.display = 'none'
928
+ }
929
+
930
+ const showInline = (row) => {
931
+ if (!row) return
932
+ currentRow = row
933
+ const text = blameAnnotationText(row, t)
934
+ if (!text) return
935
+ annotation.textContent = text
936
+ annotation.title = text
937
+ annotation.style.display = ''
938
+ try {
939
+ const model = typeof editor.getModel === 'function' ? editor.getModel() : null
940
+ const maxColumn = model && typeof model.getLineMaxColumn === 'function' ? model.getLineMaxColumn(row.line) : 1
941
+ const atEnd = typeof editor.getScrolledVisiblePosition === 'function'
942
+ ? editor.getScrolledVisiblePosition({ lineNumber: row.line, column: maxColumn })
943
+ : null
944
+ const atStart = typeof editor.getScrolledVisiblePosition === 'function'
945
+ ? editor.getScrolledVisiblePosition({ lineNumber: row.line, column: 1 })
946
+ : null
947
+ const nodeWidth = Number(node.clientWidth) || 0
948
+ const width = Number(annotation.offsetWidth) || 0
949
+ const rightLimit = nodeWidth > 0 ? Math.max(12, nodeWidth - width - 10) : 0
950
+ let left = atEnd && Number.isFinite(atEnd.left) ? atEnd.left + 18 : rightLimit
951
+ if (rightLimit > 0) left = Math.min(left, rightLimit)
952
+ annotation.style.left = Math.max(12, left) + 'px'
953
+ annotation.style.top = Math.max(0, atStart && Number.isFinite(atStart.top) ? atStart.top : 0) + 'px'
954
+ annotation.style.height = ((atStart && atStart.height) || 20) + 'px'
955
+ } catch (error) { /* optional */ }
956
+ }
957
+
958
+ const rowsOf = () => {
959
+ const rows = getBlame()
960
+ if (Array.isArray(rows) && rows.length) return rows
961
+ return Array.isArray(fetchedRows) ? fetchedRows : []
962
+ }
963
+
964
+ const fetchRows = () => {
965
+ const wanted = getPath()
966
+ if (!sessionId || !wanted) return Promise.resolve([])
967
+ if (fetchedRows) return Promise.resolve(fetchedRows)
968
+ return apiGet('blame', { sessionId, path: wanted })
969
+ .then((value) => {
970
+ fetchedRows = value && value.available === true && Array.isArray(value.lines) ? value.lines : []
971
+ return fetchedRows
972
+ })
973
+ .catch((error) => {
974
+ fetchedRows = []
975
+ return []
976
+ })
977
+ }
978
+
979
+ const loadInfo = (row) => {
980
+ if (!row || row.uncommitted || !sessionId) return Promise.resolve(null)
981
+ const key = row.sha
982
+ if (infoCache.has(key)) return infoCache.get(key)
983
+ const pending = apiGet('commitInfo', { sessionId, revision: key })
984
+ .then((value) => (value && value.available === true ? value.commit : null))
985
+ .catch(() => null)
986
+ infoCache.set(key, pending)
987
+ return pending
988
+ }
989
+
990
+ const openPopover = (row, rect) => {
991
+ if (!row || row.uncommitted) return
992
+ void loadInfo(row).then((commit) => {
993
+ if (!currentRow || currentRow !== row) return
994
+ const span = node.querySelector('.drp-blame-inline')
995
+ popover.show(row, span ? span.getBoundingClientRect() : rect, commit)
996
+ })
997
+ }
998
+
999
+ const onPointerOver = (event) => {
1000
+ const target = event.target
1001
+ if (!target || typeof target.closest !== 'function') return
1002
+ if (popover.contains(target)) {
1003
+ if (popoverHideTimer) { clearTimeout(popoverHideTimer); popoverHideTimer = 0 }
1004
+ return
1005
+ }
1006
+ const span = target.closest('.drp-blame-inline')
1007
+ if (!span || !currentRow) {
1008
+ if (!popover.contains(target)) {
1009
+ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = 0 }
1010
+ schedulePopoverHide()
1011
+ }
1012
+ return
1013
+ }
1014
+ if (popoverHideTimer) { clearTimeout(popoverHideTimer); popoverHideTimer = 0 }
1015
+ if (hoverTimer) clearTimeout(hoverTimer)
1016
+ hoverTimer = setTimeout(() => {
1017
+ hoverTimer = 0
1018
+ openPopover(currentRow, span.getBoundingClientRect())
1019
+ }, BLAME_POPOVER_DELAY_MS)
1020
+ }
1021
+
1022
+ let selecting = false
1023
+ const schedulePopoverHide = () => {
1024
+ if (selecting) return
1025
+ if (popoverHideTimer) clearTimeout(popoverHideTimer)
1026
+ popoverHideTimer = setTimeout(() => {
1027
+ popoverHideTimer = 0
1028
+ popover.hide()
1029
+ }, 450)
1030
+ }
1031
+
1032
+ const onPointerLeave = (event) => {
1033
+ const related = event.relatedTarget
1034
+ if (related && (popover.contains(related) || related.closest?.('.drp-blame-inline'))) return
1035
+ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = 0 }
1036
+ schedulePopoverHide()
1037
+ }
1038
+
1039
+ const copyButton = popover.dom.querySelector('.drp-blame-popover-copy')
1040
+ const writeClipboard = (text) => {
1041
+ try {
1042
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
1043
+ return Promise.resolve(navigator.clipboard.writeText(text))
1044
+ }
1045
+ } catch (error) { /* fall through */ }
1046
+ return new Promise((resolve, reject) => {
1047
+ try {
1048
+ const area = node.ownerDocument.createElement('textarea')
1049
+ area.value = text
1050
+ area.setAttribute('readonly', '')
1051
+ area.style.position = 'fixed'
1052
+ area.style.top = '-1000px'
1053
+ area.style.opacity = '0'
1054
+ node.ownerDocument.body.appendChild(area)
1055
+ area.select()
1056
+ const ok = typeof node.ownerDocument.execCommand === 'function' && node.ownerDocument.execCommand('copy')
1057
+ area.remove()
1058
+ if (ok) resolve()
1059
+ else reject(new Error('copy-command-failed'))
1060
+ } catch (error) { reject(error) }
1061
+ })
1062
+ }
1063
+ const flashCopy = (text) => {
1064
+ if (!copyButton) return
1065
+ copyButton.textContent = text
1066
+ setTimeout(() => { if (copyButton) copyButton.textContent = t ? t('blame.copySha') : '复制提交号' }, 1200)
1067
+ }
1068
+ const onCopy = () => {
1069
+ const row = popover.getRow()
1070
+ if (!row || !row.sha) return
1071
+ writeClipboard(String(row.sha)).then(
1072
+ () => flashCopy(t ? t('blame.copied') : '已复制'),
1073
+ () => flashCopy(t ? t('blame.copyFailed') : '复制失败'),
1074
+ )
1075
+ }
1076
+ if (copyButton) copyButton.addEventListener('click', onCopy)
1077
+
1078
+ const markInteracted = () => { interacted = true }
1079
+ node.addEventListener('mousedown', markInteracted)
1080
+ node.addEventListener('keydown', markInteracted)
1081
+
1082
+ const positionSubscription = editor.onDidChangeCursorPosition((event) => {
1083
+ clearInline()
1084
+ const focused = typeof editor.hasTextFocus === 'function' ? editor.hasTextFocus() : true
1085
+ if (!interacted && !focused) return
1086
+ const line = event.position.lineNumber
1087
+ timer = setTimeout(() => {
1088
+ timer = 0
1089
+ const rowsNow = rowsOf()
1090
+ let row = rowsNow.find((entry) => entry && entry.line === line)
1091
+ if (row) {
1092
+ showInline(row)
1093
+ return
1094
+ }
1095
+ // The prefetched rows may still be in flight (or failed): fetch once here.
1096
+ void fetchRows().then((rows) => {
1097
+ row = rows.find((entry) => entry && entry.line === line)
1098
+ if (row) showInline(row)
1099
+ })
1100
+ }, BLAME_DWELL_MS)
1101
+ })
1102
+ const scrollSubscription = typeof editor.onDidScrollChange === 'function'
1103
+ ? editor.onDidScrollChange(() => clearInline())
1104
+ : null
1105
+ const blurSubscription = null
1106
+
1107
+ const onDocumentMouseDown = (event) => {
1108
+ const target = event.target
1109
+ if (!target || typeof target.closest !== 'function') return
1110
+ if (target.closest('.drp-blame-popover')) { selecting = true; return }
1111
+ if (target.closest('.drp-blame-inline')) return
1112
+ clearInline()
1113
+ }
1114
+ const onDocumentMouseUp = () => { selecting = false }
1115
+ const doc = node.ownerDocument
1116
+ if (doc && typeof doc.addEventListener === 'function') {
1117
+ doc.addEventListener('mousedown', onDocumentMouseDown, true)
1118
+ doc.addEventListener('mouseup', onDocumentMouseUp, true)
1119
+ }
1120
+
1121
+ node.addEventListener('mouseover', onPointerOver)
1122
+ node.addEventListener('mouseout', onPointerLeave)
1123
+
1124
+ return {
1125
+ cancel: clearInline,
1126
+ dispose() {
1127
+ clearInline()
1128
+ try { positionSubscription.dispose() } catch (error) { /* optional */ }
1129
+ try { scrollSubscription?.dispose?.() } catch (error) { /* optional */ }
1130
+ try { blurSubscription?.dispose?.() } catch (error) { /* optional */ }
1131
+ node.removeEventListener('mouseover', onPointerOver)
1132
+ node.removeEventListener('mouseout', onPointerLeave)
1133
+ try {
1134
+ doc?.removeEventListener('mousedown', onDocumentMouseDown, true)
1135
+ doc?.removeEventListener('mouseup', onDocumentMouseUp, true)
1136
+ } catch (error) { /* optional */ }
1137
+ node.removeEventListener('mousedown', markInteracted)
1138
+ node.removeEventListener('keydown', markInteracted)
1139
+ if (copyButton) copyButton.removeEventListener('click', onCopy)
1140
+ try { annotation.remove() } catch (error) { /* optional */ }
1141
+ popover.dispose()
1142
+ },
1143
+ }
1144
+ }
1145
+
1146
+ function MonacoHost(props) {
1147
+ const { monaco, file, sessionId, regions, blame, wrap, minimap, reveal, scheme, fontSize, documentEpoch, onCursor, onReady, onError, onRequestLine, onRequestOutline, onModelReady, onLsp, t } = props
1148
+ const hostRef = React.useRef(null)
1149
+ const editorRef = React.useRef(null)
1150
+ const rendererRef = React.useRef(null)
1151
+ const modelsRef = React.useRef(new Map())
1152
+ const viewStatesRef = React.useRef(new Map())
1153
+ const fontSizeRef = React.useRef(13)
1154
+ const callbacksRef = React.useRef({ onCursor, onReady, onError, onRequestLine, onRequestOutline, onModelReady, onLsp })
1155
+ const blameDwellRef = React.useRef(null)
1156
+ const blameRef = React.useRef(blame)
1157
+ blameRef.current = blame
1158
+ const fileRef = React.useRef(file)
1159
+ fileRef.current = file
1160
+ const tRef = React.useRef(t)
1161
+ tRef.current = t
1162
+ callbacksRef.current = { onCursor, onReady, onError, onRequestLine, onRequestOutline, onModelReady, onLsp }
1163
+
1164
+ /**
1165
+ * Create the editor once the host node and Monaco both exist.
1166
+ * @param {HTMLDivElement | null} node host node
1167
+ */
1168
+ const attach = React.useCallback((node) => {
1169
+ hostRef.current = node
1170
+ if (!node || editorRef.current || !monaco) return
1171
+ try {
1172
+ const editor = monaco.editor.create(node, {
1173
+ value: '',
1174
+ language: 'plaintext',
1175
+ theme: themeRuntime.scheme === 'light' ? MONACO_THEME.light : MONACO_THEME.dark,
1176
+ readOnly: true,
1177
+ domReadOnly: true,
1178
+ renderValidationDecorations: 'on',
1179
+ automaticLayout: true,
1180
+ scrollBeyondLastLine: false,
1181
+ wordWrap: 'off',
1182
+ minimap: { enabled: false },
1183
+ glyphMargin: false,
1184
+ lineNumbers: 'on',
1185
+ lineDecorationsWidth: 14,
1186
+ folding: true,
1187
+ bracketPairColorization: { enabled: true },
1188
+ matchBrackets: 'always',
1189
+ renderLineHighlight: 'none',
1190
+ renderWhitespace: 'selection',
1191
+ smoothScrolling: false,
1192
+ cursorBlinking: 'smooth',
1193
+ mouseWheelZoom: true,
1194
+ contextmenu: true,
1195
+ // Let hovers / suggest widgets escape the editor's clipped box: inside the
1196
+ // narrow right sidebar a clipped hover hides its first lines behind the
1197
+ // toolbar (user report). Fixed overflow widgets are positioned in the
1198
+ // viewport, so the tooltip stays readable.
1199
+ fixedOverflowWidgets: true,
1200
+ hover: { above: false, sticky: true, delay: 120 },
1201
+ fontSize: fontSizeRef.current,
1202
+ lineHeight: 20,
1203
+ fontFamily: 'var(--ds-font-family-code, var(--dsh-code-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace))',
1204
+ tabSize: 2,
1205
+ padding: { top: 8, bottom: 24 },
1206
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
1207
+ })
1208
+ rendererRef.current = createDiffRenderer(monaco, editor)
1209
+ editorRef.current = editor
1210
+ try {
1211
+ editor.onDidChangeCursorPosition((event) => {
1212
+ callbacksRef.current.onCursor?.({ line: event.position.lineNumber, column: event.position.column })
1213
+ })
1214
+ } catch (error) { /* optional */ }
1215
+ try { blameDwellRef.current = attachBlameDwell(editor, monaco, { getBlame: () => blameRef.current, sessionId, getPath: () => (fileRef.current && fileRef.current.path) || '', t: tRef.current }, hostRef.current) } catch (error) { /* optional */ }
1216
+ try {
1217
+ const key = monaco.KeyMod
1218
+ const code = monaco.KeyCode
1219
+ const bump = (delta) => {
1220
+ fontSizeRef.current = Math.min(28, Math.max(9, fontSizeRef.current + delta))
1221
+ editor.updateOptions({ fontSize: fontSizeRef.current })
1222
+ rendererRef.current?.reflow()
1223
+ }
1224
+ editor.addCommand(key.CtrlCmd | code.Equal, () => bump(1))
1225
+ editor.addCommand(key.CtrlCmd | code.Minus, () => bump(-1))
1226
+ editor.addCommand(key.CtrlCmd | code.Digit0, () => { fontSizeRef.current = 13; editor.updateOptions({ fontSize: 13 }) })
1227
+ // VS Code go-to-line parity inside the editor (the page-level command
1228
+ // covers the non-editor region with the same overlay).
1229
+ editor.addCommand(key.CtrlCmd | code.KeyG, () => {
1230
+ try {
1231
+ const action = editor.getAction('editor.action.gotoLine')
1232
+ if (action) action.run()
1233
+ else callbacksRef.current.onRequestLine?.()
1234
+ } catch (error) { /* ignore */ }
1235
+ })
1236
+ editor.addCommand(key.CtrlCmd | key.Shift | code.KeyO, () => callbacksRef.current.onRequestOutline?.())
1237
+ // Language-server navigation: F12 definition / Shift+F12 references.
1238
+ editor.addCommand(code.F12, () => {
1239
+ try {
1240
+ const position = lspPosition(editor)
1241
+ if (position) callbacksRef.current.onLsp?.('definition', position.lineNumber, position.column)
1242
+ } catch (error) { /* ignore */ }
1243
+ })
1244
+ editor.addCommand(key.Shift | code.F12, () => {
1245
+ try {
1246
+ const position = lspPosition(editor)
1247
+ if (position) callbacksRef.current.onLsp?.('references', position.lineNumber, position.column)
1248
+ } catch (error) { /* ignore */ }
1249
+ })
1250
+ editor.onMouseUp((event) => {
1251
+ if (!(event.event.ctrlKey || event.event.metaKey) || !event.event.leftButton || !event.target.position) return
1252
+ const position = event.target.position
1253
+ callbacksRef.current.onLsp?.('definition', position.lineNumber, position.column)
1254
+ })
1255
+ } catch (error) { /* keybindings are best-effort */ }
1256
+ callbacksRef.current.onReady?.({
1257
+ revealLine: (line) => {
1258
+ try {
1259
+ const target = Math.max(1, Number(line) || 1)
1260
+ editor.revealLineInCenterIfOutsideViewport?.(target)
1261
+ editor.revealLineInCenter(target)
1262
+ editor.setPosition({ lineNumber: target, column: 1 })
1263
+ } catch (error) { /* ignore */ }
1264
+ },
1265
+ focus: () => { try { editor.focus() } catch (error) { /* ignore */ } },
1266
+ layout: () => { try { editor.layout() } catch (error) { /* ignore */ } },
1267
+ getEditor: () => editor,
1268
+ setFontSize: (size) => { fontSizeRef.current = Math.min(28, Math.max(9, Number(size) || 13)); editor.updateOptions({ fontSize: fontSizeRef.current }) },
1269
+ })
1270
+ } catch (error) {
1271
+ log('error', 'monaco editor creation failed', error)
1272
+ callbacksRef.current.onError?.(error)
1273
+ }
1274
+ }, [monaco])
1275
+
1276
+ // Disposal.
1277
+ React.useEffect(() => () => {
1278
+ try { blameDwellRef.current?.dispose?.() } catch (error) { /* ignore */ }
1279
+ blameDwellRef.current = null
1280
+ try { rendererRef.current?.dispose() } catch (error) { /* ignore */ }
1281
+ rendererRef.current = null
1282
+ const editor = editorRef.current
1283
+ lspClearDocument(editor?.getModel?.())
1284
+ editorRef.current = null
1285
+ if (editor) {
1286
+ try { editor.dispose() } catch (error) { /* ignore */ }
1287
+ }
1288
+ for (const model of modelsRef.current.values()) {
1289
+ try { model.dispose() } catch (error) { /* ignore */ }
1290
+ }
1291
+ modelsRef.current.clear()
1292
+ }, [])
1293
+
1294
+ // Model switching with per-file view state. The host stays mounted while a
1295
+ // file loads, so `file` may be null or still loading: the previous model then
1296
+ // stays visible (the page paints a loading veil on top) and the scroll
1297
+ // position of each file survives a switch because the editor is never
1298
+ // destroyed and each model's Monaco view state is restored synchronously.
1299
+ React.useEffect(() => {
1300
+ const editor = editorRef.current
1301
+ if (!editor || !monaco || !file || file.kind !== 'text') return
1302
+ const path = file.path
1303
+ if (!path || typeof file.text !== 'string') return
1304
+ let model = modelsRef.current.get(path)
1305
+ try {
1306
+ if (!model) {
1307
+ const uri = monaco.Uri.parse('did:///' + encodeURIComponent(sessionId || 'workspace') + '/' + path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'))
1308
+ model = monaco.editor.getModel(uri) || monaco.editor.createModel(file.text, file.language || 'plaintext', uri)
1309
+ modelsRef.current.set(path, model)
1310
+ } else if (model.getValue() !== file.text) {
1311
+ // Content changed underneath (refresh): keep the user's scroll position.
1312
+ const saved = editor.getModel() === model ? editor.saveViewState() : viewStatesRef.current.get(model.uri.toString())
1313
+ model.setValue(file.text)
1314
+ if (saved) {
1315
+ try { editor.restoreViewState(saved) } catch (error) { /* ignore */ }
1316
+ }
1317
+ }
1318
+ if (model.getLanguageId() !== (file.language || 'plaintext')) monaco.editor.setModelLanguage(model, file.language || 'plaintext')
1319
+ if (editor.getModel() !== model) {
1320
+ try {
1321
+ const previous = editor.getModel()
1322
+ if (previous) viewStatesRef.current.set(previous.uri.toString(), editor.saveViewState())
1323
+ } catch (error) { /* ignore */ }
1324
+ editor.setModel(model)
1325
+ try {
1326
+ const saved = viewStatesRef.current.get(model.uri.toString())
1327
+ if (saved) editor.restoreViewState(saved)
1328
+ } catch (error) { /* ignore */ }
1329
+ try { editor.layout() } catch (error) { /* ignore */ }
1330
+ }
1331
+ callbacksRef.current.onModelReady?.({
1332
+ path,
1333
+ text: model.getValue(),
1334
+ language: model.getLanguageId(),
1335
+ monaco,
1336
+ model,
1337
+ })
1338
+ } catch (error) {
1339
+ log('error', 'monaco model switch failed', error)
1340
+ }
1341
+ }, [monaco, sessionId, file && file.path, file && file.text, file && file.language, file && file.kind, file && file.missing, documentEpoch])
1342
+
1343
+ // Inline diff decorations.
1344
+ React.useEffect(() => {
1345
+ const renderer = rendererRef.current
1346
+ if (!renderer) return
1347
+ renderer.render(regions || [])
1348
+ }, [regions])
1349
+
1350
+ // Editor options.
1351
+ React.useEffect(() => {
1352
+ const editor = editorRef.current
1353
+ if (!editor) return
1354
+ try {
1355
+ editor.updateOptions({ wordWrap: wrap ? 'on' : 'off', minimap: { enabled: minimap === true } })
1356
+ rendererRef.current?.reflow()
1357
+ } catch (error) { /* ignore */ }
1358
+ }, [wrap, minimap])
1359
+
1360
+ // Font size is prop-driven so menu commands always reach the live editor
1361
+ // (the imperative controls object is only a convenience path).
1362
+ React.useEffect(() => {
1363
+ const editor = editorRef.current
1364
+ if (!editor) return
1365
+ const next = Math.min(28, Math.max(9, Math.round(Number(fontSize) || 13)))
1366
+ fontSizeRef.current = next
1367
+ try {
1368
+ editor.updateOptions({ fontSize: next })
1369
+ rendererRef.current?.reflow()
1370
+ } catch (error) { /* ignore */ }
1371
+ }, [fontSize, file && file.path])
1372
+
1373
+ // Theme switching: re-apply the plugin theme and repaint zone decorations.
1374
+ React.useEffect(() => {
1375
+ const editor = editorRef.current
1376
+ if (!editor || !monaco) return
1377
+ try {
1378
+ applyMonacoTheme(monaco)
1379
+ rendererRef.current?.reflow()
1380
+ } catch (error) { /* ignore */ }
1381
+ }, [monaco, scheme])
1382
+
1383
+ // Reveal requests from hunk navigation / file open.
1384
+ React.useEffect(() => {
1385
+ const editor = editorRef.current
1386
+ if (!editor || !reveal || !reveal.line) return
1387
+ try {
1388
+ const target = Math.max(1, Number(reveal.line) || 1)
1389
+ editor.revealLineInCenter(target)
1390
+ const column = Math.max(1, Number(reveal.column) || 1)
1391
+ if (reveal.endLine && reveal.endColumn) {
1392
+ const range = { startLineNumber: target, startColumn: column, endLineNumber: reveal.endLine, endColumn: reveal.endColumn }
1393
+ editor.setSelection(range)
1394
+ editor.revealRangeInCenter(range)
1395
+ } else {
1396
+ editor.setPosition({ lineNumber: target, column })
1397
+ editor.revealPositionInCenter({ lineNumber: target, column })
1398
+ }
1399
+ editor.focus()
1400
+ } catch (error) { /* ignore */ }
1401
+ }, [reveal && reveal.line, reveal && reveal.nonce])
1402
+
1403
+ return h('div', { className: 'drp-monaco-host', ref: attach })
1404
+ }