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.
@@ -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 workspaceBlocks = inv?.workspaces ? inv.workspaces.map((ws) => {
160
- const rows = inv.sessions.filter((s) => s.workspaceKey === ws.workspaceKey)
161
- return React.createElement('div', { key: ws.workspaceKey, className: 'vsm-perf-ws' },
162
- React.createElement('div', { className: 'vsm-perf-ws-head' },
163
- React.createElement('span', { className: 'vsm-perf-ws-name', title: ws.workspaceKey }, ws.workspaceKey),
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('div', { className: 'vsm-perf-moved' },
196
- React.createElement('h3', null, '已移出 ' + moved.moved.length + ' 个会话(释放 ' + formatBytes(moved.reclaimedBytes) + ')'),
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('div', { className: 'vsm-perf-config' },
206
- React.createElement('h3', null, 'DSH 内置压缩调优'),
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('div', { className: 'vsm-perf-guide' },
217
- React.createElement('h3', null, '侧车摘要(agent/脚本取关键字段,不整份读 sidecar)'),
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('div', { className: 'vsm-perf-toolbar' },
229
- React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: selectBig }, '圈选 ≥1MB 非活跃'),
230
- React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: clearSel, disabled: !selCount }, '清空选择'),
231
- React.createElement('span', { className: 'vsm-perf-selcount' }, '已选 ' + selCount + ' 个'),
232
- React.createElement('button', { className: 'vsm-primary vsm-small', onClick: planMove, disabled: !selCount || busy === 'plan' }, busy === 'plan' ? '规划中…' : '移出所选'),
233
- React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: doPurge, disabled: busy === 'purge' }, '清除归档(>30天)'),
234
- React.createElement('button', { className: 'vsm-secondary vsm-small', onClick: load, disabled: busy === 'load' }, '⟳ 刷新'),
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 { LspServerCapabilities } from '../shared/lsp.js'
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 = { definition: false, references: false, documentSymbol: false, workspaceSymbol: false, hover: false }
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)
@@ -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
  },
@@ -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
- 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 或手动指定路径)' }
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
- 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 入口)' }
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