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.
- package/LICENSE +21 -0
- package/README.en.md +105 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/vendor/monaco/LICENSE +21 -0
- package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
- package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
- package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
- package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
- package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
- package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
- package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
- package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
- package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
- package/assets/vendor/monaco/vs/loader.js +10 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/git-changes-diff.png +0 -0
- package/icon.svg +10 -0
- package/lib/client.js +9418 -0
- package/lib/host/assets.js +161 -0
- package/lib/host/commit-message.js +158 -0
- package/lib/host/files-index.js +206 -0
- package/lib/host/git-write.js +194 -0
- package/lib/host/git.js +1147 -0
- package/lib/host/lsp.js +842 -0
- package/lib/host/routes.js +976 -0
- package/lib/host/session.js +84 -0
- package/lib/host/util.js +107 -0
- package/lib/index.js +33 -0
- package/licenses/dsh-vscode-mode-LICENSE +21 -0
- package/licenses/vscode-theme-seti-LICENSE +21 -0
- package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -3
- package/src/client/00-const.js +79 -0
- package/src/client/05-utils.js +277 -0
- package/src/client/10-i18n.js +548 -0
- package/src/client/12-vscode-icons.js +460 -0
- package/src/client/15-theme.js +509 -0
- package/src/client/20-monaco.js +1404 -0
- package/src/client/22-lsp.js +592 -0
- package/src/client/25-tree.js +563 -0
- package/src/client/28-changes.js +1163 -0
- package/src/client/30-page.js +2259 -0
- package/src/client/35-pill.js +185 -0
- package/src/client/40-commands.js +248 -0
- package/src/client/45-css.js +357 -0
- package/src/client/47-pickers.js +457 -0
- package/src/client/50-apply.js +207 -0
- package/src/client/99-footer.js +62 -0
package/lib/host/lsp.js
ADDED
|
@@ -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
|
+
}
|