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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/lib/client.js +1229 -124
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1959 -52
  5. package/lib/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/client/events.ts +11 -0
  8. package/src/client/index.ts +15 -2
  9. package/src/client/monaco/lsp/index.ts +30 -0
  10. package/src/client/monaco/lsp/lspClient.ts +192 -0
  11. package/src/client/monaco/lsp/providers.ts +229 -0
  12. package/src/client/outline/OutlinePanel.tsx +1 -1
  13. package/src/client/outline/lspSource.ts +49 -0
  14. package/src/client/outline/parse.ts +31 -1
  15. package/src/client/outline/types.ts +2 -0
  16. package/src/client/paths.ts +2 -0
  17. package/src/client/state/workspaceFoldCache.ts +95 -0
  18. package/src/client/styles/editor.css +15 -0
  19. package/src/client/styles/mcp.css +50 -12
  20. package/src/client/ui/EditorView.ts +55 -1
  21. package/src/client/ui/KeybindingsPanel.ts +8 -5
  22. package/src/client/ui/LspSettings.ts +274 -0
  23. package/src/client/ui/McpSettings.ts +112 -49
  24. package/src/client/ui/PerfSettings.ts +145 -34
  25. package/src/fileOpenSettings.ts +1 -0
  26. package/src/index.ts +20 -2
  27. package/src/lsp/client.ts +205 -0
  28. package/src/lsp/config.ts +107 -0
  29. package/src/lsp/extmgr.ts +208 -0
  30. package/src/lsp/jsonrpc.ts +86 -0
  31. package/src/lsp/manager.ts +133 -0
  32. package/src/lsp/providers.ts +235 -0
  33. package/src/lsp/rpc.ts +334 -0
  34. package/src/lsp/server.ts +346 -0
  35. package/src/lsp/settings.ts +44 -0
  36. package/src/lsp/transport.ts +143 -0
  37. package/src/lsp/uri.ts +62 -0
  38. package/src/lsp/zip.ts +82 -0
  39. package/src/rpc.ts +11 -2
  40. package/src/shared/lsp.ts +138 -0
  41. package/src/shared/rpc.ts +33 -0
@@ -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,
@@ -24,6 +24,7 @@ export interface SettingsDeps {
24
24
  z: {
25
25
  object: (shape: Record<string, unknown>) => { default: (value: Record<string, unknown>) => unknown }
26
26
  string: () => { default: (value: string) => unknown }
27
+ boolean: () => { default: (value: boolean) => unknown }
27
28
  }
28
29
  }
29
30
 
package/src/index.ts CHANGED
@@ -18,6 +18,11 @@ import { setupOpenSettings } from './fileOpenSettings.js'
18
18
  import { disposeIndex } from './treeIndex.js'
19
19
  import { sweepTreeCache } from './paths.js'
20
20
  import { PLUGIN_NAME, buildReport } from './compat.js'
21
+ import { createLspManager } from './lsp/manager.js'
22
+ import { createLspRpc } from './lsp/rpc.js'
23
+ import { installLspSettingsSection } from './lsp/settings.js'
24
+ import { disposeAllServers } from './lsp/transport.js'
25
+ import type { RpcHandlerMap } from './shared/rpc.js'
21
26
  import type { Registry } from './registry.js'
22
27
  import type { Ctx } from './store.js'
23
28
 
@@ -34,9 +39,15 @@ export function apply(ctx: Ctx, config?: unknown): void {
34
39
  const registry: Registry = new Map()
35
40
  const searcher = newSearcher(ctx)
36
41
  const contentSearcher = newContentSearcher(ctx)
42
+ /** LSP 服务器管理器(语言智能:跳转/引用/大纲)。 */
43
+ const lspManager = createLspManager((line) => ctx.logger?.debug?.('[' + name + '] ' + line))
37
44
  /** 兼容性警告收集(route 护栏等写入,启动日志一并输出)。 */
38
45
  const warnings: string[] = []
39
46
  setupOpenSettings(ctx, config, () => {})
47
+ void installLspSettingsSection(ctx, {})
48
+ /** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
49
+ const lspRpc = createLspRpc({ ctx, pluginConfig: config, manager: lspManager })
50
+ const lspHandlers: Partial<RpcHandlerMap> = lspRpc.handlers
40
51
 
41
52
  ctx.on('tools/result', (exec: unknown, result: unknown) => {
42
53
  void captureToolResult(ctx, registry, exec, result)
@@ -51,15 +62,22 @@ export function apply(ctx: Ctx, config?: unknown): void {
51
62
  contentSearcher.dispose(cwd)
52
63
  disposeIndex(cwd)
53
64
  }
65
+ const sid = (session as { id?: unknown })?.id
66
+ if (typeof sid === 'string') lspRpc.disposeSession(sid)
54
67
  })
55
68
 
56
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher), (warning) => warnings.push(warning))
69
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers), (warning) => warnings.push(warning))
57
70
  installIsolation(ctx)
58
71
  // 启动清理缓存目录:非当前 schema / 超保留期 / 未知残留(best-effort,不阻塞装配)
59
72
  void sweepTreeCache()
60
73
  void logCompatSummary(ctx, warnings)
74
+ // 卸载/重启时强杀 LSP 子进程(防残留)
75
+ ctx.effect(() => () => {
76
+ void lspManager.disposeAll().catch(() => {})
77
+ disposeAllServers()
78
+ })
61
79
 
62
- ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)')
80
+ ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入)')
63
81
  }
64
82
 
65
83
  /**
@@ -0,0 +1,205 @@
1
+ /**
2
+ * dsh-vscode-mode host — LSP 客户端(JSON-RPC over stdio)。
3
+ * 职责:请求/通知/握手/超时/服务器端请求处理/生命周期。transport 无关性:
4
+ * 只依赖 write/onMessage 契约,便于单测时注入假传输。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { createFrameParser, encodeMessage, requestMessage, notifyMessage } from './jsonrpc.js'
8
+ import type { LspServerCapabilities } from '../shared/lsp.js'
9
+
10
+ export interface LspClientEvents {
11
+ onReady?: (capabilities: LspServerCapabilities) => void
12
+ onExit?: (code: number | null, signal: string | null) => void
13
+ onLog?: (line: string) => void
14
+ onProgress?: (params: unknown) => void
15
+ }
16
+
17
+ const DEFAULT_TIMEOUT_MS = 20_000
18
+
19
+ export class LspError extends Error {
20
+ code: number
21
+ constructor(code: number, message: string) {
22
+ super(message)
23
+ this.code = code
24
+ }
25
+ }
26
+
27
+ export interface LspClientTransport {
28
+ write(chunk: Buffer): boolean
29
+ dispose(): void
30
+ readonly alive: boolean
31
+ onMessage: ((message: unknown) => void) | null
32
+ onExit: ((code: number | null, signal: string | null) => void) | null
33
+ }
34
+
35
+ export interface LspClient {
36
+ request<T>(method: string, params: unknown, timeoutMs?: number): Promise<T>
37
+ notify(method: string, params: unknown): void
38
+ initialize(rootUri: string, workspaceFolders: string[]): Promise<LspServerCapabilities>
39
+ shutdown(): Promise<void>
40
+ readonly alive: boolean
41
+ }
42
+
43
+ /**
44
+ * 创建 LSP 客户端。
45
+ * @author ddj 2026年08月27号
46
+ * @param transport 传输句柄(write/onMessage/alive/dispose)
47
+ * @param events 事件回调
48
+ * @returns LSP 客户端
49
+ */
50
+ export function createLspClient(
51
+ transport: LspClientTransport,
52
+ events: LspClientEvents = {},
53
+ ): LspClient {
54
+ let nextId = 1
55
+ let alive = transport.alive
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 }
58
+
59
+ const log = (line: string): void => events.onLog?.(line)
60
+ const parser = createFrameParser()
61
+
62
+ transport.onMessage = (message: unknown): void => {
63
+ const msg = message as { id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: unknown }
64
+ // 服务器端请求/通知带 method(请求还带 id);响应带 id + result/error、无 method。先判 method 再判 id。
65
+ if (typeof msg.method === 'string') {
66
+ if (msg.method === '$/progress') events.onProgress?.(msg.params)
67
+ void handleServerRequest(msg as { method: string; params?: unknown; id?: unknown })
68
+ return
69
+ }
70
+ if (typeof msg.id === 'number' || typeof msg.id === 'string') {
71
+ const entry = pending.get(Number(msg.id))
72
+ if (!entry) return
73
+ pending.delete(Number(msg.id))
74
+ clearTimeout(entry.timer)
75
+ if (msg.error) {
76
+ const err = msg.error as { code?: number; message?: string }
77
+ entry.reject(new LspError(err.code ?? -32000, err.message ?? 'LSP error'))
78
+ } else {
79
+ entry.resolve(msg.result)
80
+ }
81
+ }
82
+ }
83
+
84
+ /** 服务器端请求:消息/日志类打日志,配置/能力类回 result:null,其余回 method not found。 */
85
+ const handleServerRequest = async (msg: { method: string; params?: unknown; id?: unknown }): Promise<void> => {
86
+ const { method, params, id } = msg
87
+ const respond = (payload: Record<string, unknown>): void => {
88
+ transport.write(encodeMessage(payload))
89
+ }
90
+ try {
91
+ if (method === 'window/logMessage' || method === 'window/showMessage' || method === 'window/showMessageRequest') {
92
+ const p = params as { type?: number; message?: string }
93
+ log('[server ' + (p?.type ?? '') + '] ' + String(p?.message ?? ''))
94
+ if (id !== undefined) respond({ jsonrpc: '2.0', id, result: null })
95
+ return
96
+ }
97
+ if (method === 'workspace/configuration') {
98
+ const p = params as { items?: unknown[] }
99
+ const items = Array.isArray(p?.items) ? p.items.map(() => null) : []
100
+ if (id !== undefined) respond({ jsonrpc: '2.0', id, result: items })
101
+ return
102
+ }
103
+ // 客户端能力协商类:标准行为是确认(result:null),否则部分服务器(如 LuaLS)会中断工作区初始化
104
+ if (method === 'window/workDoneProgress/create' || method === 'client/registerCapability' || method === 'workspace/workspaceFolders') {
105
+ if (id !== undefined) respond({ jsonrpc: '2.0', id, result: null })
106
+ return
107
+ }
108
+ if (id !== undefined) {
109
+ respond({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } })
110
+ }
111
+ } catch (error) {
112
+ log('server request handler error: ' + String(error))
113
+ if (id !== undefined) {
114
+ respond({ jsonrpc: '2.0', id, error: { code: -32603, message: String(error) } })
115
+ }
116
+ }
117
+ }
118
+
119
+ const transportExit = transport.onExit
120
+ transport.onExit = (code, signal): void => {
121
+ alive = false
122
+ for (const entry of pending.values()) {
123
+ clearTimeout(entry.timer)
124
+ entry.reject(new Error('LSP 服务器已退出(' + String(code) + '/' + String(signal) + ')'))
125
+ }
126
+ pending.clear()
127
+ events.onExit?.(code, signal)
128
+ transportExit?.(code, signal)
129
+ }
130
+
131
+ const client: LspClient = {
132
+ async request<T>(method: string, params: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<T> {
133
+ if (!alive) throw new Error('LSP 服务器未运行')
134
+ const id = nextId++
135
+ return new Promise<T>((resolve, reject) => {
136
+ const timer = setTimeout(() => {
137
+ pending.delete(id)
138
+ reject(new Error('LSP 请求超时:' + method + '(' + timeoutMs + 'ms)'))
139
+ }, timeoutMs)
140
+ pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
141
+ transport.write(encodeMessage(requestMessage(id, method, params)))
142
+ })
143
+ },
144
+ notify(method: string, params: unknown): void {
145
+ if (!alive) return
146
+ transport.write(encodeMessage(notifyMessage(method, params)))
147
+ },
148
+ async initialize(rootUri: string, workspaceFolders: string[]): Promise<LspServerCapabilities> {
149
+ const init = await client.request<{
150
+ capabilities?: {
151
+ definitionProvider?: unknown
152
+ referencesProvider?: unknown
153
+ documentSymbolProvider?: unknown
154
+ workspaceSymbolProvider?: unknown
155
+ hoverProvider?: unknown
156
+ }
157
+ }>('initialize', {
158
+ processId: process.pid,
159
+ rootUri: rootUri || null,
160
+ rootPath: null,
161
+ workspaceFolders: workspaceFolders.length
162
+ ? workspaceFolders.map((uri) => ({ uri, name: uri }))
163
+ : null,
164
+ capabilities: {
165
+ window: { workDoneProgress: true },
166
+ workspace: { configuration: true },
167
+ textDocument: {
168
+ synchronization: { dynamicRegistration: false, willSave: false, didSave: true },
169
+ definition: { dynamicRegistration: false },
170
+ references: { dynamicRegistration: false },
171
+ documentSymbol: { dynamicRegistration: false },
172
+ hover: { dynamicRegistration: false },
173
+ },
174
+ },
175
+ })
176
+ const caps = init?.capabilities ?? {}
177
+ serverCapabilities = {
178
+ definition: Boolean(caps.definitionProvider),
179
+ references: Boolean(caps.referencesProvider),
180
+ documentSymbol: Boolean(caps.documentSymbolProvider),
181
+ workspaceSymbol: Boolean(caps.workspaceSymbolProvider),
182
+ hover: Boolean(caps.hoverProvider),
183
+ }
184
+ client.notify('initialized', {})
185
+ events.onReady?.(serverCapabilities)
186
+ return serverCapabilities
187
+ },
188
+ async shutdown(): Promise<void> {
189
+ try {
190
+ await client.request<unknown>('shutdown', null, 3000)
191
+ } catch (error) {
192
+ log('shutdown: ' + String(error))
193
+ }
194
+ try {
195
+ client.notify('exit', null)
196
+ } catch (error) {
197
+ log('exit: ' + String(error))
198
+ }
199
+ },
200
+ get alive() {
201
+ return alive
202
+ },
203
+ }
204
+ return client
205
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * dsh-vscode-mode host — LSP 配置读取与 provider 解析装配。
3
+ * 配置来源优先级:settings 持久化(ns dsh-vscode-mode.languageServers)> 插件组合配置 > 默认。
4
+ * 作者 ddj 2026-08-27
5
+ */
6
+ import type { Ctx } from '../store.js'
7
+ import { PROVIDER_RESOLVERS, type LspProviderSpec } from './providers.js'
8
+
9
+ /** 单语言配置。 */
10
+ export interface LspLangConfig {
11
+ enabled?: boolean
12
+ command?: string
13
+ path?: string
14
+ }
15
+
16
+ /** LSP 配置(按语言)。 */
17
+ export interface LspConfig {
18
+ lua?: LspLangConfig
19
+ csharp?: LspLangConfig
20
+ [languageId: string]: LspLangConfig | undefined
21
+ }
22
+
23
+ export const LSP_SETTINGS_NS = 'dsh-vscode-mode.languageServers'
24
+ export const LSP_LANGUAGES = ['lua', 'csharp'] as const
25
+
26
+ function sanitizeLang(raw: unknown): LspLangConfig | undefined {
27
+ if (!raw || typeof raw !== 'object') return undefined
28
+ const obj = raw as Record<string, unknown>
29
+ const out: LspLangConfig = {}
30
+ if (typeof obj.enabled === 'boolean') out.enabled = obj.enabled
31
+ if (typeof obj.command === 'string') out.command = obj.command
32
+ if (typeof obj.path === 'string') out.path = obj.path
33
+ return out
34
+ }
35
+
36
+ /** 从插件组合配置读取。 */
37
+ export function configFromPlugin(config: unknown): LspConfig {
38
+ const raw = (config as { languageServers?: unknown } | undefined)?.languageServers
39
+ if (!raw || typeof raw !== 'object') return {}
40
+ const out: LspConfig = {}
41
+ for (const lang of LSP_LANGUAGES) {
42
+ const v = sanitizeLang((raw as Record<string, unknown>)[lang])
43
+ if (v) out[lang] = v
44
+ }
45
+ return out
46
+ }
47
+
48
+ /** 从 settings 描述符读取(无 settings 服务/无该 section → {})。 */
49
+ export function configFromSettings(ctx: Ctx): LspConfig {
50
+ try {
51
+ const settings = ctx.get('settings')
52
+ const descriptor = settings?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === LSP_SETTINGS_NS)
53
+ const value = descriptor?.value
54
+ if (!value || typeof value !== 'object') return {}
55
+ const out: LspConfig = {}
56
+ for (const lang of LSP_LANGUAGES) {
57
+ const v = sanitizeLang((value as Record<string, unknown>)[lang])
58
+ if (v) out[lang] = v
59
+ }
60
+ return out
61
+ } catch (error) {
62
+ return {}
63
+ }
64
+ }
65
+
66
+ /** 合并配置:settings 覆盖插件配置。 */
67
+ export function mergeConfig(a: LspConfig, b: LspConfig): LspConfig {
68
+ const out: LspConfig = { ...a }
69
+ for (const lang of LSP_LANGUAGES) {
70
+ const av = a[lang]
71
+ const bv = b[lang]
72
+ if (bv) out[lang] = { ...av, ...bv }
73
+ else if (av) out[lang] = av
74
+ }
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * 解析某语言的 provider spec(合并配置后)。
80
+ * @author ddj 2026年08月27号
81
+ * @param ctx DSH 上下文
82
+ * @param pluginConfig 插件组合配置
83
+ * @param languageId 语言 id
84
+ * @returns provider 规格
85
+ */
86
+ export function resolveProviderSpec(ctx: Ctx, pluginConfig: unknown, languageId: string): LspProviderSpec {
87
+ const resolver = PROVIDER_RESOLVERS[languageId]
88
+ if (!resolver) return { languageId, kind: 'none', argv: [], ready: false, reason: '不支持的语言:' + languageId }
89
+ const merged = mergeConfig(configFromPlugin(pluginConfig), configFromSettings(ctx))
90
+ const lang = merged[languageId]
91
+ if (lang && lang.enabled === false) {
92
+ return { languageId, kind: 'none', argv: [], ready: false, reason: '已在设置中禁用' }
93
+ }
94
+ return resolver({ command: lang?.command, path: lang?.path })
95
+ }
96
+
97
+ /** 语言 → 扩展名(供客户端判断是否触发 LSP)。 */
98
+ export function langOfPath(path: string): string | null {
99
+ const base = String(path || '').split(/[\\/]/).pop() || ''
100
+ const dot = base.lastIndexOf('.')
101
+ if (dot <= 0) return null
102
+ const ext = base.slice(dot + 1).toLowerCase()
103
+ for (const [lang, exts] of Object.entries({ lua: ['lua'], csharp: ['cs', 'csx'] })) {
104
+ if (exts.includes(ext)) return lang
105
+ }
106
+ return null
107
+ }