dsh-vscode-mode 0.1.30 → 0.1.35

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/lib/client.js +1439 -46
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +3519 -222
  6. package/lib/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/capture.ts +3 -0
  9. package/src/client/events.ts +11 -0
  10. package/src/client/index.ts +20 -2
  11. package/src/client/monaco/lsp/index.ts +30 -0
  12. package/src/client/monaco/lsp/lspClient.ts +192 -0
  13. package/src/client/monaco/lsp/providers.ts +229 -0
  14. package/src/client/outline/OutlinePanel.tsx +1 -1
  15. package/src/client/outline/lspSource.ts +49 -0
  16. package/src/client/outline/parse.ts +31 -1
  17. package/src/client/outline/types.ts +2 -0
  18. package/src/client/paths.ts +36 -0
  19. package/src/client/sidebar/panels/FileExplorer.ts +95 -30
  20. package/src/client/sidebar/panels/SearchPanel.ts +11 -2
  21. package/src/client/state/explorerCache.ts +3 -1
  22. package/src/client/state/explorerEntriesCache.ts +172 -0
  23. package/src/client/state/viewStateCache.ts +3 -1
  24. package/src/client/styles/editor.css +16 -0
  25. package/src/client/styles/mcp.css +63 -0
  26. package/src/client/ui/EditorView.ts +61 -6
  27. package/src/client/ui/LspSettings.ts +268 -0
  28. package/src/client/ui/McpSettings.ts +5 -1
  29. package/src/client/ui/PerfSettings.ts +242 -0
  30. package/src/client/ui/PerfSizeBadge.ts +54 -0
  31. package/src/compat.ts +3 -2
  32. package/src/fileOpenSettings.ts +1 -0
  33. package/src/index.ts +25 -2
  34. package/src/lsp/client.ts +205 -0
  35. package/src/lsp/config.ts +107 -0
  36. package/src/lsp/extmgr.ts +208 -0
  37. package/src/lsp/jsonrpc.ts +86 -0
  38. package/src/lsp/manager.ts +133 -0
  39. package/src/lsp/providers.ts +235 -0
  40. package/src/lsp/rpc.ts +334 -0
  41. package/src/lsp/server.ts +346 -0
  42. package/src/lsp/settings.ts +44 -0
  43. package/src/lsp/transport.ts +143 -0
  44. package/src/lsp/uri.ts +62 -0
  45. package/src/lsp/zip.ts +82 -0
  46. package/src/paths.ts +284 -0
  47. package/src/perf.ts +340 -0
  48. package/src/perfPatch.ts +76 -0
  49. package/src/routes.ts +7 -12
  50. package/src/rpc.ts +184 -36
  51. package/src/search/content.ts +14 -7
  52. package/src/search/ripgrep.ts +54 -1
  53. package/src/search/types.ts +4 -1
  54. package/src/shared/lsp.ts +138 -0
  55. package/src/shared/rpc.ts +103 -2
  56. package/src/store.ts +2 -2
  57. package/src/treeIndex.ts +397 -0
  58. package/src/treeRead.ts +87 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * dsh-vscode-mode host — 单语言服务器会话(绑定 工作区根 + 语言)。
3
+ * 负责:启动传输 + LSP 客户端握手、文档同步(didOpen/didChange/didSave/didClose)、
4
+ * 请求封装(definition/references/documentSymbol/workspaceSymbol/hover)、状态上报。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { spawnServer, type Transport } from './transport.js'
8
+ import { createLspClient, type LspClient } from './client.js'
9
+ import { pathToFileUri } from './uri.js'
10
+ import type { LspProviderSpec } from './providers.js'
11
+ import type {
12
+ LspLocation,
13
+ LspServerCapabilities,
14
+ LspServerPhase,
15
+ LspServerStatus,
16
+ LspSymbol,
17
+ } from '../shared/lsp.js'
18
+
19
+ export interface LspServer {
20
+ readonly languageId: string
21
+ readonly root: string
22
+ readonly phase: LspServerPhase
23
+ readonly capabilities: LspServerCapabilities
24
+ onStateChange: ((status: LspServerStatus) => void) | null
25
+ status(): LspServerStatus
26
+ start(): Promise<boolean>
27
+ sync(path: string, text: string, version: number): void
28
+ close(path: string): void
29
+ definition(path: string, line: number, character: number): Promise<LspLocation[]>
30
+ references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]>
31
+ documentSymbol(path: string): Promise<LspSymbol[]>
32
+ workspaceSymbol(query: string): Promise<LspSymbol[]>
33
+ hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null>
34
+ dispose(): Promise<void>
35
+ }
36
+
37
+ /** 文档注册表:path(工作区相对) → { version, text, uri }。 */
38
+ interface OpenDoc {
39
+ path: string
40
+ uri: string
41
+ version: number
42
+ text: string
43
+ }
44
+
45
+ const RESTARTABLE = true
46
+
47
+ /**
48
+ * 创建语言服务器会话。
49
+ * @author ddj 2026年08月27号
50
+ * @param spec provider 规格(argv/cwd)
51
+ * @param root 工作区根(绝对路径)
52
+ * @param languageId 语言 id
53
+ * @param logger 诊断日志
54
+ * @returns 会话句柄
55
+ */
56
+ export function createLspServer(spec: LspProviderSpec, root: string, languageId: string, logger?: (line: string) => void): LspServer {
57
+ const rootUri = pathToFileUri(root)
58
+ const docs = new Map<string, OpenDoc>()
59
+ let phase: LspServerPhase = 'idle'
60
+ let transport: Transport | null = null
61
+ let client: LspClient | null = null
62
+ let capabilities: LspServerCapabilities = { definition: false, references: false, documentSymbol: false, workspaceSymbol: false, hover: false }
63
+ let disposed = false
64
+ let readyWait: Promise<boolean> = Promise.resolve(false)
65
+ let resolveReady: ((ready: boolean) => void) | null = null
66
+ let lastProgress: { value?: number; message?: string } = {}
67
+ let lastStatus: LspServerStatus = { languageId, source: spec.kind, phase: 'idle', reason: spec.ready ? undefined : spec.reason, root }
68
+
69
+ const log = (line: string): void => logger?.('[' + languageId + '@' + root + '] ' + line)
70
+
71
+ const setPhase = (next: LspServerPhase, reason?: string): void => {
72
+ phase = next
73
+ lastStatus = { languageId, source: spec.kind, phase: next, reason, root, ...progressFields() }
74
+ server.onStateChange?.(lastStatus)
75
+ }
76
+
77
+ /** 返回当前进度字段,避免空进度污染状态载荷。 */
78
+ const progressFields = (): Pick<LspServerStatus, 'progress' | 'progressMessage'> => ({
79
+ progress: lastProgress.value,
80
+ progressMessage: lastProgress.message,
81
+ })
82
+
83
+ /** 处理 LSP $/progress 通知并映射为编辑器状态。 */
84
+ const applyProgress = (params: unknown): void => {
85
+ const value = params as { value?: unknown }
86
+ const report = value?.value as { kind?: unknown; percentage?: unknown; message?: unknown } | undefined
87
+ if (report?.kind === 'begin') {
88
+ lastProgress = { value: 0, message: typeof report.message === 'string' ? report.message : '正在解析工作区' }
89
+ if (phase === 'ready') phase = 'indexing'
90
+ } else if (report?.kind === 'end') {
91
+ lastProgress = { value: 100, message: typeof report.message === 'string' ? report.message : '解析完成' }
92
+ if (phase === 'indexing') phase = 'ready'
93
+ } else if (typeof report?.percentage === 'number') {
94
+ lastProgress = { value: Math.max(0, Math.min(100, report.percentage)), message: typeof report.message === 'string' ? report.message : lastProgress.message }
95
+ if (phase === 'ready') phase = 'indexing'
96
+ } else if (typeof report?.message === 'string') {
97
+ lastProgress = { ...lastProgress, message: report.message }
98
+ }
99
+ lastStatus = { ...lastStatus, phase, ...progressFields() }
100
+ server.onStateChange?.(lastStatus)
101
+ }
102
+
103
+ const ensureClient = (): LspClient => {
104
+ if (!client) throw new Error('LSP 服务器未启动')
105
+ return client
106
+ }
107
+
108
+ const server: LspServer = {
109
+ languageId,
110
+ root,
111
+ get phase() {
112
+ return phase
113
+ },
114
+ get capabilities() {
115
+ return capabilities
116
+ },
117
+ status(): LspServerStatus {
118
+ return lastStatus
119
+ },
120
+ onStateChange: null,
121
+
122
+ async start(): Promise<boolean> {
123
+ if (disposed) return false
124
+ if (phase === 'starting') return readyWait
125
+ if (phase === 'ready' || phase === 'indexing') return true
126
+ readyWait = new Promise((resolve) => { resolveReady = resolve })
127
+ lastProgress = {}
128
+ setPhase('starting')
129
+ try {
130
+ transport = spawnServer(
131
+ { argv: spec.argv, cwd: spec.cwd ?? root },
132
+ (message) => { /* 由 client 接管 */ },
133
+ (line) => log('stderr: ' + line),
134
+ )
135
+ client = createLspClient(transport, {
136
+ onReady: (caps) => {
137
+ capabilities = caps
138
+ // 重放已打开文档(崩溃重启后恢复服务器侧状态)
139
+ for (const doc of docs.values()) {
140
+ client?.notify('textDocument/didOpen', {
141
+ textDocument: { uri: doc.uri, languageId, version: doc.version, text: doc.text },
142
+ })
143
+ }
144
+ setPhase('ready')
145
+ resolveReady?.(true)
146
+ resolveReady = null
147
+ log('ready: definition=' + caps.definition + ' references=' + caps.references + ' symbols=' + caps.documentSymbol)
148
+ },
149
+ onProgress: (params) => applyProgress(params),
150
+ onExit: (code, signal) => {
151
+ if (disposed) return
152
+ resolveReady?.(false)
153
+ resolveReady = null
154
+ log('server exited (' + String(code) + '/' + String(signal) + ')')
155
+ client = null
156
+ transport = null
157
+ if (RESTARTABLE) setPhase('idle', '服务器已退出(' + String(code) + '/' + String(signal) + '),等待重启')
158
+ else setPhase('stopped', '服务器已退出')
159
+ },
160
+ onLog: (line) => log('lsp: ' + line),
161
+ })
162
+ // 让 client 接管 stdout 消息
163
+ const caps = await client.initialize(rootUri, [rootUri])
164
+ capabilities = caps
165
+ return true
166
+ } catch (error) {
167
+ log('start failed: ' + String(error))
168
+ setPhase('unavailable', '启动失败:' + String(error))
169
+ resolveReady?.(false)
170
+ resolveReady = null
171
+ try { await server.dispose() } catch { /* 忽略 */ }
172
+ return false
173
+ }
174
+ },
175
+
176
+ sync(path: string, text: string, version: number): void {
177
+ const existing = docs.get(path)
178
+ const uri = existing?.uri ?? pathToFileUri(root + '/' + path.split('/').join('/'))
179
+ if (existing) {
180
+ existing.version = version
181
+ existing.text = text
182
+ if (phase === 'ready' || phase === 'indexing') {
183
+ client?.notify('textDocument/didChange', {
184
+ textDocument: { uri, version },
185
+ contentChanges: [{ text }],
186
+ })
187
+ }
188
+ return
189
+ }
190
+ docs.set(path, { path, uri, version, text })
191
+ if (phase === 'ready' || phase === 'indexing') {
192
+ client?.notify('textDocument/didOpen', {
193
+ textDocument: { uri, languageId, version, text },
194
+ })
195
+ }
196
+ },
197
+
198
+ close(path: string): void {
199
+ const doc = docs.get(path)
200
+ if (!doc) return
201
+ docs.delete(path)
202
+ if (phase === 'ready' || phase === 'indexing') {
203
+ client?.notify('textDocument/didClose', { textDocument: { uri: doc.uri } })
204
+ }
205
+ },
206
+
207
+ async definition(path: string, line: number, character: number): Promise<LspLocation[]> {
208
+ const doc = docs.get(path)
209
+ if (!doc || !(await readyWait)) return []
210
+ const result = await ensureClient().request<unknown>(
211
+ 'textDocument/definition',
212
+ { textDocument: { uri: doc.uri }, position: { line, character } },
213
+ )
214
+ return normalizeLocations(result)
215
+ },
216
+
217
+ async references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]> {
218
+ const doc = docs.get(path)
219
+ if (!doc || !(await readyWait)) return []
220
+ const result = await ensureClient().request<unknown>(
221
+ 'textDocument/references',
222
+ {
223
+ textDocument: { uri: doc.uri },
224
+ position: { line, character },
225
+ context: { includeDeclaration },
226
+ },
227
+ )
228
+ return normalizeLocations(result)
229
+ },
230
+
231
+ async documentSymbol(path: string): Promise<LspSymbol[]> {
232
+ const doc = docs.get(path)
233
+ if (!doc || !(await readyWait)) return []
234
+ const result = await ensureClient().request<unknown>('textDocument/documentSymbol', { textDocument: { uri: doc.uri } })
235
+ if (!Array.isArray(result)) return []
236
+ return (result as unknown[]).map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
237
+ },
238
+
239
+ async workspaceSymbol(query: string): Promise<LspSymbol[]> {
240
+ if (!(await readyWait)) return []
241
+ const result = await ensureClient().request<unknown>('workspace/symbol', { query })
242
+ if (!Array.isArray(result)) return []
243
+ return (result as unknown[]).map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
244
+ },
245
+
246
+ async hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null> {
247
+ const doc = docs.get(path)
248
+ if (!doc || !(await readyWait)) return null
249
+ const result = await ensureClient().request<{ contents?: unknown; range?: unknown } | null>(
250
+ 'textDocument/hover',
251
+ { textDocument: { uri: doc.uri }, position: { line, character } },
252
+ )
253
+ if (!result || !result.contents) return null
254
+ return { contents: stringifyHoverContents(result.contents) }
255
+ },
256
+
257
+ async dispose(): Promise<void> {
258
+ disposed = true
259
+ resolveReady?.(false)
260
+ resolveReady = null
261
+ if (client) {
262
+ await client.shutdown().catch(() => {})
263
+ client = null
264
+ }
265
+ if (transport) {
266
+ transport.dispose()
267
+ transport = null
268
+ }
269
+ setPhase('stopped')
270
+ },
271
+ }
272
+ return server
273
+ }
274
+
275
+ /** 归一化 definition/references 结果(Location | Location[] | LocationLink[])。 */
276
+ function normalizeLocations(result: unknown): LspLocation[] {
277
+ if (!Array.isArray(result)) return []
278
+ const out: LspLocation[] = []
279
+ for (const item of result) {
280
+ if (!item || typeof item !== 'object') continue
281
+ const obj = item as { uri?: unknown; range?: unknown; targetUri?: unknown; targetRange?: unknown; targetSelectionRange?: unknown }
282
+ const uri = typeof obj.targetUri === 'string' ? obj.targetUri : typeof obj.uri === 'string' ? obj.uri : ''
283
+ const range = (obj.targetRange ?? obj.range) as { start?: { line?: unknown; character?: unknown }; end?: { line?: unknown; character?: unknown } } | undefined
284
+ if (!uri || !range || !range.start || !range.end) continue
285
+ out.push({
286
+ uri,
287
+ range: {
288
+ start: { line: toInt(range.start.line), character: toInt(range.start.character) },
289
+ end: { line: toInt(range.end.line), character: toInt(range.end.character) },
290
+ },
291
+ })
292
+ }
293
+ return out
294
+ }
295
+
296
+ /** 归一化 documentSymbol/workspaceSymbol 结果(DocumentSymbol | SymbolInformation)。 */
297
+ function normalizeSymbol(item: unknown): LspSymbol | null {
298
+ if (!item || typeof item !== 'object') return null
299
+ const obj = item as {
300
+ name?: unknown
301
+ kind?: unknown
302
+ detail?: unknown
303
+ containerName?: unknown
304
+ range?: { start?: unknown; end?: unknown }
305
+ selectionRange?: { start?: unknown; end?: unknown }
306
+ children?: unknown
307
+ location?: { uri?: unknown; range?: unknown }
308
+ }
309
+ if (typeof obj.name !== 'string') return null
310
+ const selection = obj.selectionRange ?? (obj.location && (obj.location as { range?: unknown }).range)
311
+ const range = obj.range ?? selection
312
+ if (!range || typeof range !== 'object') return null
313
+ const r = range as { start?: { line?: unknown; character?: unknown }; end?: { line?: unknown; character?: unknown } }
314
+ if (!r.start || !r.end) return null
315
+ const symbol: LspSymbol = {
316
+ name: obj.name,
317
+ kind: toInt(obj.kind),
318
+ range: { start: { line: toInt((r.start as { line?: unknown }).line), character: toInt((r.start as { character?: unknown }).character) }, end: { line: toInt((r.end as { line?: unknown }).line), character: toInt((r.end as { character?: unknown }).character) } },
319
+ selectionRange: selection
320
+ ? { start: { line: toInt(((selection as { start?: { line?: unknown } }).start as { line?: unknown } | undefined)?.line ?? toInt((r.start as { line?: unknown }).line)), character: toInt(((selection as { start?: { character?: unknown } }).start as { character?: unknown } | undefined)?.character ?? toInt((r.start as { character?: unknown }).character)) }, end: { line: toInt(((selection as { end?: { line?: unknown } }).end as { line?: unknown } | undefined)?.line ?? toInt((r.end as { line?: unknown }).line)), character: toInt(((selection as { end?: { character?: unknown } }).end as { character?: unknown } | undefined)?.character ?? toInt((r.end as { character?: unknown }).character)) } }
321
+ : { start: { line: toInt((r.start as { line?: unknown }).line), character: toInt((r.start as { character?: unknown }).character) }, end: { line: toInt((r.end as { line?: unknown }).line), character: toInt((r.end as { character?: unknown }).character) } },
322
+ }
323
+ if (typeof obj.detail === 'string' && obj.detail) symbol.detail = obj.detail
324
+ if (typeof obj.containerName === 'string' && obj.containerName) symbol.containerName = obj.containerName
325
+ if (Array.isArray(obj.children)) {
326
+ const kids = obj.children.map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
327
+ if (kids.length) symbol.children = kids
328
+ }
329
+ return symbol
330
+ }
331
+
332
+ /** hover contents → 文本行(MarkupContent | MarkedString | MarkedString[])。 */
333
+ function stringifyHoverContents(contents: unknown): string[] {
334
+ if (typeof contents === 'string') return [contents]
335
+ if (Array.isArray(contents)) return contents.map(stringifyHoverContents).flat().filter(Boolean)
336
+ if (contents && typeof contents === 'object') {
337
+ const value = (contents as { value?: unknown }).value
338
+ if (typeof value === 'string') return [value]
339
+ }
340
+ return []
341
+ }
342
+
343
+ function toInt(value: unknown): number {
344
+ const n = Number(value)
345
+ return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
346
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * dsh-vscode-mode host — LSP 配置设置 section 安装(动态依赖,缺失不抛错)。
3
+ * ns:dsh-vscode-mode.languageServers;每语言 { enabled, command, path }。
4
+ * 作者 ddj 2026-08-27
5
+ */
6
+ import type { Ctx } from '../store.js'
7
+ import { LSP_LANGUAGES, LSP_SETTINGS_NS, type LspConfig } from './config.js'
8
+ import { loadSettingsDeps, type SettingsDeps } from '../fileOpenSettings.js'
9
+
10
+ /**
11
+ * 安装 LSP 设置 section。
12
+ * @author ddj 2026年08月27号
13
+ * @param ctx DSH 上下文
14
+ * @param entry 初始配置
15
+ * @returns 是否成功安装
16
+ */
17
+ export async function installLspSettingsSection(ctx: Ctx, entry: LspConfig): Promise<boolean> {
18
+ const deps = await loadSettingsDeps()
19
+ if (!deps) return false
20
+ try {
21
+ // schemastery 无 .optional(),对象字段默认即可选;.default 必给避免 undefined 覆盖
22
+ const z = deps.z
23
+ const langShape: Record<string, unknown> = {}
24
+ for (const lang of LSP_LANGUAGES) {
25
+ langShape[lang] = z.object({
26
+ enabled: z.boolean(),
27
+ command: z.string(),
28
+ path: z.string(),
29
+ }).default({})
30
+ }
31
+ const schema = z.object(langShape).default({})
32
+ deps.installSettingsSection(ctx, LSP_SETTINGS_NS, schema, entry, {
33
+ setSource: () => {},
34
+ onChange: () => {},
35
+ })
36
+ return true
37
+ } catch (error) {
38
+ // 设置 section 安装失败不致命:降级为无设置 UI,LSP 核心功能不受影响
39
+ console.error('[dsh-vscode-mode] LSP 设置 section 安装失败:' + String(error))
40
+ return false
41
+ }
42
+ }
43
+
44
+ export type { SettingsDeps }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * dsh-vscode-mode host — LSP 语言服务器进程传输层。
3
+ * 基于 node:child_process.spawn(stdio pipe 双向流)。刻意不用 DSH ctx.subprocess
4
+ * 抽象:LSP 需要持续写 stdin + 流式读 stdout,而该抽象 stdin 不可写、输出为一次性收集。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { spawn, type ChildProcess } from 'node:child_process'
8
+ import { createFrameParser } from './jsonrpc.js'
9
+
10
+ const STDERR_CAP = 256 << 10
11
+
12
+ export interface TransportSpec {
13
+ argv: string[]
14
+ cwd?: string
15
+ env?: Record<string, string>
16
+ }
17
+
18
+ export interface Transport {
19
+ /** 写一行帧到 stdin(自动加换行)。 */
20
+ write(chunk: Buffer): boolean
21
+ /** 推送 stdout 解析出的原始消息(内部由帧解析器调用)。 */
22
+ onMessage: ((message: unknown) => void) | null
23
+ /** stderr 收集缓冲(有界,供诊断)。 */
24
+ stderr(): string
25
+ /** 进程退出/崩溃回调(code/signal)。 */
26
+ onExit: ((code: number | null, signal: string | null) => void) | null
27
+ /** 优雅关闭后强杀(Windows 下连带进程树)。 */
28
+ dispose(): void
29
+ /** 是否仍存活。 */
30
+ readonly alive: boolean
31
+ readonly pid: number | undefined
32
+ }
33
+
34
+ const running = new Set<ChildProcess>()
35
+
36
+ /** 当前仍在运行的 LSP 子进程(供 disposeAll / 诊断)。 */
37
+ export function liveChildren(): ReadonlySet<ChildProcess> {
38
+ return running
39
+ }
40
+
41
+ /**
42
+ * 启动语言服务器进程。
43
+ * @author ddj 2026年08月27号
44
+ * @param spec 启动参数(argv 数组,不经 shell,Windows 引号安全)
45
+ * @param onMessage stdout 消息回调
46
+ * @param logger 诊断日志(可选)
47
+ * @returns 传输句柄
48
+ */
49
+ export function spawnServer(spec: TransportSpec, onMessage: (message: unknown) => void, logger?: (line: string) => void): Transport {
50
+ const { argv, cwd, env } = spec
51
+ const child = spawn(argv[0], argv.slice(1), {
52
+ cwd,
53
+ env: { ...process.env, ...env },
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ windowsHide: true,
56
+ })
57
+ running.add(child)
58
+ let stderrBuf = Buffer.alloc(0)
59
+ let alive = true
60
+ const parser = createFrameParser()
61
+
62
+ child.stderr.on('data', (chunk: Buffer) => {
63
+ if (stderrBuf.length < STDERR_CAP) {
64
+ stderrBuf = Buffer.concat([stderrBuf, chunk], Math.min(stderrBuf.length + chunk.length, STDERR_CAP))
65
+ }
66
+ logger?.(chunk.toString('utf8'))
67
+ })
68
+
69
+ let exited = false
70
+ const emitExit = (code: number | null, signal: string | null): void => {
71
+ if (exited) return
72
+ exited = true
73
+ alive = false
74
+ running.delete(child)
75
+ transport.onExit?.(code, signal)
76
+ }
77
+ child.on('error', (error) => {
78
+ logger?.('spawn error: ' + String(error))
79
+ emitExit(null, null)
80
+ })
81
+ child.on('exit', (code, signal) => emitExit(code, signal))
82
+ child.on('close', (code, signal) => emitExit(code, signal))
83
+
84
+ const transport: Transport = {
85
+ write(chunk: Buffer): boolean {
86
+ if (!alive || !child.stdin.writable) return false
87
+ child.stdin.write(chunk)
88
+ return true
89
+ },
90
+ onMessage,
91
+ stderr(): string {
92
+ return stderrBuf.toString('utf8')
93
+ },
94
+ onExit: null,
95
+ dispose(): void {
96
+ if (!alive) return
97
+ // 先 SIGTERM 让服务器有机会清理,1.5s 后强杀进程树
98
+ child.kill()
99
+ const timer = setTimeout(() => {
100
+ if (!alive) return
101
+ alive = false
102
+ running.delete(child)
103
+ try {
104
+ if (process.platform === 'win32') {
105
+ spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
106
+ } else {
107
+ child.kill('SIGKILL')
108
+ }
109
+ } catch (error) {
110
+ logger?.('force kill error: ' + String(error))
111
+ }
112
+ }, 1500)
113
+ timer.unref?.()
114
+ },
115
+ get alive() {
116
+ return alive
117
+ },
118
+ get pid() {
119
+ return child.pid
120
+ },
121
+ }
122
+ // stdout 帧解析 → onMessage(LSP 响应/通知;onMessage 由 client 接管后动态生效)
123
+ child.stdout.on('data', (chunk: Buffer) => {
124
+ for (const message of parser.push(chunk)) transport.onMessage?.(message)
125
+ })
126
+ return transport
127
+ }
128
+
129
+ /** 强杀全部运行中的 LSP 子进程(插件卸载/退出用)。 */
130
+ export function disposeAllServers(): void {
131
+ for (const child of running) {
132
+ try {
133
+ if (process.platform === 'win32' && child.pid) {
134
+ spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
135
+ } else {
136
+ child.kill('SIGKILL')
137
+ }
138
+ } catch (error) {
139
+ /* 忽略单个清理失败 */
140
+ }
141
+ }
142
+ running.clear()
143
+ }
package/src/lsp/uri.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-vscode-mode host — 路径 ↔ file:// URI 转换(纯函数,node 可测)。
3
+ * LSP 的 uri 字段必须是 file:// 绝对 URI;客户端 Monaco 用 edrv:///<encodeURI(path)>。
4
+ * 作者 ddj 2026-08-27
5
+ */
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { isAbsolute, resolve as resolvePath } from 'node:path'
8
+
9
+ /**
10
+ * 绝对路径 → file:// URI(Windows 盘符保留:D:/x → file:///D:/x)。
11
+ * @author ddj 2026年08月27号
12
+ * @param absPath 绝对路径(正/反斜杠均可)
13
+ * @returns file:// URI
14
+ */
15
+ export function pathToFileUri(absPath: string): string {
16
+ const normalized = absPath.replace(/\\/g, '/')
17
+ const drive = /^([A-Za-z]:)(?:\/(.*))?$/.exec(normalized)
18
+ if (drive) {
19
+ const tail = drive[2] ? '/' + drive[2].split('/').map(encodeURIComponent).join('/') : ''
20
+ return 'file:///' + drive[1] + tail
21
+ }
22
+ return pathToFileURL(normalized).toString()
23
+ }
24
+
25
+ /**
26
+ * file:// URI → 绝对路径(Windows file:///C:/x → C:\x)。
27
+ * @author ddj 2026年08月27号
28
+ * @param uri file:// URI
29
+ * @returns 绝对路径
30
+ */
31
+ export function fileUriToPath(fileUri: string): string {
32
+ try {
33
+ return fileURLToPath(fileUri)
34
+ } catch (error) {
35
+ return String(error)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 将(可能相对的)路径解析为绝对路径后转 file:// URI。
41
+ * @author ddj 2026年08月27号
42
+ * @param path 相对或绝对路径
43
+ * @param cwd 解析相对路径的基准(可选)
44
+ * @returns file:// URI
45
+ */
46
+ export function resolveToFileUri(path: string, cwd?: string): string {
47
+ const abs = isAbsolute(path) ? path : cwd ? resolvePath(cwd, path) : resolvePath(path)
48
+ return pathToFileUri(abs)
49
+ }
50
+
51
+ /**
52
+ * 判定绝对路径是否在给定根目录(含)内。
53
+ * @author ddj 2026年08月27号
54
+ * @param root 根目录绝对路径
55
+ * @param target 目标绝对路径
56
+ * @returns 是否在根内
57
+ */
58
+ export function isInside(root: string, target: string): boolean {
59
+ const r = root.replace(/\\/g, '/').replace(/\/+$/, '') + '/'
60
+ const t = target.replace(/\\/g, '/')
61
+ return t === r.slice(0, -1) || t.startsWith(r)
62
+ }
package/src/lsp/zip.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dsh-vscode-mode host — 纯 Node ZIP 解压(零依赖,跨平台)。
3
+ * 供 extmgr 解包 VSIX 使用;只实现 VSIX 需要的子集:
4
+ * 读取 EOCD + 中央目录,支持 stored(0)/deflate(8),忽略 zip64(VSIX < 4GB)。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { inflateRawSync } from 'node:zlib'
8
+
9
+ /** 单条解压结果:相对路径 + 内容(目录条目 path 以 / 结尾)。 */
10
+ export interface ZipEntry {
11
+ path: string
12
+ data: Buffer
13
+ isDirectory: boolean
14
+ }
15
+
16
+ const EOCD_SIG = 0x06054b50
17
+ const CDIR_SIG = 0x02014b50
18
+ const LHDR_SIG = 0x04034b50
19
+
20
+ /**
21
+ * 解析 ZIP 缓冲区 → 条目列表(不解压内容,仅元数据)。
22
+ * @author ddj 2026年08月27号
23
+ * @param buf zip 字节
24
+ * @returns 条目元数据
25
+ */
26
+ export function zipEntries(buf: Buffer): { name: string; method: number; compressed: number; uncompressed: number; localOffset: number }[] {
27
+ const eocd = findEocd(buf)
28
+ if (eocd < 0) throw new Error('不是有效的 ZIP 文件(找不到 EOCD)')
29
+ const total = buf.readUInt16LE(eocd + 10)
30
+ let offset = buf.readUInt32LE(eocd + 16)
31
+ const out: { name: string; method: number; compressed: number; uncompressed: number; localOffset: number }[] = []
32
+ for (let i = 0; i < total; i++) {
33
+ if (buf.readUInt32LE(offset) !== CDIR_SIG) throw new Error('ZIP 中央目录损坏')
34
+ const method = buf.readUInt16LE(offset + 10)
35
+ const compressed = buf.readUInt32LE(offset + 20)
36
+ const uncompressed = buf.readUInt32LE(offset + 24)
37
+ const nameLen = buf.readUInt16LE(offset + 28)
38
+ const extraLen = buf.readUInt16LE(offset + 30)
39
+ const commentLen = buf.readUInt16LE(offset + 32)
40
+ const localOffset = buf.readUInt32LE(offset + 42)
41
+ const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString('utf8')
42
+ out.push({ name, method, compressed, uncompressed, localOffset })
43
+ offset += 46 + nameLen + extraLen + commentLen
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * 解压 ZIP → 条目列表(目录条目含尾部 /)。
50
+ * @author ddj 2026年08月27号
51
+ * @param buf zip 字节
52
+ * @returns 条目(含内容)
53
+ */
54
+ export function unzip(buf: Buffer): ZipEntry[] {
55
+ const metas = zipEntries(buf)
56
+ const out: ZipEntry[] = []
57
+ for (const meta of metas) {
58
+ const lh = meta.localOffset
59
+ if (buf.readUInt32LE(lh) !== LHDR_SIG) throw new Error('ZIP 本地头损坏:' + meta.name)
60
+ const nameLen = buf.readUInt16LE(lh + 26)
61
+ const extraLen = buf.readUInt16LE(lh + 28)
62
+ const dataStart = lh + 30 + nameLen + extraLen
63
+ const isDir = /\/$/.test(meta.name)
64
+ let data: Buffer = Buffer.alloc(0)
65
+ if (!isDir && meta.compressed > 0) {
66
+ const raw = buf.subarray(dataStart, dataStart + meta.compressed)
67
+ data = meta.method === 8 ? Buffer.from(inflateRawSync(raw)) : meta.method === 0 ? Buffer.from(raw) : Buffer.alloc(0)
68
+ }
69
+ out.push({ path: meta.name, data, isDirectory: isDir })
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** 定位 EOCD(从尾部向前扫,容忍尾部注释)。 */
75
+ function findEocd(buf: Buffer): number {
76
+ const maxScan = Math.min(buf.length, 65557)
77
+ for (let i = buf.length - 22; i >= buf.length - maxScan; i--) {
78
+ if (i < 0) break
79
+ if (buf.readUInt32LE(i) === EOCD_SIG) return i
80
+ }
81
+ return -1
82
+ }