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,842 @@
1
+ /**
2
+ * Minimal LSP bridge for the read-only review page.
3
+ *
4
+ * DSH 0.2.1 ships no language-server service, so this module speaks JSON-RPC
5
+ * over a real child process started through `ctx.subprocess`:
6
+ * - one lazily-started server per (session, language family);
7
+ * - `initialize` → `initialized` → `textDocument/didOpen` (full sync);
8
+ * - request/response correlation with a per-request timeout;
9
+ * - `publishDiagnostics` notifications cached per file;
10
+ * - idle shutdown after `IDLE_MS` and full teardown on plugin dispose.
11
+ *
12
+ * Nothing here installs a language server: detection probes the PATH and the
13
+ * user-level tool directories for known executables, and reports install hints
14
+ * to the browser when nothing is found.
15
+ */
16
+
17
+ import { existsSync, readdirSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { delimiter, join, win32, posix } from 'node:path'
20
+
21
+ /** JSON-RPC/LSP framing: headers plus a JSON body. */
22
+ const MAX_MESSAGE_BYTES = 8 * 1024 * 1024
23
+ /** How long one LSP request may take before the bridge answers a timeout. */
24
+ const REQUEST_TIMEOUT_MS = 15000
25
+ /** How long a session's server may stay idle before it is terminated. */
26
+ const IDLE_MS = 10 * 60 * 1000
27
+ /** Initialization handshake timeout. */
28
+ const INIT_TIMEOUT_MS = 20000
29
+
30
+ /**
31
+ * Supported language families and the executables that can serve them.
32
+ * `install` is shown verbatim to the user when nothing is found.
33
+ */
34
+ const SERVER_REGISTRY = [
35
+ {
36
+ id: 'typescript',
37
+ label: 'TypeScript / JavaScript',
38
+ languages: ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'],
39
+ commands: [
40
+ { command: 'typescript-language-server', args: ['--stdio'] },
41
+ ],
42
+ install: 'npm install -g typescript@5 typescript-language-server',
43
+ },
44
+ {
45
+ id: 'pyright',
46
+ label: 'Python (Pyright)',
47
+ languages: ['python'],
48
+ commands: [
49
+ { command: 'pyright-langserver', args: ['--stdio'] },
50
+ ],
51
+ install: 'npm install -g pyright',
52
+ },
53
+ {
54
+ id: 'pylsp',
55
+ label: 'Python (python-lsp-server)',
56
+ languages: ['python'],
57
+ commands: [
58
+ { command: 'pylsp', args: [] },
59
+ ],
60
+ install: 'pip install python-lsp-server',
61
+ },
62
+ {
63
+ id: 'json',
64
+ label: 'JSON',
65
+ languages: ['json', 'jsonc'],
66
+ commands: [
67
+ { command: 'vscode-json-language-server', args: ['--stdio'] },
68
+ ],
69
+ install: 'npm install -g vscode-langservers-extracted',
70
+ },
71
+ {
72
+ id: 'gopls',
73
+ label: 'Go',
74
+ languages: ['go'],
75
+ commands: [
76
+ { command: 'gopls', args: [] },
77
+ ],
78
+ install: 'go install golang.org/x/tools/gopls@latest',
79
+ },
80
+ {
81
+ id: 'rust-analyzer',
82
+ label: 'Rust',
83
+ languages: ['rust'],
84
+ commands: [
85
+ { command: 'rust-analyzer', args: [] },
86
+ ],
87
+ install: 'rustup component add rust-analyzer',
88
+ },
89
+ ]
90
+
91
+ /**
92
+ * npm global module roots that may hold a node-based language-server entry.
93
+ * PATH remains authoritative on POSIX (including nvm/fnm/asdf installations).
94
+ * Explicit prefixes and common system prefixes also allow direct Node launch
95
+ * when Windows resolves the npm binary to a .cmd or .ps1 shim.
96
+ * @param {{platform?:string, env?:object, home?:string}} [options] host facts
97
+ * @returns {string[]} candidate roots
98
+ */
99
+ export function npmGlobalRoots({ platform = process.platform, env = process.env, home = homedir() } = {}) {
100
+ const windows = platform === 'win32'
101
+ const api = windows ? win32 : posix
102
+ const roots = []
103
+ for (const prefix of [env.npm_config_prefix, env.NPM_CONFIG_PREFIX]) {
104
+ if (prefix) roots.push(api.join(prefix, ...(windows ? [] : ['lib']), 'node_modules'))
105
+ }
106
+ if (env.NODE_PATH) roots.push(...env.NODE_PATH.split(windows ? ';' : ':').filter(Boolean))
107
+ if (windows) {
108
+ if (env.APPDATA) roots.push(api.join(env.APPDATA, 'npm', 'node_modules'))
109
+ roots.push(api.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'))
110
+ if (env.NVM_SYMLINK) roots.push(api.join(env.NVM_SYMLINK, 'node_modules'))
111
+ } else {
112
+ if (env.NVM_BIN) roots.push(api.join(api.dirname(env.NVM_BIN), 'lib', 'node_modules'))
113
+ roots.push('/opt/homebrew/lib/node_modules', '/usr/local/lib/node_modules', '/usr/lib/node_modules')
114
+ roots.push(api.join(home, '.npm-global', 'lib', 'node_modules'))
115
+ }
116
+ return [...new Map(roots.map((root) => [windows ? api.normalize(root).toLowerCase() : api.normalize(root), root])).values()]
117
+ }
118
+
119
+ /**
120
+ * User-level tool directories the GUI host may not see. macOS apps launched by
121
+ * launchd inherit `/usr/bin:/bin:/usr/sbin:/sbin` instead of the login shell
122
+ * PATH, so `go install` and nvm binaries stay invisible without a fallback.
123
+ * @returns {string[]} extra bin directories
124
+ */
125
+ function extraBinDirs() {
126
+ const dirs = [
127
+ join(homedir(), 'go', 'bin'),
128
+ join(homedir(), '.local', 'bin'),
129
+ join(homedir(), '.npm-global', 'bin'),
130
+ '/usr/local/go/bin',
131
+ '/usr/local/bin',
132
+ '/opt/homebrew/bin',
133
+ ]
134
+ try {
135
+ const versions = join(homedir(), '.nvm', 'versions', 'node')
136
+ for (const version of readdirSync(versions)) dirs.push(join(versions, version, 'bin'))
137
+ } catch {
138
+ /* no nvm installation */
139
+ }
140
+ return dirs
141
+ }
142
+
143
+ /**
144
+ * Resolve one server executable through the host PATH first, then through the
145
+ * user-level bin directories above.
146
+ * @param {{resolveExecutable:(command:string)=>Promise<string|null>}} subprocess host service
147
+ * @param {string} command executable name or path
148
+ * @returns {Promise<string|null>} resolved executable path
149
+ */
150
+ async function resolveServerCommand(subprocess, command) {
151
+ try {
152
+ const resolved = await subprocess.resolveExecutable(command)
153
+ if (resolved) return resolved
154
+ } catch {
155
+ /* fall back to the known install directories */
156
+ }
157
+ if (command.includes('/')) return null
158
+ for (const dir of extraBinDirs()) {
159
+ try {
160
+ const candidate = join(dir, command)
161
+ if (existsSync(candidate)) return candidate
162
+ } catch {
163
+ /* ignore unreadable directories */
164
+ }
165
+ }
166
+ return null
167
+ }
168
+
169
+ /** PATH handed to spawned servers: host PATH plus the user-level bin directories. */
170
+ function serverPath() {
171
+ return [process.env.PATH, ...extraBinDirs()].filter(Boolean).join(delimiter)
172
+ }
173
+
174
+ /** Node entry points for servers whose npm bin is a .cmd/.ps1 shim on Windows. */
175
+ const NODE_ENTRIES = {
176
+ typescript: { bin: join('typescript-language-server', 'lib', 'cli.mjs'), args: ['--stdio'] },
177
+ pyright: { bin: join('pyright', 'langserver.index.js'), args: ['--stdio'] },
178
+ json: { bin: join('vscode-langservers-extracted', 'bin', 'vscode-json-language-server'), args: ['--stdio'] },
179
+ }
180
+
181
+ /**
182
+ * Candidate launch commands for one registry entry. Node + explicit CLI path
183
+ * comes first on Windows because `ctx.subprocess.spawn` cannot execute a .cmd
184
+ * shim directly. POSIX prefers the native PATH command and its shebang.
185
+ * @param {object} entry registry entry
186
+ * @returns {Array<{command:string, args:string[]}>}
187
+ */
188
+ function candidatesFor(entry) {
189
+ const commands = []
190
+ const nodeEntry = NODE_ENTRIES[entry.id]
191
+ if (nodeEntry) {
192
+ for (const root of npmGlobalRoots()) {
193
+ const cli = join(root, nodeEntry.bin)
194
+ try {
195
+ if (existsSync(cli)) commands.push({ command: 'node', args: [cli, ...nodeEntry.args] })
196
+ } catch {
197
+ /* ignore unreadable roots */
198
+ }
199
+ }
200
+ }
201
+ return process.platform === 'win32' ? [...commands, ...entry.commands] : [...entry.commands, ...commands]
202
+ }
203
+
204
+ /**
205
+ * Registry entries that can serve one Monaco language id.
206
+ * @param {string} languageId Monaco language id
207
+ * @returns {Array<object>}
208
+ */
209
+ export function serversFor(languageId) {
210
+ const id = String(languageId || '').toLowerCase()
211
+ return SERVER_REGISTRY.filter((entry) => entry.languages.includes(id))
212
+ }
213
+ /**
214
+ * All registry entries (for the status payload).
215
+ * @returns {Array<object>}
216
+ */
217
+ export function listServerRegistry() {
218
+ return SERVER_REGISTRY.map((entry) => ({
219
+ id: entry.id,
220
+ label: entry.label,
221
+ languages: entry.languages.slice(),
222
+ install: entry.install,
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Convert an absolute host path to a `file://` URI (Windows drive letters kept).
228
+ * @param {string} absolutePath absolute path
229
+ * @returns {string}
230
+ */
231
+ export function fileUri(absolutePath) {
232
+ const normalized = normalizeHostPath(absolutePath)
233
+ const encoded = normalized.split('/').map((part, index) =>
234
+ index === 0 && /^[a-zA-Z]:$/.test(part) ? part : encodeURIComponent(part)).join('/')
235
+ if (normalized.startsWith('//')) return 'file:' + encoded
236
+ return (/^[a-zA-Z]:\//.test(normalized) ? 'file:///' : 'file://') + encoded
237
+ }
238
+
239
+ function windowsPath(path) {
240
+ return /^[a-zA-Z]:[\\/]|^[\\/]{2}/.test(String(path))
241
+ }
242
+
243
+ function normalizeHostPath(path) {
244
+ const value = String(path || '')
245
+ const normalized = (windowsPath(value) ? win32 : posix).normalize(value).replace(/\\/g, '/')
246
+ return normalized.replace(/^[a-z]:/, (drive) => drive.toUpperCase())
247
+ }
248
+
249
+ function pathKey(path) {
250
+ const normalized = normalizeHostPath(path)
251
+ return windowsPath(normalized) ? normalized.toLowerCase() : normalized
252
+ }
253
+
254
+ /**
255
+ * Convert a `file://` URI back to a host path.
256
+ * @param {string} uri file URI or plain path
257
+ * @returns {string}
258
+ */
259
+ export function uriToPath(uri) {
260
+ const text = String(uri || '')
261
+ if (!/^file:\/\//i.test(text)) return normalizeHostPath(text)
262
+ try {
263
+ const url = new URL(text)
264
+ let path = decodeURIComponent(url.pathname)
265
+ if (url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
266
+ else if (/^\/[a-zA-Z]:\//.test(path)) path = path.slice(1)
267
+ return normalizeHostPath(path)
268
+ } catch {
269
+ return text
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Detect one available server for a language family.
275
+ * @param {object} subprocess Host subprocess service
276
+ * @param {string} languageId Monaco language id
277
+ * @returns {Promise<{entry:object, command:string, args:string[]} | null>}
278
+ */
279
+ export async function detectServer(subprocess, languageId) {
280
+ for (const entry of serversFor(languageId)) {
281
+ for (const candidate of candidatesFor(entry)) {
282
+ try {
283
+ const resolved = await resolveServerCommand(subprocess, candidate.command)
284
+ if (!resolved) continue
285
+ if (process.platform === 'win32' && /\.(?:cmd|bat|ps1)$/i.test(resolved)) {
286
+ // A custom global npm prefix may only be discoverable through PATH.
287
+ // Resolve its JS entry beside the shim instead of invoking a shell.
288
+ const nodeEntry = NODE_ENTRIES[entry.id]
289
+ const cli = nodeEntry && join(win32.dirname(resolved), 'node_modules', nodeEntry.bin)
290
+ if (!cli || !existsSync(cli)) continue
291
+ const node = await resolveServerCommand(subprocess, 'node')
292
+ if (node) return { entry, command: node, args: [cli, ...nodeEntry.args] }
293
+ continue
294
+ }
295
+ return { entry, command: resolved, args: candidate.args.slice() }
296
+ } catch {
297
+ /* keep probing */
298
+ }
299
+ }
300
+ }
301
+ return null
302
+ }
303
+
304
+ /** One JSON-RPC connection over a child process. */
305
+ class LspConnection {
306
+ /**
307
+ * @param {object} handle SubprocessHandle
308
+ * @param {(line:string) => void} log sink
309
+ */
310
+ constructor(handle, log) {
311
+ this.handle = handle
312
+ this.log = log
313
+ this.buffer = Buffer.alloc(0)
314
+ this.pending = new Map()
315
+ this.nextId = 1
316
+ this.closed = false
317
+ this.diagnostics = new Map()
318
+ this.stdout = handle.stdout
319
+ this.stdin = handle.stdin
320
+ this.stdin?.on?.('error', () => this.onClose())
321
+ if (this.stdout) {
322
+ this.stdout.on('data', (chunk) => this.onData(chunk))
323
+ this.stdout.on('error', () => this.onClose())
324
+ this.stdout.on('end', () => this.onClose())
325
+ }
326
+ this.handle.done.then(() => this.onClose()).catch(() => this.onClose())
327
+ }
328
+
329
+ /** Feed stdout bytes into the framing parser. */
330
+ onData(chunk) {
331
+ this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk])
332
+ for (;;) {
333
+ const headerEnd = this.buffer.indexOf('\r\n\r\n')
334
+ if (headerEnd < 0) {
335
+ if (this.buffer.length > 8192) this.dispose()
336
+ break
337
+ }
338
+ const header = this.buffer.subarray(0, headerEnd).toString('ascii')
339
+ const lengthMatch = /content-length:\s*(\d+)/i.exec(header)
340
+ if (!lengthMatch) {
341
+ this.buffer = this.buffer.subarray(headerEnd + 4)
342
+ continue
343
+ }
344
+ const length = Number(lengthMatch[1])
345
+ if (!Number.isSafeInteger(length) || length > MAX_MESSAGE_BYTES) {
346
+ this.buffer = Buffer.alloc(0)
347
+ this.dispose()
348
+ return
349
+ }
350
+ const start = headerEnd + 4
351
+ if (this.buffer.length < start + length) break
352
+ const body = this.buffer.subarray(start, start + length).toString('utf8')
353
+ this.buffer = this.buffer.subarray(start + length)
354
+ this.dispatch(body)
355
+ }
356
+ }
357
+
358
+ /** Route one decoded message. */
359
+ dispatch(body) {
360
+ let message
361
+ try {
362
+ message = JSON.parse(body)
363
+ } catch {
364
+ return
365
+ }
366
+ if (message && typeof message.id === 'number' && (message.result !== undefined || message.error !== undefined)) {
367
+ const pending = this.pending.get(message.id)
368
+ if (!pending) return
369
+ this.pending.delete(message.id)
370
+ clearTimeout(pending.timer)
371
+ if (message.error) pending.reject(new Error(String(message.error.message || 'lsp error')))
372
+ else pending.resolve(message.result)
373
+ return
374
+ }
375
+ if (message && typeof message.method === 'string') {
376
+ // Server → client requests: answer the few that keep the server alive.
377
+ if (typeof message.id === 'number' || typeof message.id === 'string') {
378
+ let result = null
379
+ if (message.method === 'workspace/configuration') result = (Array.isArray(message.params?.items) ? message.params.items : []).map(() => ({}))
380
+ if (message.method === 'window/workDoneProgress/create' || message.method === 'client/registerCapability') result = null
381
+ if (message.method === 'workspace/workspaceFolders') result = [{ uri: this.rootUri || fileUri(process.cwd()), name: 'workspace' }]
382
+ this.send({ jsonrpc: '2.0', id: message.id, result })
383
+ return
384
+ }
385
+ if (message.method === 'textDocument/publishDiagnostics') {
386
+ const params = message.params
387
+ if (params?.uri) this.diagnostics.set(pathKey(uriToPath(params.uri)), params)
388
+ }
389
+ return
390
+ }
391
+ }
392
+
393
+ /** Write one message to the server. */
394
+ send(message) {
395
+ if (this.closed || !this.stdin) throw new Error('lsp connection is closed')
396
+ const json = JSON.stringify(message)
397
+ const payload = 'Content-Length: ' + Buffer.byteLength(json, 'utf8') + '\r\n\r\n' + json
398
+ this.stdin.write(payload)
399
+ }
400
+
401
+ /**
402
+ * One request with timeout.
403
+ * @param {string} method LSP method
404
+ * @param {object} params parameters
405
+ * @returns {Promise<any>}
406
+ */
407
+ request(method, params) {
408
+ if (this.closed) return Promise.reject(new Error('lsp connection is closed'))
409
+ const id = this.nextId++
410
+ return new Promise((resolve, reject) => {
411
+ const timer = setTimeout(() => {
412
+ this.pending.delete(id)
413
+ reject(new Error('lsp request timed out: ' + method))
414
+ }, REQUEST_TIMEOUT_MS)
415
+ this.pending.set(id, { resolve, reject, timer })
416
+ try {
417
+ this.send({ jsonrpc: '2.0', id, method, params })
418
+ } catch (error) {
419
+ clearTimeout(timer)
420
+ this.pending.delete(id)
421
+ reject(error)
422
+ }
423
+ })
424
+ }
425
+
426
+ /** Send one notification. */
427
+ notify(method, params) {
428
+ try {
429
+ this.send({ jsonrpc: '2.0', method, params })
430
+ } catch {
431
+ /* a dead server is reported by the next request */
432
+ }
433
+ }
434
+
435
+ /** Mark closed and reject pending work. */
436
+ onClose() {
437
+ if (this.closed) return
438
+ this.closed = true
439
+ for (const [, pending] of this.pending) {
440
+ clearTimeout(pending.timer)
441
+ pending.reject(new Error('lsp server exited'))
442
+ }
443
+ this.pending.clear()
444
+ this.buffer = Buffer.alloc(0)
445
+ }
446
+
447
+ /** Graceful shutdown, then terminate. */
448
+ dispose() {
449
+ try { this.notify('exit', null) } catch { /* ignore */ }
450
+ try { this.handle.terminate() } catch { /* ignore */ }
451
+ this.onClose()
452
+ }
453
+ }
454
+
455
+ /** One language server bound to a session workspace. */
456
+ class LspSession {
457
+ /**
458
+ * @param {object} options session options
459
+ */
460
+ constructor(options) {
461
+ this.sessionId = options.sessionId
462
+ this.cwd = options.cwd
463
+ this.languageId = options.languageId
464
+ this.server = options.server
465
+ this.subprocess = options.subprocess
466
+ this.log = options.log
467
+ this.connection = null
468
+ this.ready = null
469
+ this.opened = new Map()
470
+ this.versions = new Map()
471
+ this.operations = Promise.resolve()
472
+ this.sync = { openClose: false, change: 0 }
473
+ this.lastUsed = Date.now()
474
+ this.disposed = false
475
+ }
476
+
477
+ /** Start the process and run the initialize handshake once. */
478
+ ensureReady() {
479
+ if (this.ready) return this.ready
480
+ const spec = {
481
+ argv: [this.server.command, ...this.server.args],
482
+ cwd: this.cwd,
483
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 16 * 1024 } },
484
+ graceMs: 2000,
485
+ env: { ...process.env, PATH: serverPath(), NO_COLOR: '1' },
486
+ }
487
+ this.ready = new Promise((resolve, reject) => {
488
+ let handle
489
+ try {
490
+ handle = this.subprocess.spawn(spec)
491
+ } catch (error) {
492
+ reject(error)
493
+ return
494
+ }
495
+ const connection = new LspConnection(handle, this.log)
496
+ connection.rootUri = fileUri(this.cwd)
497
+ this.connection = connection
498
+ const timer = setTimeout(() => reject(new Error('lsp initialize timed out')), INIT_TIMEOUT_MS)
499
+ connection.request('initialize', {
500
+ processId: null,
501
+ rootUri: fileUri(this.cwd),
502
+ workspaceFolders: [{ uri: fileUri(this.cwd), name: 'workspace' }],
503
+ // Syntax-only fallback can return an import alias while the TS project
504
+ // loads. A read-only navigator needs the semantic answer on the first F12.
505
+ initializationOptions: this.server.entry.id === 'typescript'
506
+ ? { tsserver: { useSyntaxServer: 'never' }, disableAutomaticTypingAcquisition: true }
507
+ : undefined,
508
+ capabilities: {
509
+ textDocument: {
510
+ synchronization: { dynamicRegistration: false },
511
+ definition: { linkSupport: false },
512
+ references: {},
513
+ hover: { contentFormat: ['plaintext', 'markdown'] },
514
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
515
+ publishDiagnostics: { relatedInformation: false },
516
+ },
517
+ workspace: { configuration: true, workspaceFolders: true },
518
+ },
519
+ clientInfo: { name: 'dsh-git-changes' },
520
+ }).then((result) => {
521
+ clearTimeout(timer)
522
+ const sync = result?.capabilities?.textDocumentSync
523
+ this.sync = typeof sync === 'number'
524
+ ? { openClose: sync !== 0, change: sync }
525
+ : { openClose: sync?.openClose === true, change: sync?.change || 0 }
526
+ connection.notify('initialized', {})
527
+ resolve({ capabilities: (result && result.capabilities) || {} })
528
+ }).catch((error) => {
529
+ clearTimeout(timer)
530
+ reject(error)
531
+ })
532
+ }).catch((error) => {
533
+ this.dispose()
534
+ throw error
535
+ })
536
+ return this.ready
537
+ }
538
+
539
+ /**
540
+ * Keep only the requested document overlaid. Other files must be read from
541
+ * disk so an Agent's edits cannot be hidden behind a formerly viewed model.
542
+ * The whole operation is serialized: closing a document while its hover or
543
+ * navigation request is in flight can change that request's meaning.
544
+ */
545
+ withDocument(request, operation) {
546
+ const pending = this.operations.then(async () => {
547
+ if (this.disposed) throw new Error('lsp session is disposed')
548
+ const uri = await this.syncDocument(request.path, request.text || '', request.languageId)
549
+ return operation(uri)
550
+ })
551
+ this.operations = pending.catch(() => {})
552
+ return pending
553
+ }
554
+
555
+ /** Open or refresh the requested document using the negotiated sync mode. */
556
+ async syncDocument(path, text, languageId) {
557
+ await this.ensureReady()
558
+ if (this.disposed) throw new Error('lsp session is disposed')
559
+ this.lastUsed = Date.now()
560
+ const uri = fileUri(resolveWorkspacePath(this.cwd, path))
561
+ const key = pathKey(uriToPath(uri))
562
+ for (const [other, document] of this.opened) {
563
+ if (other === key) continue
564
+ if (this.sync.openClose) this.connection.notify('textDocument/didClose', { textDocument: { uri: document.uri } })
565
+ this.connection.diagnostics.delete(other)
566
+ this.opened.delete(other)
567
+ }
568
+ const existing = this.opened.get(key)
569
+ if (existing) {
570
+ if (existing.text === text) return existing.uri
571
+ this.connection.diagnostics.delete(key)
572
+ const version = existing.version + 1
573
+ if (this.sync.change === 1 || this.sync.change === 2) {
574
+ const lines = existing.text.split(/\r\n|\r|\n/)
575
+ const range = { start: { line: 0, character: 0 }, end: { line: lines.length - 1, character: lines.at(-1).length } }
576
+ this.connection.notify('textDocument/didChange', {
577
+ textDocument: { uri, version },
578
+ contentChanges: [this.sync.change === 2 ? { range, text } : { text }],
579
+ })
580
+ } else if (this.sync.openClose) {
581
+ // A server may accept open/close without incremental changes.
582
+ this.connection.notify('textDocument/didClose', { textDocument: { uri } })
583
+ this.connection.notify('textDocument/didOpen', {
584
+ textDocument: { uri, languageId: languageId || 'plaintext', version, text },
585
+ })
586
+ }
587
+ existing.version = version
588
+ existing.text = text
589
+ this.versions.set(key, version)
590
+ return existing.uri
591
+ }
592
+ const version = (this.versions.get(key) || 0) + 1
593
+ this.connection.diagnostics.delete(key)
594
+ if (this.sync.openClose) this.connection.notify('textDocument/didOpen', {
595
+ textDocument: { uri, languageId: languageId || 'plaintext', version, text },
596
+ })
597
+ this.versions.set(key, version)
598
+ this.opened.set(key, { uri, version, text })
599
+ return uri
600
+ }
601
+
602
+ /** Position parameters for one 1-based line/column. */
603
+ positionParams(path, line, column) {
604
+ const opened = this.opened.get(pathKey(resolveWorkspacePath(this.cwd, path)))
605
+ const uri = opened ? opened.uri : fileUri(resolveWorkspacePath(this.cwd, path))
606
+ return {
607
+ textDocument: { uri },
608
+ position: { line: Math.max(0, (Number(line) || 1) - 1), character: Math.max(0, (Number(column) || 1) - 1) },
609
+ }
610
+ }
611
+
612
+ /** Serialize a Location/LocationLink for the browser. */
613
+ locationOf(value) {
614
+ if (!value) return null
615
+ const target = value.targetUri ? value.targetUri : value.uri
616
+ const range = value.targetSelectionRange || value.targetRange || value.range
617
+ if (!target || !range || !/^file:\/\//i.test(target)) return null
618
+ return {
619
+ path: relativeToCwd(this.cwd, uriToPath(target)),
620
+ line: range.start.line + 1,
621
+ column: range.start.character + 1,
622
+ endLine: range.end.line + 1,
623
+ endColumn: range.end.character + 1,
624
+ }
625
+ }
626
+
627
+ /** Read the latest complete notification for a file (an empty list clears errors). */
628
+ takeDiagnostics(path) {
629
+ const out = []
630
+ const opened = this.opened.get(pathKey(resolveWorkspacePath(this.cwd, path)))
631
+ const params = this.connection?.diagnostics.get(pathKey(resolveWorkspacePath(this.cwd, path)))
632
+ if (!params || (typeof params.version === 'number' && params.version < opened?.version)) return { diagnostics: out, pending: true }
633
+ for (const diagnostic of Array.isArray(params.diagnostics) ? params.diagnostics : []) {
634
+ out.push({
635
+ path: relativeToCwd(this.cwd, uriToPath(params.uri)),
636
+ line: diagnostic.range.start.line + 1,
637
+ column: diagnostic.range.start.character + 1,
638
+ endLine: diagnostic.range.end.line + 1,
639
+ endColumn: diagnostic.range.end.character + 1,
640
+ severity: diagnostic.severity || 1,
641
+ message: String(diagnostic.message || ''),
642
+ source: diagnostic.source ? String(diagnostic.source) : '',
643
+ })
644
+ }
645
+ return { diagnostics: out, pending: false }
646
+ }
647
+
648
+ /** Terminate the server. */
649
+ dispose() {
650
+ if (this.disposed) return
651
+ this.disposed = true
652
+ if (this.connection) this.connection.dispose()
653
+ this.connection = null
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Join a cwd-relative posix path to a host path.
659
+ * @param {string} cwd workspace root
660
+ * @param {string} rel relative path
661
+ * @returns {string}
662
+ */
663
+ function resolveWorkspacePath(cwd, rel) {
664
+ const api = windowsPath(cwd) ? win32 : posix
665
+ return api.resolve(String(cwd), String(rel))
666
+ }
667
+
668
+ /**
669
+ * Host-path → cwd-relative posix path when inside the workspace, else the path.
670
+ * @param {string} cwd workspace root
671
+ * @param {string} absolute absolute path
672
+ * @returns {string}
673
+ */
674
+ export function relativeToCwd(cwd, absolute) {
675
+ const root = normalizeHostPath(cwd)
676
+ const target = normalizeHostPath(absolute)
677
+ const api = windowsPath(root) ? win32 : posix
678
+ if (windowsPath(root) !== windowsPath(target) || !api.isAbsolute(target)) return target
679
+ const relative = api.relative(root, target).replace(/\\/g, '/')
680
+ if (relative === '') return ''
681
+ if (relative !== '..' && !relative.startsWith('../') && !api.isAbsolute(relative)) return relative
682
+ return target
683
+ }
684
+
685
+ /** Bridge owner: registry detection cache plus per-session servers. */
686
+ export class LspBridge {
687
+ /**
688
+ * @param {object} ctx Host context
689
+ * @param {object} logger logger
690
+ */
691
+ constructor(ctx, logger) {
692
+ this.ctx = ctx
693
+ this.logger = logger
694
+ this.detection = new Map()
695
+ this.sessions = new Map()
696
+ this.closed = false
697
+ }
698
+
699
+ /**
700
+ * Detect (and cache for the plugin lifetime) a server for a language.
701
+ * @param {string} languageId Monaco language id
702
+ * @returns {Promise<object | null>}
703
+ */
704
+ detect(languageId) {
705
+ if (!this.detection.has(languageId)) {
706
+ const probe = detectServer(this.ctx.subprocess, languageId).catch(() => null)
707
+ this.detection.set(languageId, probe)
708
+ }
709
+ return this.detection.get(languageId)
710
+ }
711
+
712
+ /** Status payload for the browser (which languages can work right now). */
713
+ async status(languageId, refresh = false) {
714
+ const language = String(languageId || '')
715
+ if (refresh) this.detection.delete(language)
716
+ const registryEntries = serversFor(language)
717
+ if (registryEntries.length === 0) {
718
+ return { language, available: false, server: null, servers: [], reason: 'unsupported-language', install: null }
719
+ }
720
+ const entry = (await this.detect(language)) || null
721
+ const servers = registryEntries.map((server) => server.id)
722
+ return {
723
+ language,
724
+ available: entry !== null,
725
+ server: entry ? entry.entry.id : null,
726
+ servers,
727
+ reason: entry ? null : 'missing-server',
728
+ install: entry ? null : registryEntries[0].install || null,
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Run one language-server operation (starting the server on demand).
734
+ * @param {{sessionId:string, cwd:string, languageId:string, action:string, path:string, text?:string, line?:number, column?:number, query?:string}} request request
735
+ * @returns {Promise<object>}
736
+ */
737
+ async operate(request) {
738
+ if (this.closed) return { available: false, reason: 'disposed' }
739
+ const registryEntries = serversFor(request.languageId)
740
+ if (registryEntries.length === 0) {
741
+ return { available: false, reason: 'unsupported-language', language: request.languageId, install: null }
742
+ }
743
+ const server = await this.detect(request.languageId)
744
+ if (this.closed) return { available: false, reason: 'disposed' }
745
+ if (!server) {
746
+ return {
747
+ available: false,
748
+ reason: 'missing-server',
749
+ language: request.languageId,
750
+ install: registryEntries[0].install || null,
751
+ }
752
+ }
753
+ const key = request.sessionId + '\n' + server.entry.id
754
+ let session = this.sessions.get(key)
755
+ if (session && (pathKey(session.cwd) !== pathKey(request.cwd) || session.connection?.closed)) session.dispose()
756
+ if (!session || session.disposed) {
757
+ session = new LspSession({
758
+ sessionId: request.sessionId,
759
+ cwd: request.cwd,
760
+ languageId: request.languageId,
761
+ server,
762
+ subprocess: this.ctx.subprocess,
763
+ log: (level, ...args) => {
764
+ try { this.logger?.[level === 'warn' ? 'warn' : 'info']?.('[git-changes lsp]', ...args) } catch { /* ignore */ }
765
+ },
766
+ })
767
+ this.sessions.set(key, session)
768
+ }
769
+ session.lastUsed = Date.now()
770
+ try {
771
+ return await session.withDocument(request, async (uri) => {
772
+ const params = session.positionParams(request.path, request.line, request.column)
773
+ if (request.action === 'definition') {
774
+ const result = await session.connection.request('textDocument/definition', params)
775
+ const values = Array.isArray(result) ? result : result ? [result] : []
776
+ return { available: true, locations: values.map((value) => session.locationOf(value)).filter(Boolean) }
777
+ }
778
+ if (request.action === 'references') {
779
+ const result = await session.connection.request('textDocument/references', { ...params, context: { includeDeclaration: true } })
780
+ const values = Array.isArray(result) ? result : []
781
+ return { available: true, locations: values.map((value) => session.locationOf(value)).filter(Boolean) }
782
+ }
783
+ if (request.action === 'hover') {
784
+ const result = await session.connection.request('textDocument/hover', params)
785
+ const contents = result && result.contents
786
+ const text = Array.isArray(contents)
787
+ ? contents.map((part) => (typeof part === 'string' ? part : part && part.value ? part.value : '')).join('\n')
788
+ : typeof contents === 'string' ? contents : contents && contents.value ? contents.value : ''
789
+ return { available: true, contents: String(text || '') }
790
+ }
791
+ if (request.action === 'symbols') {
792
+ const result = await session.connection.request('textDocument/documentSymbol', { textDocument: { uri } })
793
+ const flat = []
794
+ const walk = (nodes, depth) => {
795
+ for (const node of Array.isArray(nodes) ? nodes : []) {
796
+ const range = node.selectionRange || node.range || node.location?.range
797
+ flat.push({
798
+ name: String(node.name || ''),
799
+ detail: node.detail ? String(node.detail) : '',
800
+ kind: typeof node.kind === 'number' ? node.kind : 0,
801
+ depth,
802
+ line: range ? range.start.line + 1 : 1,
803
+ column: range ? range.start.character + 1 : 1,
804
+ })
805
+ if (Array.isArray(node.children)) walk(node.children, depth + 1)
806
+ }
807
+ }
808
+ walk(result, 0)
809
+ return { available: true, symbols: flat.slice(0, 500) }
810
+ }
811
+ if (request.action === 'diagnostics') {
812
+ return { available: true, ...session.takeDiagnostics(request.path) }
813
+ }
814
+ return { available: true, unsupported: true }
815
+ })
816
+ } catch (error) {
817
+ try { session.dispose() } catch { /* ignore */ }
818
+ if (this.sessions.get(key) === session) this.sessions.delete(key)
819
+ return { available: false, reason: 'server-error', message: String(error && error.message ? error.message : error) }
820
+ }
821
+ }
822
+
823
+ /** Terminate idle servers and all servers. */
824
+ reap() {
825
+ const now = Date.now()
826
+ for (const [key, session] of this.sessions) {
827
+ if (!session.disposed && now - session.lastUsed > IDLE_MS) {
828
+ session.dispose()
829
+ this.sessions.delete(key)
830
+ }
831
+ }
832
+ }
833
+
834
+ /** Dispose everything. */
835
+ dispose() {
836
+ this.closed = true
837
+ for (const [, session] of this.sessions) {
838
+ try { session.dispose() } catch { /* ignore */ }
839
+ }
840
+ this.sessions.clear()
841
+ }
842
+ }