dsh-vscode-mode 0.1.35 → 0.1.42
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/lib/client.js +1117 -156
- package/lib/client.js.map +1 -1
- package/lib/index.js +447 -62
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/monaco/loader.ts +12 -1
- package/src/client/monaco/lsp/lspClient.ts +65 -11
- package/src/client/monaco/lsp/providers.ts +34 -1
- package/src/client/monaco/theme.ts +229 -0
- package/src/client/paths.ts +2 -0
- package/src/client/state/workspaceFoldCache.ts +95 -0
- package/src/client/styles/mcp.css +35 -12
- package/src/client/ui/EditorView.ts +21 -2
- package/src/client/ui/KeybindingsPanel.ts +8 -5
- package/src/client/ui/LspSettings.ts +53 -15
- package/src/client/ui/McpSettings.ts +110 -49
- package/src/client/ui/PerfSettings.ts +145 -34
- package/src/lsp/client.ts +29 -2
- package/src/lsp/manager.ts +15 -0
- package/src/lsp/providers.ts +203 -24
- package/src/lsp/rpc.ts +86 -4
- package/src/lsp/server.ts +80 -3
- package/src/paths.ts +7 -0
- package/src/rpc.ts +4 -1
- package/src/shared/lsp.ts +24 -0
- package/src/shared/rpc.ts +6 -2
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import React from 'react'
|
|
12
12
|
import { rpc } from '../rpc.js'
|
|
13
|
+
import { foldLoad, foldSave, normalizeFoldKey } from '../state/workspaceFoldCache.js'
|
|
13
14
|
import '../styles/mcp.css'
|
|
14
15
|
|
|
15
16
|
/** 字节数人类可读。 */
|
|
@@ -23,6 +24,39 @@ function formatBytes(n) {
|
|
|
23
24
|
/** 会话行 key:workspaceKey \u0000 sessionId。 */
|
|
24
25
|
const keyOf = (ws, id) => ws + '\u0000' + id
|
|
25
26
|
|
|
27
|
+
/** 工作区列表每页条数。 */
|
|
28
|
+
const PAGE_SIZE = 7
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 计算分页(纯函数,可单测)。
|
|
32
|
+
* 作者 ddj 2026年09月02号
|
|
33
|
+
* @param total 总条数
|
|
34
|
+
* @param pageSize 每页条数
|
|
35
|
+
* @param page 当前页码(1 起)
|
|
36
|
+
* @returns 归一化后的分页信息(page 被夹到有效范围)
|
|
37
|
+
*/
|
|
38
|
+
export function paginate(total, pageSize, page) {
|
|
39
|
+
const size = Math.max(1, Math.floor(pageSize) || 1)
|
|
40
|
+
const pages = Math.max(1, Math.ceil(total / size))
|
|
41
|
+
const current = Math.min(Math.max(1, Math.floor(page) || 1), pages)
|
|
42
|
+
const start = (current - 1) * size
|
|
43
|
+
return { page: current, pages, pageSize: size, start, end: Math.min(start + size, total), total }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 取当前页切片(纯函数,可单测)。
|
|
48
|
+
* 作者 ddj 2026年09月02号
|
|
49
|
+
* @param items 全量列表
|
|
50
|
+
* @param page 当前页码(1 起)
|
|
51
|
+
* @param pageSize 每页条数
|
|
52
|
+
* @returns 当前页子数组
|
|
53
|
+
*/
|
|
54
|
+
export function pageSlice(items, page, pageSize) {
|
|
55
|
+
const list = items ?? []
|
|
56
|
+
const { start, end } = paginate(list.length, pageSize, page)
|
|
57
|
+
return list.slice(start, end)
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/** 单行会话。 */
|
|
27
61
|
function SessionRow({ row, checked, disabled, onToggle }) {
|
|
28
62
|
const age = Math.max(0, (Date.now() - row.mtime) / (24 * 3600 * 1000))
|
|
@@ -36,6 +70,56 @@ function SessionRow({ row, checked, disabled, onToggle }) {
|
|
|
36
70
|
)
|
|
37
71
|
}
|
|
38
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 工作区列表分页器:顶部文案 + 上一页/下一页交互。
|
|
75
|
+
* 作者 ddj 2026年09月02号
|
|
76
|
+
* @param {object} info paginate() 的分页信息
|
|
77
|
+
* @param {Function} onPrev 上一页回调
|
|
78
|
+
* @param {Function} onNext 下一页回调
|
|
79
|
+
*/
|
|
80
|
+
function WorkspacePager({ info, onPrev, onNext }) {
|
|
81
|
+
const { page, pages, start, end, total } = info
|
|
82
|
+
return React.createElement('div', { className: 'vsm-perf-pager' },
|
|
83
|
+
React.createElement('span', { className: 'vsm-perf-pager-text' },
|
|
84
|
+
total ? `第 ${start + 1}–${end} 个 / 共 ${total} 个工作区(第 ${page}/${pages} 页)` : '暂无工作区',
|
|
85
|
+
),
|
|
86
|
+
React.createElement('div', { className: 'vsm-perf-pager-actions' },
|
|
87
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: onPrev, disabled: page <= 1 }, '‹ 上一页'),
|
|
88
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: onNext, disabled: page >= pages }, '下一页 ›'),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 工作区栏目(可展开/折叠),内含该工作区的会话行。
|
|
95
|
+
* 作者 ddj 2026年09月02号
|
|
96
|
+
* @param {object} ws 工作区汇总(workspaceKey / totalBytes)
|
|
97
|
+
* @param {object[]} rows 该工作区的会话行
|
|
98
|
+
* @param {boolean} expanded 是否展开
|
|
99
|
+
* @param {Function} onToggleFold 展开/折叠切换回调
|
|
100
|
+
* @param {object} selected 已选会话键集合
|
|
101
|
+
* @param {boolean} locked 会话勾选是否禁用(移出执行中)
|
|
102
|
+
* @param {Function} onToggleRow 单行勾选回调
|
|
103
|
+
*/
|
|
104
|
+
function WorkspaceBlock({ ws, rows, expanded, onToggleFold, selected, locked, onToggleRow }) {
|
|
105
|
+
const head = React.createElement('div', { className: 'vsm-perf-ws-head', role: 'button', tabIndex: 0, 'aria-expanded': expanded, onClick: onToggleFold, onKeyDown: (event) => {
|
|
106
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onToggleFold() }
|
|
107
|
+
} },
|
|
108
|
+
React.createElement('span', { className: 'vsm-perf-ws-caret ' + (expanded ? 'open' : '') }, '▸'),
|
|
109
|
+
React.createElement('span', { className: 'vsm-perf-ws-name', title: ws.workspaceKey }, ws.workspaceKey),
|
|
110
|
+
React.createElement('span', { className: 'vsm-perf-ws-meta' }, rows.length + ' 会话 · ' + formatBytes(ws.totalBytes)),
|
|
111
|
+
)
|
|
112
|
+
if (!expanded) return React.createElement('div', { className: 'vsm-perf-ws' }, head)
|
|
113
|
+
const body = rows.length ? rows.map((row) => React.createElement(SessionRow, {
|
|
114
|
+
key: keyOf(row.workspaceKey, row.sessionId),
|
|
115
|
+
row,
|
|
116
|
+
checked: !!selected[keyOf(row.workspaceKey, row.sessionId)],
|
|
117
|
+
disabled: row.active || locked,
|
|
118
|
+
onToggle: () => onToggleRow(keyOf(row.workspaceKey, row.sessionId)),
|
|
119
|
+
})) : React.createElement('div', { className: 'vsm-mcp-empty' }, '无会话')
|
|
120
|
+
return React.createElement('div', { className: 'vsm-perf-ws expanded' }, head, React.createElement('div', { className: 'vsm-perf-rows' }, body))
|
|
121
|
+
}
|
|
122
|
+
|
|
39
123
|
/**
|
|
40
124
|
* 会话卫生 + 压缩调优设置子页。
|
|
41
125
|
* @author ddj 2026年09月02号
|
|
@@ -48,6 +132,23 @@ export function PerfSettings() {
|
|
|
48
132
|
const [confirm, setConfirm] = React.useState(null)
|
|
49
133
|
const [moved, setMoved] = React.useState(null)
|
|
50
134
|
const [config, setConfig] = React.useState(null)
|
|
135
|
+
const [expanded, setExpanded] = React.useState(() => foldLoad())
|
|
136
|
+
const [page, setPage] = React.useState(1)
|
|
137
|
+
|
|
138
|
+
React.useEffect(() => { foldSave(expanded) }, [expanded])
|
|
139
|
+
|
|
140
|
+
/** 数据变化后把越界页码夹回有效范围。 */
|
|
141
|
+
const workspaces = inv?.workspaces ?? []
|
|
142
|
+
const pageInfo = paginate(workspaces.length, PAGE_SIZE, page)
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
if (page !== pageInfo.page) setPage(pageInfo.page)
|
|
145
|
+
}, [page, pageInfo.page])
|
|
146
|
+
|
|
147
|
+
/** 切换某工作区栏目的展开/折叠(默认全折叠)。 */
|
|
148
|
+
const toggleFold = (key) => setExpanded((old) => old.includes(key) ? old.filter((item) => item !== key) : old.concat(key))
|
|
149
|
+
|
|
150
|
+
/** 切换单会话勾选。 */
|
|
151
|
+
const toggleRow = (k) => setSelected((old) => { const next = { ...old }; if (next[k]) delete next[k]; else next[k] = true; return next })
|
|
51
152
|
|
|
52
153
|
const load = React.useCallback(() => {
|
|
53
154
|
setError('')
|
|
@@ -156,27 +257,25 @@ export function PerfSettings() {
|
|
|
156
257
|
React.createElement('div', { className: 'vsm-perf-card' }, React.createElement('span', null, '压缩态总体积'), React.createElement('b', { className: totals.totalBytes >= 50 * 1024 * 1024 ? 'big' : '' }, formatBytes(totals.totalBytes))),
|
|
157
258
|
) : null
|
|
158
259
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
React.createElement('span', { className: 'vsm-perf-ws-meta' }, rows.length + ' 会话 · ' + formatBytes(ws.totalBytes)),
|
|
165
|
-
),
|
|
166
|
-
React.createElement('div', { className: 'vsm-perf-rows' },
|
|
167
|
-
rows.length ? rows.map((row) => React.createElement(SessionRow, {
|
|
168
|
-
key: keyOf(row.workspaceKey, row.sessionId),
|
|
169
|
-
row,
|
|
170
|
-
checked: !!selected[keyOf(row.workspaceKey, row.sessionId)],
|
|
171
|
-
disabled: row.active || busy === 'move',
|
|
172
|
-
onToggle: () => {
|
|
173
|
-
const k = keyOf(row.workspaceKey, row.sessionId)
|
|
174
|
-
setSelected((old) => { const next = { ...old }; if (next[k]) delete next[k]; else next[k] = true; return next })
|
|
175
|
-
},
|
|
176
|
-
})) : React.createElement('div', { className: 'vsm-mcp-empty' }, '无会话'),
|
|
177
|
-
),
|
|
178
|
-
)
|
|
260
|
+
const visible = pageSlice(workspaces, pageInfo.page, PAGE_SIZE)
|
|
261
|
+
const workspacePager = workspaces.length > PAGE_SIZE ? React.createElement(WorkspacePager, {
|
|
262
|
+
info: pageInfo,
|
|
263
|
+
onPrev: () => setPage((old) => Math.max(1, old - 1)),
|
|
264
|
+
onNext: () => setPage((old) => Math.min(pageInfo.pages, old + 1)),
|
|
179
265
|
}) : null
|
|
266
|
+
const workspaceBlocks = workspaces.length ? React.createElement('div', { className: 'vsm-perf-ws-list' }, visible.map((ws) => {
|
|
267
|
+
const key = normalizeFoldKey(ws.workspaceKey)
|
|
268
|
+
return React.createElement(WorkspaceBlock, {
|
|
269
|
+
key: ws.workspaceKey,
|
|
270
|
+
ws,
|
|
271
|
+
rows: inv.sessions.filter((s) => s.workspaceKey === ws.workspaceKey),
|
|
272
|
+
expanded: expanded.includes(key),
|
|
273
|
+
onToggleFold: () => toggleFold(key),
|
|
274
|
+
selected,
|
|
275
|
+
locked: busy === 'move',
|
|
276
|
+
onToggleRow: toggleRow,
|
|
277
|
+
})
|
|
278
|
+
})) : null
|
|
180
279
|
|
|
181
280
|
const selCount = Object.keys(selected).length
|
|
182
281
|
const confirmBody = confirm ? React.createElement('div', { className: 'vsm-perf-modal' },
|
|
@@ -192,18 +291,21 @@ export function PerfSettings() {
|
|
|
192
291
|
),
|
|
193
292
|
) : null
|
|
194
293
|
|
|
195
|
-
const movedBody = moved?.moved?.length ? React.createElement('
|
|
196
|
-
React.createElement('h3',
|
|
294
|
+
const movedBody = moved?.moved?.length ? React.createElement('section', { className: 'vsm-panel' },
|
|
295
|
+
React.createElement('h3', { className: 'vsm-panel-title' }, '已移出 ' + moved.moved.length + ' 个会话(释放 ' + formatBytes(moved.reclaimedBytes) + ')'),
|
|
296
|
+
React.createElement('div', { className: 'vsm-panel-body' },
|
|
197
297
|
React.createElement('div', { className: 'vsm-perf-rows' }, moved.moved.map((item) => React.createElement('div', { key: item.workspaceKey + item.sessionId, className: 'vsm-perf-row' },
|
|
198
298
|
React.createElement('span', { className: 'vsm-perf-cell vsm-perf-id' }, item.sessionId),
|
|
199
299
|
React.createElement('span', { className: 'vsm-perf-cell vsm-perf-size' }, formatBytes(item.bytes)),
|
|
200
300
|
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: () => doRestore(item), disabled: busy === 'restore' }, '恢复'),
|
|
201
301
|
))),
|
|
202
302
|
moved.failures?.length ? React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, '失败:' + moved.failures.join(';')) : null,
|
|
303
|
+
),
|
|
203
304
|
) : null
|
|
204
305
|
|
|
205
|
-
const configBody = config ? React.createElement('
|
|
206
|
-
React.createElement('h3',
|
|
306
|
+
const configBody = config ? React.createElement('section', { className: 'vsm-panel' },
|
|
307
|
+
React.createElement('h3', { className: 'vsm-panel-title' }, 'DSH 内置压缩调优'),
|
|
308
|
+
React.createElement('div', { className: 'vsm-panel-body' },
|
|
207
309
|
React.createElement('p', null, '调低 compaction-basic / tool-result-pruner 触发阈值,让长循环会话更早压缩,降低模型可见上下文压力(减少 token 与防 context overflow)。'),
|
|
208
310
|
React.createElement('p', { className: 'vsm-perf-note' }, '注意:压缩只追加 compaction 事件,不重写已持久化会话日志(append-only),因此不会缩小已存在的会话文件——存量瘦身请用上方「移出到归档」。packChunks 已默认开启,无需配置。'),
|
|
209
311
|
React.createElement('div', { className: 'vsm-perf-config-state' },
|
|
@@ -211,13 +313,16 @@ export function PerfSettings() {
|
|
|
211
313
|
config.applied ? React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: undoConfig, disabled: busy === 'undo' }, '撤销') : React.createElement('button', { className: 'vsm-primary vsm-small', onClick: applyConfig, disabled: busy === 'apply' }, '应用建议配置(需重启)'),
|
|
212
314
|
),
|
|
213
315
|
React.createElement('pre', { className: 'vsm-perf-block' }, config.block),
|
|
316
|
+
),
|
|
214
317
|
) : null
|
|
215
318
|
|
|
216
|
-
const guideBody = React.createElement('
|
|
217
|
-
React.createElement('h3',
|
|
319
|
+
const guideBody = React.createElement('section', { className: 'vsm-panel' },
|
|
320
|
+
React.createElement('h3', { className: 'vsm-panel-title' }, '侧车摘要(agent/脚本取关键字段,不整份读 sidecar)'),
|
|
321
|
+
React.createElement('div', { className: 'vsm-panel-body' },
|
|
218
322
|
React.createElement('p', null, '工作区根目录的 .dsh-edit-review.json(可能数 MB)只应通过摘要接口读取,禁止 agent 整份 read/write 进对话:'),
|
|
219
323
|
React.createElement('pre', { className: 'vsm-perf-block' },
|
|
220
324
|
"curl -s -X POST http://127.0.0.1:3080/edrv/rpc -H 'content-type: application/json' -d '{\"method\":\"edrv.perf.sidecarSummary\",\"args\":{}}'\n# 差异记录列表(含每文件 pending 摘要):edrv.list\n# 历史归档:edrv.archiveList / edrv.archiveRead"),
|
|
325
|
+
),
|
|
221
326
|
)
|
|
222
327
|
|
|
223
328
|
return React.createElement('section', { className: 'vsm-perf-page' },
|
|
@@ -225,15 +330,21 @@ export function PerfSettings() {
|
|
|
225
330
|
React.createElement('p', null, 'DSH 启动会回放 ~/.dsh/sessions 全部会话(V8 展开约 10×),体积越大内存越吃紧。定期把巨型/旧会话移出到归档可显著降低启动内存。'),
|
|
226
331
|
error ? React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error) : null,
|
|
227
332
|
summaryCards,
|
|
228
|
-
React.createElement('
|
|
229
|
-
React.createElement('
|
|
230
|
-
React.createElement('
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
333
|
+
React.createElement('section', { className: 'vsm-panel' },
|
|
334
|
+
React.createElement('h3', { className: 'vsm-panel-title' }, '会话盘点'),
|
|
335
|
+
React.createElement('div', { className: 'vsm-panel-body' },
|
|
336
|
+
React.createElement('div', { className: 'vsm-perf-toolbar' },
|
|
337
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: selectBig }, '圈选 ≥1MB 非活跃'),
|
|
338
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: clearSel, disabled: !selCount }, '清空选择'),
|
|
339
|
+
React.createElement('span', { className: 'vsm-perf-selcount' }, '已选 ' + selCount + ' 个'),
|
|
340
|
+
React.createElement('button', { className: 'vsm-primary vsm-small', onClick: planMove, disabled: !selCount || busy === 'plan' }, busy === 'plan' ? '规划中…' : '移出所选'),
|
|
341
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: doPurge, disabled: busy === 'purge' }, '清除归档(>30天)'),
|
|
342
|
+
React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: load, disabled: busy === 'load' }, '⟳ 刷新'),
|
|
343
|
+
),
|
|
344
|
+
workspacePager,
|
|
345
|
+
workspaceBlocks,
|
|
346
|
+
),
|
|
235
347
|
),
|
|
236
|
-
workspaceBlocks,
|
|
237
348
|
movedBody,
|
|
238
349
|
configBody,
|
|
239
350
|
guideBody,
|
package/src/lsp/client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 作者 ddj 2026-08-27
|
|
6
6
|
*/
|
|
7
7
|
import { createFrameParser, encodeMessage, requestMessage, notifyMessage } from './jsonrpc.js'
|
|
8
|
-
import type
|
|
8
|
+
import { LSP_SEMANTIC_TOKEN_TYPES, LSP_SEMANTIC_TOKEN_MODIFIERS, type LspServerCapabilities } from '../shared/lsp.js'
|
|
9
9
|
|
|
10
10
|
export interface LspClientEvents {
|
|
11
11
|
onReady?: (capabilities: LspServerCapabilities) => void
|
|
@@ -54,7 +54,16 @@ export function createLspClient(
|
|
|
54
54
|
let nextId = 1
|
|
55
55
|
let alive = transport.alive
|
|
56
56
|
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>()
|
|
57
|
-
let serverCapabilities: LspServerCapabilities = {
|
|
57
|
+
let serverCapabilities: LspServerCapabilities = {
|
|
58
|
+
definition: false,
|
|
59
|
+
references: false,
|
|
60
|
+
documentSymbol: false,
|
|
61
|
+
workspaceSymbol: false,
|
|
62
|
+
hover: false,
|
|
63
|
+
semanticTokens: false,
|
|
64
|
+
semanticTokenTypes: [],
|
|
65
|
+
semanticTokenModifiers: [],
|
|
66
|
+
}
|
|
58
67
|
|
|
59
68
|
const log = (line: string): void => events.onLog?.(line)
|
|
60
69
|
const parser = createFrameParser()
|
|
@@ -153,6 +162,9 @@ export function createLspClient(
|
|
|
153
162
|
documentSymbolProvider?: unknown
|
|
154
163
|
workspaceSymbolProvider?: unknown
|
|
155
164
|
hoverProvider?: unknown
|
|
165
|
+
semanticTokensProvider?: {
|
|
166
|
+
legend?: { tokenTypes?: unknown; tokenModifiers?: unknown }
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
}>('initialize', {
|
|
158
170
|
processId: process.pid,
|
|
@@ -170,16 +182,31 @@ export function createLspClient(
|
|
|
170
182
|
references: { dynamicRegistration: false },
|
|
171
183
|
documentSymbol: { dynamicRegistration: false },
|
|
172
184
|
hover: { dynamicRegistration: false },
|
|
185
|
+
semanticTokens: {
|
|
186
|
+
dynamicRegistration: false,
|
|
187
|
+
requests: { range: false, full: { delta: false } },
|
|
188
|
+
tokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES],
|
|
189
|
+
tokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS],
|
|
190
|
+
formats: ['relative'],
|
|
191
|
+
},
|
|
173
192
|
},
|
|
174
193
|
},
|
|
175
194
|
})
|
|
176
195
|
const caps = init?.capabilities ?? {}
|
|
196
|
+
const legend = caps.semanticTokensProvider?.legend
|
|
177
197
|
serverCapabilities = {
|
|
178
198
|
definition: Boolean(caps.definitionProvider),
|
|
179
199
|
references: Boolean(caps.referencesProvider),
|
|
180
200
|
documentSymbol: Boolean(caps.documentSymbolProvider),
|
|
181
201
|
workspaceSymbol: Boolean(caps.workspaceSymbolProvider),
|
|
182
202
|
hover: Boolean(caps.hoverProvider),
|
|
203
|
+
semanticTokens: Boolean(caps.semanticTokensProvider),
|
|
204
|
+
semanticTokenTypes: Array.isArray(legend?.tokenTypes)
|
|
205
|
+
? legend.tokenTypes.filter((item): item is string => typeof item === 'string')
|
|
206
|
+
: [...LSP_SEMANTIC_TOKEN_TYPES],
|
|
207
|
+
semanticTokenModifiers: Array.isArray(legend?.tokenModifiers)
|
|
208
|
+
? legend.tokenModifiers.filter((item): item is string => typeof item === 'string')
|
|
209
|
+
: [...LSP_SEMANTIC_TOKEN_MODIFIERS],
|
|
183
210
|
}
|
|
184
211
|
client.notify('initialized', {})
|
|
185
212
|
events.onReady?.(serverCapabilities)
|
package/src/lsp/manager.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface LspManager {
|
|
|
25
25
|
release(root: string, languageId: string): void
|
|
26
26
|
/** 释放工作区下全部语言(session/disposed 用)。 */
|
|
27
27
|
releaseRoot(root: string): void
|
|
28
|
+
/** 重置单个工作区语言 server(重新检测 provider 用)。 */
|
|
29
|
+
reset(root: string, languageId: string): void
|
|
30
|
+
/** 重置全部工作区的指定语言 server。 */
|
|
31
|
+
resetLanguage(languageId: string): void
|
|
28
32
|
/** 状态快照。 */
|
|
29
33
|
statusAll(): LspServerStatus[]
|
|
30
34
|
/** 查询已注册的工作区+语言服务器,不增加引用计数。 */
|
|
@@ -110,6 +114,17 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
|
|
|
110
114
|
}
|
|
111
115
|
},
|
|
112
116
|
|
|
117
|
+
reset(root: string, languageId: string): void {
|
|
118
|
+
const entry = entries.get(keyOf(root, languageId))
|
|
119
|
+
if (entry) dropEntry(entry)
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
resetLanguage(languageId: string): void {
|
|
123
|
+
for (const entry of [...entries.values()]) {
|
|
124
|
+
if (entry.server.languageId === languageId) dropEntry(entry)
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
113
128
|
statusAll(): LspServerStatus[] {
|
|
114
129
|
return [...entries.values()].map((entry) => entry.server.status())
|
|
115
130
|
},
|
package/src/lsp/providers.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* 供 manager 启动服务器。
|
|
6
6
|
* 作者 ddj 2026-08-27
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync, readdirSync, type Dirent } from 'node:fs'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, type Dirent } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
|
-
import { join, sep } from 'node:path'
|
|
10
|
+
import { dirname, join, sep } from 'node:path'
|
|
11
11
|
import { isAbsolute } from 'node:path'
|
|
12
12
|
import { extensionsRoot } from './extmgr.js'
|
|
13
|
-
import { dshHome } from '../paths.js'
|
|
13
|
+
import { dshHome, lspSpecCacheFile } from '../paths.js'
|
|
14
14
|
|
|
15
15
|
/** provider 来源标记(与 shared/lsp.ts 的 status.source 一致)。 */
|
|
16
16
|
export type LspProviderKind = 'extension' | 'discover' | 'manual' | 'none'
|
|
@@ -22,6 +22,8 @@ export interface LspProviderSpec {
|
|
|
22
22
|
cwd?: string
|
|
23
23
|
ready: boolean
|
|
24
24
|
reason?: string
|
|
25
|
+
version?: string
|
|
26
|
+
providerName?: string
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** 语言 → 扩展名(用于判断哪些文件触发该语言服务器)。 */
|
|
@@ -88,7 +90,7 @@ export function registerExtensionProvider(languageId: string, argv: string[], cw
|
|
|
88
90
|
export function extensionProviderFor(languageId: string): LspProviderSpec | null {
|
|
89
91
|
const p = extProviders.get(languageId)
|
|
90
92
|
if (!p || !p.argv.length) return null
|
|
91
|
-
return { languageId, kind: 'extension', argv: p.argv, cwd: p.cwd, ready: true }
|
|
93
|
+
return { languageId, kind: 'extension', argv: p.argv, cwd: p.cwd, ready: true, version: p.version, providerName: p.version ? 'EmmyLua' : '语言服务器扩展' }
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/** 清空全部扩展源注册(插件卸载/扩展目录重建时)。 */
|
|
@@ -123,6 +125,25 @@ export function findDotnet(): string | null {
|
|
|
123
125
|
return null
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/** EmmyLua 自动发现候选(tangzx.emmylua-* / server/emmylua_ls)。 */
|
|
129
|
+
export function candidateEmmyLua(home = dshHome()): { path: string; version?: string } | null {
|
|
130
|
+
const candidates: { path: string; version?: string }[] = []
|
|
131
|
+
for (const dir of vscodeExtensionsDirs(home)) {
|
|
132
|
+
for (const extDir of listMatchingDirs(dir, 'tangzx.emmylua-')) {
|
|
133
|
+
const server = join(extDir, 'server', 'emmylua_ls' + exeSuffix())
|
|
134
|
+
if (!existsSync(server)) continue
|
|
135
|
+
let version: string | undefined
|
|
136
|
+
try {
|
|
137
|
+
const manifest = JSON.parse(readFileSync(join(extDir, 'package.json'), 'utf8')) as { version?: unknown }
|
|
138
|
+
if (typeof manifest.version === 'string') version = manifest.version
|
|
139
|
+
} catch (error) { /* 清单读取失败不影响入口使用 */ }
|
|
140
|
+
candidates.push({ path: server, version })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
candidates.sort((a, b) => (b.version ?? '').localeCompare(a.version ?? '', undefined, { numeric: true }) || a.path.localeCompare(b.path))
|
|
144
|
+
return candidates[0] ?? null
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
/** LuaLS 自动发现候选(VSCode 扩展目录 + extmgr 目录下 sumneko.lua-* 的 server/bin 可执行)。 */
|
|
127
148
|
export function candidateLuaServers(home = dshHome()): string[] {
|
|
128
149
|
const plat = platformServerDir()
|
|
@@ -186,13 +207,17 @@ export function resolveLuaProvider(manual?: { command?: string; path?: string },
|
|
|
186
207
|
const base = { languageId: 'lua' }
|
|
187
208
|
const manualResolved = manualSpec(manual)
|
|
188
209
|
if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
210
|
+
return resolveCached('lua', manual, home, (h) => {
|
|
211
|
+
const ext = extensionProviderFor('lua')
|
|
212
|
+
if (ext) return ext
|
|
213
|
+
const emmy = candidateEmmyLua(h)
|
|
214
|
+
if (emmy) return { ...base, kind: 'extension', argv: [emmy.path], ready: true, version: emmy.version, providerName: 'EmmyLua' }
|
|
215
|
+
const candidates = candidateLuaServers(h)
|
|
216
|
+
if (candidates.length) return { ...base, kind: 'discover', argv: [candidates[0]], cwd: undefined, ready: true }
|
|
217
|
+
const inPath = findInPath('lua-language-server' + exeSuffix())
|
|
218
|
+
if (inPath) return { ...base, kind: 'discover', argv: [inPath], ready: true }
|
|
219
|
+
return { ...base, kind: 'none', argv: [], ready: false, reason: manualResolved.reason ?? '未发现 lua-language-server(可安装 LuaLS 或手动指定路径)' }
|
|
220
|
+
})
|
|
196
221
|
}
|
|
197
222
|
|
|
198
223
|
/**
|
|
@@ -206,19 +231,21 @@ export function resolveCSharpProvider(manual?: { command?: string; path?: string
|
|
|
206
231
|
const base = { languageId: 'csharp' }
|
|
207
232
|
const manualResolved = manualSpec(manual)
|
|
208
233
|
if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
234
|
+
return resolveCached('csharp', manual, home, (h) => {
|
|
235
|
+
const ext = extensionProviderFor('csharp')
|
|
236
|
+
if (ext) return ext
|
|
237
|
+
const discovered = candidateCSharpServers(h)
|
|
238
|
+
if (discovered.roslynDll && discovered.dotnet) {
|
|
239
|
+
return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], ready: true }
|
|
240
|
+
}
|
|
241
|
+
if (discovered.omnisharpExe) {
|
|
242
|
+
return { ...base, kind: 'discover', argv: [discovered.omnisharpExe, '--stdio'], ready: true }
|
|
243
|
+
}
|
|
244
|
+
const reasons: string[] = []
|
|
245
|
+
if (!discovered.dotnet) reasons.push('未找到 dotnet')
|
|
246
|
+
if (!discovered.roslynDll && !discovered.omnisharpExe) reasons.push('未发现 ms-dotnettools.csharp 扩展')
|
|
247
|
+
return { ...base, kind: 'none', argv: [], ready: false, reason: reasons.join(';') + '(可手动指定 Roslyn/OmniSharp 入口)' }
|
|
248
|
+
})
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
/** 语言 → provider 解析器(可扩展;Phase 2 由 extmgr 注册扩展源 provider)。 */
|
|
@@ -233,3 +260,155 @@ export const PROVIDER_RESOLVERS: Record<string, ProviderResolver> = {
|
|
|
233
260
|
export function looksAbsolute(value: string): boolean {
|
|
234
261
|
return isAbsolute(value)
|
|
235
262
|
}
|
|
263
|
+
|
|
264
|
+
// --region provider 发现结果缓存(进程级 + 磁盘持久化)
|
|
265
|
+
/**
|
|
266
|
+
* 发现结果缓存:只缓存"自动发现/扩展源"结论,不缓存 manual(用户改配置要立即生效)。
|
|
267
|
+
* 进程内 Map 命中优先;未命中读磁盘缓存(跨会话/跨重启共享);仍无才真扫描。
|
|
268
|
+
* 缓存条目按"配置签名 + 扩展目录指纹"校验,任一变化即失效重扫。
|
|
269
|
+
* 作者 ddj 2026年08月28号
|
|
270
|
+
*/
|
|
271
|
+
|
|
272
|
+
/** 缓存条目:spec + 生成时的环境指纹。 */
|
|
273
|
+
interface SpecCacheEntry {
|
|
274
|
+
spec: LspProviderSpec
|
|
275
|
+
fingerprint: string
|
|
276
|
+
at: number
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 进程级缓存(home|languageId → 条目),同一 DSH home 的不同会话共享。 */
|
|
280
|
+
const specCache = new Map<string, SpecCacheEntry>()
|
|
281
|
+
/** 磁盘缓存是否已载入本进程(home → loaded),不同 profile 不串缓存。 */
|
|
282
|
+
const diskLoaded = new Set<string>()
|
|
283
|
+
/** 自动发现成功结果的复用时长;过期后才检查目录指纹。 */
|
|
284
|
+
const SPEC_CACHE_TTL_MS = 30 * 60 * 1000
|
|
285
|
+
/** 未发现结果的短缓存时长,避免缺失服务器时反复扫描 PATH。 */
|
|
286
|
+
const SPEC_NEGATIVE_CACHE_MS = 60 * 1000
|
|
287
|
+
|
|
288
|
+
/** 生成带 home 的缓存键,避免不同 profile 的 provider 结果串用。 */
|
|
289
|
+
function cacheEntryKey(home: string, languageId: string): string {
|
|
290
|
+
return home + '|' + languageId
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 构造缓存 key:语言 + 手动配置(manual 结果不缓存,故只参与"是否走缓存"判定)。
|
|
295
|
+
* @author ddj 2026年08月28号
|
|
296
|
+
* @param languageId 语言 id
|
|
297
|
+
* @param manual 手动配置
|
|
298
|
+
* @returns 缓存 key(manual 有效时返回空串表示跳过缓存)
|
|
299
|
+
*/
|
|
300
|
+
function cacheKeyOf(languageId: string, manual?: { command?: string; path?: string }): string {
|
|
301
|
+
const pathValue = manual?.path?.trim() ?? ''
|
|
302
|
+
const commandValue = manual?.command?.trim() ?? ''
|
|
303
|
+
if (pathValue || commandValue) return '' // 手动配置:不缓存,立即生效
|
|
304
|
+
return languageId
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 环境指纹:扩展目录清单 + 平台。目录增删/换平台即失效(防缓存陈旧指向已删扩展)。
|
|
309
|
+
* @author ddj 2026年08月28号
|
|
310
|
+
* @param home DSH home
|
|
311
|
+
* @returns 指纹串
|
|
312
|
+
*/
|
|
313
|
+
function fingerprintOf(home: string): string {
|
|
314
|
+
const dirs = vscodeExtensionsDirs(home)
|
|
315
|
+
const marks = dirs.map((dir) => {
|
|
316
|
+
try {
|
|
317
|
+
return readdirSync(dir).sort().join(',')
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return '?'
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
return process.platform + '|' + marks.join('|')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 读取磁盘缓存(幂等:每进程只载一次;schema 文件名已隔离版本)。
|
|
327
|
+
* @author ddj 2026年08月28号
|
|
328
|
+
* @param home DSH home
|
|
329
|
+
*/
|
|
330
|
+
function loadDiskCache(home: string): void {
|
|
331
|
+
if (diskLoaded.has(home)) return
|
|
332
|
+
diskLoaded.add(home)
|
|
333
|
+
const file = lspSpecCacheFile(home)
|
|
334
|
+
try {
|
|
335
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, SpecCacheEntry>
|
|
336
|
+
for (const [languageId, entry] of Object.entries(raw)) {
|
|
337
|
+
if (entry && typeof entry === 'object' && entry.spec && typeof entry.fingerprint === 'string') {
|
|
338
|
+
specCache.set(cacheEntryKey(home, languageId), entry)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch (error) { /* 首次运行/损坏:静默降级为真扫描 */ }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 写回磁盘缓存(best-effort;写失败不影响本次解析结果)。
|
|
346
|
+
* @author ddj 2026年08月28号
|
|
347
|
+
* @param home DSH home
|
|
348
|
+
*/
|
|
349
|
+
function saveDiskCache(home: string): void {
|
|
350
|
+
const file = lspSpecCacheFile(home)
|
|
351
|
+
try {
|
|
352
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
353
|
+
const out: Record<string, SpecCacheEntry> = {}
|
|
354
|
+
const prefix = home + '|'
|
|
355
|
+
for (const [key, entry] of specCache) if (key.startsWith(prefix)) out[key.slice(prefix.length)] = entry
|
|
356
|
+
writeFileSync(file, JSON.stringify(out), 'utf8')
|
|
357
|
+
} catch (error) { /* 写入失败静默 */ }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 带缓存执行 provider 解析(自动发现路径专用)。
|
|
362
|
+
* @author ddj 2026年08月28号
|
|
363
|
+
* @param languageId 语言 id
|
|
364
|
+
* @param manual 手动配置
|
|
365
|
+
* @param home DSH home
|
|
366
|
+
* @param resolve 真解析函数(未命中时调用)
|
|
367
|
+
* @returns provider 规格
|
|
368
|
+
*/
|
|
369
|
+
function resolveCached(
|
|
370
|
+
languageId: string,
|
|
371
|
+
manual: { command?: string; path?: string } | undefined,
|
|
372
|
+
home: string,
|
|
373
|
+
resolve: (h: string) => LspProviderSpec,
|
|
374
|
+
): LspProviderSpec {
|
|
375
|
+
const key = cacheKeyOf(languageId, manual)
|
|
376
|
+
if (!key) return resolve(home) // 手动配置:不走缓存
|
|
377
|
+
loadDiskCache(home)
|
|
378
|
+
const fullKey = cacheEntryKey(home, key)
|
|
379
|
+
const hit = specCache.get(fullKey)
|
|
380
|
+
if (hit && isCacheFresh(hit)) return hit.spec
|
|
381
|
+
const fingerprint = fingerprintOf(home)
|
|
382
|
+
if (hit && hit.fingerprint === fingerprint) {
|
|
383
|
+
hit.at = Date.now()
|
|
384
|
+
return hit.spec
|
|
385
|
+
}
|
|
386
|
+
const spec = resolve(home)
|
|
387
|
+
specCache.set(fullKey, { spec, fingerprint, at: Date.now() })
|
|
388
|
+
saveDiskCache(home)
|
|
389
|
+
return spec
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 判断缓存条目是否仍可直接复用,不触发扩展目录扫描。 */
|
|
393
|
+
function isCacheFresh(entry: SpecCacheEntry): boolean {
|
|
394
|
+
const age = Date.now() - entry.at
|
|
395
|
+
const ttl = entry.spec.kind === 'none' ? SPEC_NEGATIVE_CACHE_MS : SPEC_CACHE_TTL_MS
|
|
396
|
+
if (age < 0 || age >= ttl) return false
|
|
397
|
+
const executable = entry.spec.argv[0]
|
|
398
|
+
return !entry.spec.ready || !executable || existsSync(executable)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 清空 provider 发现缓存(扩展安装/卸载、配置变更后调用;进程级 + 删磁盘文件)。
|
|
403
|
+
* @author ddj 2026年08月28号
|
|
404
|
+
* @param home DSH home(缺省真实)
|
|
405
|
+
*/
|
|
406
|
+
export function clearProviderCache(home = dshHome()): void {
|
|
407
|
+
const prefix = home + '|'
|
|
408
|
+
for (const key of specCache.keys()) if (key.startsWith(prefix)) specCache.delete(key)
|
|
409
|
+
diskLoaded.delete(home)
|
|
410
|
+
try {
|
|
411
|
+
rmSync(lspSpecCacheFile(home), { force: true })
|
|
412
|
+
} catch (error) { /* 删除失败静默 */ }
|
|
413
|
+
}
|
|
414
|
+
// --endregion
|