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.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/lib/client.js +1439 -46
- package/lib/client.js.map +1 -1
- package/lib/index.js +3519 -222
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/capture.ts +3 -0
- package/src/client/events.ts +11 -0
- package/src/client/index.ts +20 -2
- package/src/client/monaco/lsp/index.ts +30 -0
- package/src/client/monaco/lsp/lspClient.ts +192 -0
- package/src/client/monaco/lsp/providers.ts +229 -0
- package/src/client/outline/OutlinePanel.tsx +1 -1
- package/src/client/outline/lspSource.ts +49 -0
- package/src/client/outline/parse.ts +31 -1
- package/src/client/outline/types.ts +2 -0
- package/src/client/paths.ts +36 -0
- package/src/client/sidebar/panels/FileExplorer.ts +95 -30
- package/src/client/sidebar/panels/SearchPanel.ts +11 -2
- package/src/client/state/explorerCache.ts +3 -1
- package/src/client/state/explorerEntriesCache.ts +172 -0
- package/src/client/state/viewStateCache.ts +3 -1
- package/src/client/styles/editor.css +16 -0
- package/src/client/styles/mcp.css +63 -0
- package/src/client/ui/EditorView.ts +61 -6
- package/src/client/ui/LspSettings.ts +268 -0
- package/src/client/ui/McpSettings.ts +5 -1
- package/src/client/ui/PerfSettings.ts +242 -0
- package/src/client/ui/PerfSizeBadge.ts +54 -0
- package/src/compat.ts +3 -2
- package/src/fileOpenSettings.ts +1 -0
- package/src/index.ts +25 -2
- package/src/lsp/client.ts +205 -0
- package/src/lsp/config.ts +107 -0
- package/src/lsp/extmgr.ts +208 -0
- package/src/lsp/jsonrpc.ts +86 -0
- package/src/lsp/manager.ts +133 -0
- package/src/lsp/providers.ts +235 -0
- package/src/lsp/rpc.ts +334 -0
- package/src/lsp/server.ts +346 -0
- package/src/lsp/settings.ts +44 -0
- package/src/lsp/transport.ts +143 -0
- package/src/lsp/uri.ts +62 -0
- package/src/lsp/zip.ts +82 -0
- package/src/paths.ts +284 -0
- package/src/perf.ts +340 -0
- package/src/perfPatch.ts +76 -0
- package/src/routes.ts +7 -12
- package/src/rpc.ts +184 -36
- package/src/search/content.ts +14 -7
- package/src/search/ripgrep.ts +54 -1
- package/src/search/types.ts +4 -1
- package/src/shared/lsp.ts +138 -0
- package/src/shared/rpc.ts +103 -2
- package/src/store.ts +2 -2
- package/src/treeIndex.ts +397 -0
- 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
|
+
}
|