dsh-vscode-mode 0.1.32 → 0.1.36
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/lib/client.js +1229 -124
- package/lib/client.js.map +1 -1
- package/lib/index.js +1959 -52
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/events.ts +11 -0
- package/src/client/index.ts +15 -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 +2 -0
- package/src/client/state/workspaceFoldCache.ts +95 -0
- package/src/client/styles/editor.css +15 -0
- package/src/client/styles/mcp.css +50 -12
- package/src/client/ui/EditorView.ts +55 -1
- package/src/client/ui/KeybindingsPanel.ts +8 -5
- package/src/client/ui/LspSettings.ts +274 -0
- package/src/client/ui/McpSettings.ts +112 -49
- package/src/client/ui/PerfSettings.ts +145 -34
- package/src/fileOpenSettings.ts +1 -0
- package/src/index.ts +20 -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/rpc.ts +11 -2
- package/src/shared/lsp.ts +138 -0
- package/src/shared/rpc.ts +33 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — VSIX 扩展管理器(extmgr)。
|
|
3
|
+
* 管理 DSH 专属扩展目录(~/.dsh/dsh-vscode-mode/extensions/),支持:
|
|
4
|
+
* 本地 .vsix 安装、Open VSX 市场搜索/下载安装、卸载、更新检测与安装。
|
|
5
|
+
* 已装扩展作为 LSP provider 的"扩展源"(kind=extension),见 providers.ts。
|
|
6
|
+
* 作者 ddj 2026-08-27
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { basename, dirname, join } from 'node:path'
|
|
10
|
+
import { unzip } from './zip.js'
|
|
11
|
+
import { dshHome, PLUGIN_ID } from '../paths.js'
|
|
12
|
+
|
|
13
|
+
/** 单个已装扩展信息(edrv.lsp.ext.list 载荷元素)。 */
|
|
14
|
+
export interface ExtInfo {
|
|
15
|
+
id: string // publisher.name
|
|
16
|
+
namespace: string
|
|
17
|
+
name: string
|
|
18
|
+
version: string
|
|
19
|
+
displayName?: string
|
|
20
|
+
description?: string
|
|
21
|
+
dirName: string // 目录名 publisher.name-version
|
|
22
|
+
dir: string // 绝对路径
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Open VSX 市场搜索结果(edrv.lsp.ext.market 载荷元素)。 */
|
|
26
|
+
export interface MarketItem {
|
|
27
|
+
namespace: string
|
|
28
|
+
name: string
|
|
29
|
+
id: string
|
|
30
|
+
version: string
|
|
31
|
+
displayName?: string
|
|
32
|
+
description?: string
|
|
33
|
+
download?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OPEN_VSX = 'https://open-vsx.org'
|
|
37
|
+
const META_FILE = 'package.json'
|
|
38
|
+
/** 当前平台(与 VSCode platform 命名对齐:win32-x64/darwin-arm64/linux-x64)。 */
|
|
39
|
+
const PLATFORM = (() => {
|
|
40
|
+
if (process.platform === 'win32') return process.arch === 'arm64' ? 'win32-arm64' : 'win32-x64'
|
|
41
|
+
if (process.platform === 'darwin') return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
|
|
42
|
+
return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x64'
|
|
43
|
+
})()
|
|
44
|
+
|
|
45
|
+
/** DSH 专属扩展目录。 */
|
|
46
|
+
export function extensionsRoot(home = dshHome()): string {
|
|
47
|
+
return join(home, PLUGIN_ID, 'extensions')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 从 VSIX 字节里读 extension/package.json 清单。 */
|
|
51
|
+
export function vsixManifest(vsix: Buffer): Record<string, unknown> {
|
|
52
|
+
for (const entry of unzip(vsix)) {
|
|
53
|
+
if (entry.path === 'extension/package.json' || entry.path === 'extension\\package.json') {
|
|
54
|
+
return JSON.parse(entry.data.toString('utf8')) as Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error('VSIX 缺少 extension/package.json')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解包 VSIX 到目录(仅取 extension/ 子树,剥前缀;返回清单)。
|
|
62
|
+
* @author ddj 2026年08月27号
|
|
63
|
+
* @param vsix vsix 字节
|
|
64
|
+
* @param dest 目标目录(已存在则先清空)
|
|
65
|
+
* @returns 清单对象
|
|
66
|
+
*/
|
|
67
|
+
export function unpackVsix(vsix: Buffer, dest: string): Record<string, unknown> {
|
|
68
|
+
rmSync(dest, { recursive: true, force: true })
|
|
69
|
+
mkdirSync(dest, { recursive: true })
|
|
70
|
+
let manifest: Record<string, unknown> | null = null
|
|
71
|
+
for (const entry of unzip(vsix)) {
|
|
72
|
+
const rel = entry.path.replace(/^extension[\\/]/, '')
|
|
73
|
+
if (!rel || entry.path.startsWith('..')) continue
|
|
74
|
+
const safe = rel.split(/[\\/]/).filter((part) => part && part !== '..' && part !== '.').join('/')
|
|
75
|
+
if (!safe) continue
|
|
76
|
+
const target = join(dest, ...safe.split('/'))
|
|
77
|
+
if (entry.isDirectory) {
|
|
78
|
+
mkdirSync(target, { recursive: true })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
82
|
+
writeFileSync(target, entry.data)
|
|
83
|
+
if (rel === 'package.json') manifest = JSON.parse(entry.data.toString('utf8')) as Record<string, unknown>
|
|
84
|
+
}
|
|
85
|
+
if (!manifest) throw new Error('VSIX 解包后缺少 package.json')
|
|
86
|
+
return manifest
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 扩展信息(清单 + 目录)。 */
|
|
90
|
+
export function extInfoOf(manifest: Record<string, unknown>, dir: string): ExtInfo {
|
|
91
|
+
const namespace = String(manifest.publisher ?? 'unknown')
|
|
92
|
+
const name = String(manifest.name ?? 'unknown')
|
|
93
|
+
const version = String(manifest.version ?? '0.0.0')
|
|
94
|
+
return {
|
|
95
|
+
id: namespace + '.' + name,
|
|
96
|
+
namespace,
|
|
97
|
+
name,
|
|
98
|
+
version,
|
|
99
|
+
displayName: typeof manifest.displayName === 'string' ? manifest.displayName : undefined,
|
|
100
|
+
description: typeof manifest.description === 'string' ? manifest.description : undefined,
|
|
101
|
+
dirName: basename(dir),
|
|
102
|
+
dir,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 安装本地 .vsix 文件(已存在同 id 则升级替换)。 */
|
|
107
|
+
export async function installVsixFile(vsixPath: string, home = dshHome()): Promise<ExtInfo> {
|
|
108
|
+
const vsix = readFileSync(vsixPath)
|
|
109
|
+
return installVsixBuffer(vsix, home)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 安装 VSIX 字节(解包进 extensionsRoot;同名旧版本先清)。 */
|
|
113
|
+
export function installVsixBuffer(vsix: Buffer, home = dshHome()): ExtInfo {
|
|
114
|
+
const manifest = vsixManifest(vsix)
|
|
115
|
+
const info = extInfoOf(manifest, '')
|
|
116
|
+
const target = join(extensionsRoot(home), info.id + '-' + info.version)
|
|
117
|
+
const unpacked = unpackVsix(vsix, target)
|
|
118
|
+
return extInfoOf(unpacked, target)
|
|
119
|
+
}
|
|
120
|
+
/** 扫描已装扩展目录。 */
|
|
121
|
+
export function listInstalled(home = dshHome()): ExtInfo[] {
|
|
122
|
+
const root = extensionsRoot(home)
|
|
123
|
+
if (!existsSync(root)) return []
|
|
124
|
+
const out: ExtInfo[] = []
|
|
125
|
+
for (const dir of readdirSync(root)) {
|
|
126
|
+
const full = join(root, dir)
|
|
127
|
+
const meta = join(full, META_FILE)
|
|
128
|
+
if (!existsSync(meta)) continue
|
|
129
|
+
try {
|
|
130
|
+
const manifest = JSON.parse(readFileSync(meta, 'utf8')) as Record<string, unknown>
|
|
131
|
+
out.push(extInfoOf(manifest, full))
|
|
132
|
+
} catch (error) {
|
|
133
|
+
/* 坏清单目录跳过 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 卸载扩展(按 id)。 */
|
|
140
|
+
export function uninstall(id: string, home = dshHome()): boolean {
|
|
141
|
+
const target = listInstalled(home).find((e) => e.id === id)?.dir
|
|
142
|
+
if (!target) return false
|
|
143
|
+
rmSync(target, { recursive: true, force: true })
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Open VSX 单扩展元数据(latest + 下载 URL)。 */
|
|
148
|
+
export async function marketGet(namespace: string, name: string, version?: string): Promise<Record<string, unknown> | null> {
|
|
149
|
+
const path = version ? namespace + '/' + name + '/' + version : namespace + '/' + name
|
|
150
|
+
const res = await fetch(OPEN_VSX + '/api/' + path, { headers: { accept: 'application/json' } })
|
|
151
|
+
if (!res.ok) return null
|
|
152
|
+
return (await res.json()) as Record<string, unknown>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Open VSX 搜索。 */
|
|
156
|
+
export async function marketSearch(query: string, size = 12): Promise<MarketItem[]> {
|
|
157
|
+
const url = OPEN_VSX + '/api/-/search?query=' + encodeURIComponent(query) + '&size=' + size
|
|
158
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } })
|
|
159
|
+
if (!res.ok) throw new Error('市场搜索失败:HTTP ' + res.status)
|
|
160
|
+
const data = (await res.json()) as { extensions?: unknown[] }
|
|
161
|
+
return ((data.extensions ?? []) as Record<string, unknown>[]).map((item) => ({
|
|
162
|
+
namespace: String(item.namespace ?? ''),
|
|
163
|
+
name: String(item.name ?? ''),
|
|
164
|
+
id: String(item.namespace ?? '') + '.' + String(item.name ?? ''),
|
|
165
|
+
version: String(item.version ?? ''),
|
|
166
|
+
displayName: typeof item.displayName === 'string' ? item.displayName : undefined,
|
|
167
|
+
description: typeof item.description === 'string' ? item.description : undefined,
|
|
168
|
+
download: typeof (item.files as { download?: unknown } | undefined)?.download === 'string' ? (item.files as { download: string }).download : undefined,
|
|
169
|
+
}))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 平台匹配的 VSIX 下载 URL(探测平台变体,失败回退 API 默认)。 */
|
|
173
|
+
export async function resolveDownload(namespace: string, name: string, version: string): Promise<string> {
|
|
174
|
+
const candidate = OPEN_VSX + '/api/' + namespace + '/' + name + '/' + PLATFORM + '/' + version +
|
|
175
|
+
'/file/' + namespace + '.' + name + '-' + version + '@' + PLATFORM + '.vsix'
|
|
176
|
+
try {
|
|
177
|
+
const head = await fetch(candidate, { method: 'HEAD' })
|
|
178
|
+
if (head.ok) return candidate
|
|
179
|
+
} catch (error) {
|
|
180
|
+
/* 探测失败走默认 */
|
|
181
|
+
}
|
|
182
|
+
const meta = await marketGet(namespace, name, version)
|
|
183
|
+
const download = (meta?.files as { download?: unknown } | undefined)?.download
|
|
184
|
+
if (typeof download === 'string') return download
|
|
185
|
+
throw new Error('市场未提供 ' + namespace + '.' + name + '@' + version + ' 的下载地址')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 从 Open VSX 安装/更新扩展。 */
|
|
189
|
+
export async function installFromMarket(namespace: string, name: string, version?: string): Promise<ExtInfo> {
|
|
190
|
+
const meta = await marketGet(namespace, name, version)
|
|
191
|
+
if (!meta) throw new Error('市场找不到 ' + namespace + '.' + name + (version ? '@' + version : ''))
|
|
192
|
+
const ver = version ?? String(meta.version ?? '')
|
|
193
|
+
const url = await resolveDownload(namespace, name, ver)
|
|
194
|
+
const res = await fetch(url)
|
|
195
|
+
if (!res.ok) throw new Error('下载失败:HTTP ' + res.status)
|
|
196
|
+
const vsix = Buffer.from(await res.arrayBuffer())
|
|
197
|
+
return installVsixBuffer(vsix)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 更新已装扩展(对比市场最新版;返回 null 表示已最新)。 */
|
|
201
|
+
export async function updateExtension(id: string): Promise<ExtInfo | null> {
|
|
202
|
+
const installed = listInstalled().find((e) => e.id === id)
|
|
203
|
+
if (!installed) throw new Error('扩展未安装:' + id)
|
|
204
|
+
const meta = await marketGet(installed.namespace, installed.name)
|
|
205
|
+
const latest = String(meta?.version ?? '')
|
|
206
|
+
if (!latest || latest === installed.version) return null
|
|
207
|
+
return installFromMarket(installed.namespace, installed.name, latest)
|
|
208
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — LSP JSON-RPC 帧编解码(纯函数,node 可测)。
|
|
3
|
+
* LSP over stdio 用 Content-Length 帧:
|
|
4
|
+
* Content-Length: <n>\r\n\r\n<恰好 n 字节 JSON>
|
|
5
|
+
* 导出:encodeMessage(对象→帧 Buffer)、createFrameParser(流式粘包/拆包)、parseFrame(单帧)。
|
|
6
|
+
* 作者 ddj 2026-08-27
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HEADER_END = '\r\n\r\n'
|
|
10
|
+
|
|
11
|
+
/** 消息头正则(仅取 Content-Length;其它头忽略)。 */
|
|
12
|
+
const HEADER_RE = /^Content-Length:\s*(\d+)\s*$/im
|
|
13
|
+
|
|
14
|
+
/** 将对象编码为带 Content-Length 头的帧。 */
|
|
15
|
+
export function encodeMessage(message: unknown): Buffer {
|
|
16
|
+
const body = JSON.stringify(message)
|
|
17
|
+
const head = 'Content-Length: ' + Buffer.byteLength(body, 'utf8') + '\r\n\r\n'
|
|
18
|
+
return Buffer.from(head + body, 'utf8')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 尝试从 buffer 解析单个帧;成功返回 { body, rest },数据不足返回 null。
|
|
23
|
+
* @author ddj 2026年08月27号
|
|
24
|
+
* @param buffer 已累积的字节
|
|
25
|
+
* @returns 解析结果(rest 为剩余未消费字节)
|
|
26
|
+
*/
|
|
27
|
+
export function parseFrame(buffer: Buffer): { body: string; rest: Buffer } | null {
|
|
28
|
+
const headEnd = buffer.indexOf(HEADER_END)
|
|
29
|
+
if (headEnd < 0) return null
|
|
30
|
+
const header = buffer.subarray(0, headEnd).toString('utf8')
|
|
31
|
+
const m = HEADER_RE.exec(header)
|
|
32
|
+
if (!m) {
|
|
33
|
+
// 非法头:丢弃直到下一个帧头,避免死循环
|
|
34
|
+
const next = buffer.indexOf('Content-Length:')
|
|
35
|
+
return next >= 0 ? parseFrame(buffer.subarray(next)) : null
|
|
36
|
+
}
|
|
37
|
+
const length = Number(m[1])
|
|
38
|
+
const bodyStart = headEnd + HEADER_END.length
|
|
39
|
+
if (buffer.length < bodyStart + length) return null
|
|
40
|
+
const body = buffer.subarray(bodyStart, bodyStart + length).toString('utf8')
|
|
41
|
+
return { body, rest: buffer.subarray(bodyStart + length) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 帧解析器:push 数据块,吐出完整消息(可能拆包后重组、也可能一包多帧)。 */
|
|
45
|
+
export function createFrameParser(): {
|
|
46
|
+
push(chunk: Buffer): unknown[]
|
|
47
|
+
clear(): void
|
|
48
|
+
} {
|
|
49
|
+
let buffer: Buffer = Buffer.alloc(0)
|
|
50
|
+
return {
|
|
51
|
+
push(chunk: Buffer): unknown[] {
|
|
52
|
+
if (chunk.length) buffer = Buffer.concat([buffer, chunk]) as Buffer
|
|
53
|
+
const out: unknown[] = []
|
|
54
|
+
for (;;) {
|
|
55
|
+
const parsed = parseFrame(buffer)
|
|
56
|
+
if (!parsed) break
|
|
57
|
+
buffer = parsed.rest
|
|
58
|
+
try {
|
|
59
|
+
out.push(JSON.parse(parsed.body))
|
|
60
|
+
} catch (error) {
|
|
61
|
+
out.push({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error: ' + String(error) }, id: null })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
},
|
|
66
|
+
clear(): void {
|
|
67
|
+
buffer = Buffer.alloc(0)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 构造标准 JSON-RPC 请求消息。 */
|
|
73
|
+
export function requestMessage(id: number, method: string, params: unknown): Record<string, unknown> {
|
|
74
|
+
return { jsonrpc: '2.0', id, method, params }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 构造标准 JSON-RPC 通知消息(无 id,fire-and-forget)。 */
|
|
78
|
+
export function notifyMessage(method: string, params: unknown): Record<string, unknown> {
|
|
79
|
+
return { jsonrpc: '2.0', method, params }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 构造标准 JSON-RPC 响应消息。 */
|
|
83
|
+
export function responseMessage(id: number | string | null, result: unknown, error?: unknown): Record<string, unknown> {
|
|
84
|
+
if (error !== undefined) return { jsonrpc: '2.0', id, error }
|
|
85
|
+
return { jsonrpc: '2.0', id, result }
|
|
86
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — LSP 服务器管理器。
|
|
3
|
+
* 实例注册表(key=root|languageId,refcount 共享),崩溃退避重启,session/disposed 清理。
|
|
4
|
+
* 作者 ddj 2026-08-27
|
|
5
|
+
*/
|
|
6
|
+
import { createLspServer, type LspServer } from './server.js'
|
|
7
|
+
import type { LspProviderSpec } from './providers.js'
|
|
8
|
+
import type { LspServerStatus } from '../shared/lsp.js'
|
|
9
|
+
|
|
10
|
+
interface Entry {
|
|
11
|
+
key: string
|
|
12
|
+
server: LspServer
|
|
13
|
+
refs: number
|
|
14
|
+
failures: number
|
|
15
|
+
restartTimer: ReturnType<typeof setTimeout> | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BACKOFF_MS = [1000, 5000, 30000]
|
|
19
|
+
const MAX_FAILURES = 3
|
|
20
|
+
|
|
21
|
+
export interface LspManager {
|
|
22
|
+
/** 获取(或惰性创建)某工作区+语言的服务器;refs+1。 */
|
|
23
|
+
acquire(root: string, languageId: string, resolveSpec: (languageId: string) => LspProviderSpec): LspServer
|
|
24
|
+
/** 释放引用;refs 归零时停止服务器。 */
|
|
25
|
+
release(root: string, languageId: string): void
|
|
26
|
+
/** 释放工作区下全部语言(session/disposed 用)。 */
|
|
27
|
+
releaseRoot(root: string): void
|
|
28
|
+
/** 状态快照。 */
|
|
29
|
+
statusAll(): LspServerStatus[]
|
|
30
|
+
/** 查询已注册的工作区+语言服务器,不增加引用计数。 */
|
|
31
|
+
peek(root: string, languageId: string): LspServer | undefined
|
|
32
|
+
/** 停止并清空全部(卸载/退出)。 */
|
|
33
|
+
disposeAll(): Promise<void>
|
|
34
|
+
/** 监听状态变化(用于 UI)。 */
|
|
35
|
+
onStatusChange: ((status: LspServerStatus) => void) | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 创建 LSP 管理器。
|
|
40
|
+
* @author ddj 2026年08月27号
|
|
41
|
+
* @param logger 诊断日志
|
|
42
|
+
* @returns 管理器
|
|
43
|
+
*/
|
|
44
|
+
export function createLspManager(logger?: (line: string) => void): LspManager {
|
|
45
|
+
const entries = new Map<string, Entry>()
|
|
46
|
+
const keyOf = (root: string, languageId: string): string => root + '|' + languageId
|
|
47
|
+
|
|
48
|
+
const log = (line: string): void => logger?.('[lsp-manager] ' + line)
|
|
49
|
+
|
|
50
|
+
/** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
|
|
51
|
+
const dropEntry = (entry: Entry): void => {
|
|
52
|
+
if (entry.restartTimer) {
|
|
53
|
+
clearTimeout(entry.restartTimer)
|
|
54
|
+
entry.restartTimer = null
|
|
55
|
+
}
|
|
56
|
+
entries.delete(entry.key)
|
|
57
|
+
void entry.server.dispose().catch(() => {})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scheduleRestart = (entry: Entry): void => {
|
|
61
|
+
if (entry.restartTimer) return
|
|
62
|
+
if (entry.failures >= MAX_FAILURES) {
|
|
63
|
+
log('unavailable after ' + entry.failures + ' failures: ' + entry.key)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const delay = BACKOFF_MS[Math.min(entry.failures, BACKOFF_MS.length - 1)]
|
|
67
|
+
entry.restartTimer = setTimeout(() => {
|
|
68
|
+
entry.restartTimer = null
|
|
69
|
+
if (!entries.has(entry.key)) return
|
|
70
|
+
void (async () => {
|
|
71
|
+
const ok = await entry.server.start()
|
|
72
|
+
if (!ok) entry.failures++
|
|
73
|
+
else entry.failures = 0
|
|
74
|
+
if (!ok && entries.has(entry.key)) scheduleRestart(entry)
|
|
75
|
+
})()
|
|
76
|
+
}, delay)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const manager: LspManager = {
|
|
80
|
+
acquire(root: string, languageId: string, resolveSpec: (languageId: string) => LspProviderSpec): LspServer {
|
|
81
|
+
const key = keyOf(root, languageId)
|
|
82
|
+
let entry = entries.get(key)
|
|
83
|
+
if (!entry) {
|
|
84
|
+
const spec = resolveSpec(languageId)
|
|
85
|
+
const server = createLspServer(spec, root, languageId, log)
|
|
86
|
+
server.onStateChange = (status) => manager.onStatusChange?.(status)
|
|
87
|
+
entry = { key, server, refs: 0, failures: 0, restartTimer: null }
|
|
88
|
+
entries.set(key, entry)
|
|
89
|
+
// 惰性启动:若 spec ready 才启动,否则保持 idle(供 status 展示 reason)
|
|
90
|
+
if (spec.ready) {
|
|
91
|
+
void server.start().then((ok) => {
|
|
92
|
+
if (!ok && entries.has(key)) entry!.failures++
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
entry.refs++
|
|
97
|
+
return entry.server
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
release(root: string, languageId: string): void {
|
|
101
|
+
const entry = entries.get(keyOf(root, languageId))
|
|
102
|
+
if (!entry) return
|
|
103
|
+
entry.refs = Math.max(0, entry.refs - 1)
|
|
104
|
+
if (entry.refs === 0) dropEntry(entry)
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
releaseRoot(root: string): void {
|
|
108
|
+
for (const entry of [...entries.values()]) {
|
|
109
|
+
if (entry.server.root === root) dropEntry(entry)
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
statusAll(): LspServerStatus[] {
|
|
114
|
+
return [...entries.values()].map((entry) => entry.server.status())
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
peek(root: string, languageId: string): LspServer | undefined {
|
|
118
|
+
return entries.get(keyOf(root, languageId))?.server
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async disposeAll(): Promise<void> {
|
|
122
|
+
const list = [...entries.values()]
|
|
123
|
+
entries.clear()
|
|
124
|
+
for (const entry of list) {
|
|
125
|
+
if (entry.restartTimer) clearTimeout(entry.restartTimer)
|
|
126
|
+
await entry.server.dispose()
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
onStatusChange: null,
|
|
131
|
+
}
|
|
132
|
+
return manager
|
|
133
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 语言服务器 provider 解析。
|
|
3
|
+
* 优先级:手动配置 > extmgr 扩展源(Phase 2 挂载) > 自动发现(~/.vscode/extensions、
|
|
4
|
+
* ~/.dsh/dsh-vscode-mode/extensions、PATH)。产物 LspProviderSpec:{ ready, argv, cwd, reason },
|
|
5
|
+
* 供 manager 启动服务器。
|
|
6
|
+
* 作者 ddj 2026-08-27
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, type Dirent } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join, sep } from 'node:path'
|
|
11
|
+
import { isAbsolute } from 'node:path'
|
|
12
|
+
import { extensionsRoot } from './extmgr.js'
|
|
13
|
+
import { dshHome } from '../paths.js'
|
|
14
|
+
|
|
15
|
+
/** provider 来源标记(与 shared/lsp.ts 的 status.source 一致)。 */
|
|
16
|
+
export type LspProviderKind = 'extension' | 'discover' | 'manual' | 'none'
|
|
17
|
+
|
|
18
|
+
export interface LspProviderSpec {
|
|
19
|
+
languageId: string
|
|
20
|
+
kind: LspProviderKind
|
|
21
|
+
argv: string[]
|
|
22
|
+
cwd?: string
|
|
23
|
+
ready: boolean
|
|
24
|
+
reason?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 语言 → 扩展名(用于判断哪些文件触发该语言服务器)。 */
|
|
28
|
+
export const LSP_LANG_EXT: Record<string, string[]> = {
|
|
29
|
+
lua: ['lua'],
|
|
30
|
+
csharp: ['cs', 'csx'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 平台目录名(LuaLS/OmniSharp 的 server/bin/<平台>)。 */
|
|
34
|
+
export function platformServerDir(platform = process.platform): string {
|
|
35
|
+
if (platform === 'win32') return 'Windows'
|
|
36
|
+
if (platform === 'darwin') return 'macOS'
|
|
37
|
+
return 'Linux'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** exe 后缀(Windows 为 .exe)。 */
|
|
41
|
+
export function exeSuffix(platform = process.platform): string {
|
|
42
|
+
return platform === 'win32' ? '.exe' : ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 列出目录下匹配前缀的子目录(如 ~/.vscode/extensions/sumneko.lua-*)。 */
|
|
46
|
+
export function listMatchingDirs(dir: string, prefix: string): string[] {
|
|
47
|
+
if (!existsSync(dir)) return []
|
|
48
|
+
let entries: Dirent[]
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
return entries.filter((e) => e.isDirectory() && e.name.startsWith(prefix)).map((e) => join(dir, e.name))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** VSCode 扩展目录(~/.vscode/extensions、~/.vscode-server/extensions + extmgr 目录;无则空数组)。 */
|
|
58
|
+
export function vscodeExtensionsDirs(home = dshHome()): string[] {
|
|
59
|
+
const userHome = homedir()
|
|
60
|
+
return [
|
|
61
|
+
join(userHome, '.vscode', 'extensions'),
|
|
62
|
+
join(userHome, '.vscode-server', 'extensions'),
|
|
63
|
+
extensionsRoot(home),
|
|
64
|
+
].filter((dir) => existsSync(dir))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --region 扩展源 provider 挂载点(extmgr 安装扩展后注册;kind=extension)
|
|
68
|
+
/** 扩展源 provider 注册表:languageId → 启动参数。 */
|
|
69
|
+
const extProviders = new Map<string, { argv: string[]; cwd?: string; version?: string }>()
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 注册扩展源 provider(extmgr 安装的 VSIX 扩展调用;覆盖/清除传空 argv 可注销)。
|
|
73
|
+
* @author ddj 2026年08月27号
|
|
74
|
+
* @param languageId 语言 id
|
|
75
|
+
* @param argv 启动参数(argv[0] 为可执行)
|
|
76
|
+
* @param cwd 工作目录
|
|
77
|
+
* @param version 扩展版本(诊断展示)
|
|
78
|
+
*/
|
|
79
|
+
export function registerExtensionProvider(languageId: string, argv: string[], cwd?: string, version?: string): void {
|
|
80
|
+
if (!argv.length) {
|
|
81
|
+
extProviders.delete(languageId)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
extProviders.set(languageId, { argv, cwd, version })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 取扩展源 provider spec(未注册返回 null)。 */
|
|
88
|
+
export function extensionProviderFor(languageId: string): LspProviderSpec | null {
|
|
89
|
+
const p = extProviders.get(languageId)
|
|
90
|
+
if (!p || !p.argv.length) return null
|
|
91
|
+
return { languageId, kind: 'extension', argv: p.argv, cwd: p.cwd, ready: true }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 清空全部扩展源注册(插件卸载/扩展目录重建时)。 */
|
|
95
|
+
export function clearExtensionProviders(): void {
|
|
96
|
+
extProviders.clear()
|
|
97
|
+
}
|
|
98
|
+
// --endregion
|
|
99
|
+
|
|
100
|
+
/** PATH 中查找可执行文件(不含扩展名校验,交由 spawn 报错)。 */
|
|
101
|
+
export function findInPath(name: string): string | null {
|
|
102
|
+
const pathEnv = process.env.PATH ?? ''
|
|
103
|
+
const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']
|
|
104
|
+
for (const dir of pathEnv.split(sep === '\\' ? ';' : ':')) {
|
|
105
|
+
if (!dir) continue
|
|
106
|
+
for (const ext of exts) {
|
|
107
|
+
const candidate = join(dir, name + ext)
|
|
108
|
+
if (existsSync(candidate)) return candidate
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 查找 dotnet(PATH + 常见安装路径)。 */
|
|
115
|
+
export function findDotnet(): string | null {
|
|
116
|
+
const inPath = findInPath('dotnet')
|
|
117
|
+
if (inPath) return inPath
|
|
118
|
+
const candidates = [
|
|
119
|
+
'C:/Program Files/dotnet/dotnet.exe',
|
|
120
|
+
'C:/Program Files (x86)/dotnet/dotnet.exe',
|
|
121
|
+
]
|
|
122
|
+
for (const c of candidates) if (existsSync(c)) return c
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** LuaLS 自动发现候选(VSCode 扩展目录 + extmgr 目录下 sumneko.lua-* 的 server/bin 可执行)。 */
|
|
127
|
+
export function candidateLuaServers(home = dshHome()): string[] {
|
|
128
|
+
const plat = platformServerDir()
|
|
129
|
+
const suffix = exeSuffix()
|
|
130
|
+
const out: string[] = []
|
|
131
|
+
for (const dir of vscodeExtensionsDirs(home)) {
|
|
132
|
+
for (const extDir of listMatchingDirs(dir, 'sumneko.lua-')) {
|
|
133
|
+
const binDir = join(extDir, 'server', 'bin')
|
|
134
|
+
const binName = 'lua-language-server' + suffix
|
|
135
|
+
// 新布局(3.x 平台变体 vsix):server/bin/xxx.exe;旧布局:server/bin/<平台>/xxx.exe
|
|
136
|
+
const bin = existsSync(join(binDir, binName)) ? join(binDir, binName) : join(binDir, plat, binName)
|
|
137
|
+
if (existsSync(bin) && !out.includes(bin)) out.push(bin)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** C# 自动发现候选:Roslyn dll(需 dotnet)或 OmniSharp 可执行。 */
|
|
144
|
+
export function candidateCSharpServers(home = dshHome()): { roslynDll?: string; omnisharpExe?: string; dotnet?: string } {
|
|
145
|
+
const out: { roslynDll?: string; omnisharpExe?: string; dotnet?: string } = {}
|
|
146
|
+
for (const dir of vscodeExtensionsDirs(home)) {
|
|
147
|
+
for (const extDir of listMatchingDirs(dir, 'ms-dotnettools.csharp-')) {
|
|
148
|
+
const roslyn = join(extDir, '.roslyn', 'Microsoft.CodeAnalysis.LanguageServer.dll')
|
|
149
|
+
if (!out.roslynDll && existsSync(roslyn)) out.roslynDll = roslyn
|
|
150
|
+
const omni = join(extDir, '.omnisharp', 'OmniSharp' + exeSuffix())
|
|
151
|
+
if (!out.omnisharpExe && existsSync(omni)) out.omnisharpExe = omni
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const dotnet = findDotnet()
|
|
155
|
+
if (dotnet) out.dotnet = dotnet
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 手动配置归一化:path 指向可执行文件/目录,command 为可执行名(PATH 解析)。
|
|
161
|
+
* 未配置时返回空 reason(由各 resolve 兜底输出语言相关安装指引文案)。
|
|
162
|
+
* @author ddj 2026年08月27号
|
|
163
|
+
*/
|
|
164
|
+
export function manualSpec(manual: { command?: string; path?: string } | undefined): { argv: string[]; cwd?: string; ready: boolean; reason?: string } {
|
|
165
|
+
if (!manual) return { argv: [], ready: false }
|
|
166
|
+
const pathValue = typeof manual.path === 'string' && manual.path.trim() ? manual.path.trim() : ''
|
|
167
|
+
const commandValue = typeof manual.command === 'string' && manual.command.trim() ? manual.command.trim() : ''
|
|
168
|
+
if (!pathValue && !commandValue) return { argv: [], ready: false }
|
|
169
|
+
if (pathValue) {
|
|
170
|
+
if (!existsSync(pathValue)) return { argv: [], ready: false, reason: '路径不存在:' + pathValue }
|
|
171
|
+
return { argv: [pathValue], ready: true }
|
|
172
|
+
}
|
|
173
|
+
const found = findInPath(commandValue)
|
|
174
|
+
if (!found) return { argv: [], ready: false, reason: 'PATH 中找不到命令:' + commandValue }
|
|
175
|
+
return { argv: [found], ready: true }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 解析 Lua provider(优先级:手动 > 扩展源 > 自动发现)。
|
|
180
|
+
* @author ddj 2026年08月27号
|
|
181
|
+
* @param manual 手动配置
|
|
182
|
+
* @param home DSH home(缺省真实;测试可注入临时目录)
|
|
183
|
+
* @returns provider 规格
|
|
184
|
+
*/
|
|
185
|
+
export function resolveLuaProvider(manual?: { command?: string; path?: string }, home = dshHome()): LspProviderSpec {
|
|
186
|
+
const base = { languageId: 'lua' }
|
|
187
|
+
const manualResolved = manualSpec(manual)
|
|
188
|
+
if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
|
|
189
|
+
const ext = extensionProviderFor('lua')
|
|
190
|
+
if (ext) return ext
|
|
191
|
+
const candidates = candidateLuaServers(home)
|
|
192
|
+
if (candidates.length) return { ...base, kind: 'discover', argv: [candidates[0]], cwd: undefined, ready: true }
|
|
193
|
+
const inPath = findInPath('lua-language-server' + exeSuffix())
|
|
194
|
+
if (inPath) return { ...base, kind: 'discover', argv: [inPath], ready: true }
|
|
195
|
+
return { ...base, kind: 'none', argv: [], ready: false, reason: manualResolved.reason ?? '未发现 lua-language-server(可安装 LuaLS 或手动指定路径)' }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 解析 C# provider(优先级:手动 > 扩展源 > 自动发现;需 dotnet + Roslyn dll;或 OmniSharp)。
|
|
200
|
+
* @author ddj 2026年08月27号
|
|
201
|
+
* @param manual 手动配置
|
|
202
|
+
* @param home DSH home(缺省真实;测试可注入临时目录)
|
|
203
|
+
* @returns provider 规格
|
|
204
|
+
*/
|
|
205
|
+
export function resolveCSharpProvider(manual?: { command?: string; path?: string }, home = dshHome()): LspProviderSpec {
|
|
206
|
+
const base = { languageId: 'csharp' }
|
|
207
|
+
const manualResolved = manualSpec(manual)
|
|
208
|
+
if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
|
|
209
|
+
const ext = extensionProviderFor('csharp')
|
|
210
|
+
if (ext) return ext
|
|
211
|
+
const discovered = candidateCSharpServers(home)
|
|
212
|
+
if (discovered.roslynDll && discovered.dotnet) {
|
|
213
|
+
return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], ready: true }
|
|
214
|
+
}
|
|
215
|
+
if (discovered.omnisharpExe) {
|
|
216
|
+
return { ...base, kind: 'discover', argv: [discovered.omnisharpExe, '--stdio'], ready: true }
|
|
217
|
+
}
|
|
218
|
+
const reasons: string[] = []
|
|
219
|
+
if (!discovered.dotnet) reasons.push('未找到 dotnet')
|
|
220
|
+
if (!discovered.roslynDll && !discovered.omnisharpExe) reasons.push('未发现 ms-dotnettools.csharp 扩展')
|
|
221
|
+
return { ...base, kind: 'none', argv: [], ready: false, reason: reasons.join(';') + '(可手动指定 Roslyn/OmniSharp 入口)' }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 语言 → provider 解析器(可扩展;Phase 2 由 extmgr 注册扩展源 provider)。 */
|
|
225
|
+
export type ProviderResolver = (manual?: { command?: string; path?: string }) => LspProviderSpec
|
|
226
|
+
|
|
227
|
+
export const PROVIDER_RESOLVERS: Record<string, ProviderResolver> = {
|
|
228
|
+
lua: resolveLuaProvider,
|
|
229
|
+
csharp: resolveCSharpProvider,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 手动配置是否为绝对路径可执行(供设置页判断输入类型)。 */
|
|
233
|
+
export function looksAbsolute(value: string): boolean {
|
|
234
|
+
return isAbsolute(value)
|
|
235
|
+
}
|