dsh-vscode-mode 0.10.0 → 0.12.0

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 (49) hide show
  1. package/README.md +62 -11
  2. package/lib/client.js +2879 -1030
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2227 -1056
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/activePathStore.ts +28 -0
  8. package/src/client/commandBridge.ts +9 -41
  9. package/src/client/dap/hover.ts +2 -7
  10. package/src/client/index.ts +116 -14
  11. package/src/client/keybindings.ts +19 -73
  12. package/src/client/luaReservedWords.ts +10 -0
  13. package/src/client/monaco/lsp/keywords.ts +121 -0
  14. package/src/client/monaco/lsp/lspClient.ts +68 -6
  15. package/src/client/monaco/lsp/memberScope.ts +90 -0
  16. package/src/client/monaco/lsp/providers.ts +14 -4
  17. package/src/client/settingsDocRouter.ts +52 -0
  18. package/src/client/shortcutsOfficial.ts +451 -0
  19. package/src/client/sidebar/menuItems.ts +1 -0
  20. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  21. package/src/client/sidebar/types.ts +2 -0
  22. package/src/client/styles/editor.css +143 -37
  23. package/src/client/svnActions.ts +4 -4
  24. package/src/client/ui/ContextMenu.ts +4 -2
  25. package/src/client/ui/EditorView.ts +142 -135
  26. package/src/client/ui/McpSettings.ts +4 -5
  27. package/src/client/ui/QuickOpen.ts +10 -11
  28. package/src/client/ui/ShortcutSettings.ts +294 -0
  29. package/src/client/ui/SvnLogDialog.ts +200 -69
  30. package/src/client/ui/SvnUpdateDialog.ts +472 -0
  31. package/src/client/ui/commandCatalog.ts +36 -41
  32. package/src/client/ui/svnUpdateModel.ts +135 -0
  33. package/src/fileOpenSettings.ts +3 -0
  34. package/src/index.ts +2 -0
  35. package/src/lsp/completionCap.ts +80 -0
  36. package/src/lsp/manager.ts +42 -10
  37. package/src/lsp/rpc.ts +8 -5
  38. package/src/lsp/server.ts +18 -4
  39. package/src/rpc.ts +74 -2
  40. package/src/rules.ts +100 -44
  41. package/src/settingsDocPath.ts +30 -0
  42. package/src/shared/keybindings.ts +253 -0
  43. package/src/shared/rpc.ts +12 -2
  44. package/src/shared/rules.ts +9 -1
  45. package/src/shared/svn.ts +38 -0
  46. package/src/svn.ts +102 -7
  47. package/src/svnText.ts +22 -13
  48. package/src/svnUpdateJob.ts +588 -0
  49. package/src/client/ui/KeybindingsPanel.ts +0 -190
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 编辑器指令目录(纯元数据)。
3
- * 「一条命令 = 一条注册数据」:命令栏、快捷键设置页、快捷键监听与 Monaco 右键菜单
4
- * 全部从本表读取;新增一条能力只需在 EDITOR_COMMANDS 追加一项(可选 keybinding)。
3
+ * 「一条命令 = 一条注册数据」:命令栏、快捷键设置页与 Monaco 右键菜单
4
+ * 全部从本表读取;新增一条能力只需在 EDITOR_COMMANDS 追加一项(默认键位见 shared/keybindings SHORTCUT_PROFILES)。
5
5
  * run 只派发 `edrv.command.*` 窗口事件,不直接触碰 React/Monaco(`edrv.command.` 前缀
6
6
  * 与既有 `edrv:` 刷新/主题事件分属不同命名空间,互不干扰)。
7
7
  * 作者 ddj 2026年09月10号
8
8
  */
9
9
  import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
10
+ import { activeEditorPath } from '../activePathStore.js'
11
+ import { isMarkdownPath } from '../markdownPreview.js'
10
12
  import { svnCurrentStatus } from '../svnStatus.js'
11
13
  import { dapStore } from '../dap/store.js'
12
14
  import { svnActionOn, svnActionsFor } from '../../shared/svnActions.js'
@@ -22,8 +24,6 @@ export interface CommandDef {
22
24
  category: string
23
25
  /** 组内排序(小者优先)。 */
24
26
  order?: number
25
- /** 默认键位弦(可选;可含 `|` 多候选)。声明后由快捷键设置页展示与录制。 */
26
- keybinding?: string
27
27
  /** 运行体:派发窗口事件或直接执行。 */
28
28
  run: () => void
29
29
  /** 可用性判定(缺省视为始终可用;返回 false 时命令栏隐藏且 run 被拒)。 */
@@ -62,7 +62,7 @@ function alwaysAvailable(): boolean {
62
62
  function nextEditorRowDef(): CommandDef {
63
63
  return {
64
64
  id: 'edrv.nextEditorRow', label: '下一编辑行(光标整行下移)', category: '导航', order: 50,
65
- keybinding: 'Ctrl+Alt+ArrowDown', available: needsModel,
65
+ available: needsModel,
66
66
  run: () => emit('nextEditorRow'),
67
67
  }
68
68
  }
@@ -75,7 +75,7 @@ function nextEditorRowDef(): CommandDef {
75
75
  function prevEditorRowDef(): CommandDef {
76
76
  return {
77
77
  id: 'edrv.prevEditorRow', label: '上一编辑行(光标整行上移)', category: '导航', order: 60,
78
- keybinding: 'Ctrl+Alt+ArrowUp', available: needsModel,
78
+ available: needsModel,
79
79
  run: () => emit('prevEditorRow'),
80
80
  }
81
81
  }
@@ -88,7 +88,7 @@ function prevEditorRowDef(): CommandDef {
88
88
  function gotoLineDef(): CommandDef {
89
89
  return {
90
90
  id: 'edrv.goToLine', label: '转到行', category: '导航', order: 45,
91
- keybinding: 'Ctrl+G', available: needsModel,
91
+ available: needsModel,
92
92
  run: () => emit('goToLine'),
93
93
  }
94
94
  }
@@ -102,7 +102,7 @@ function gotoLineDef(): CommandDef {
102
102
  function addSelectionRefDef(): CommandDef {
103
103
  return {
104
104
  id: 'edrv.addSelectionRef', label: '添加选中内容为引用', category: '编辑', order: 10,
105
- keybinding: 'Ctrl+U', available: needsModel,
105
+ available: needsModel,
106
106
  run: () => emit('addSelectionRef'),
107
107
  }
108
108
  }
@@ -117,7 +117,7 @@ function addSelectionRefDef(): CommandDef {
117
117
  function closeTabDef(): CommandDef {
118
118
  return {
119
119
  id: 'edrv.closeTab', label: '关闭当前页签', category: '文件', order: 40,
120
- keybinding: 'Ctrl+F4', available: hasOpenTabs,
120
+ available: hasOpenTabs,
121
121
  run: () => emit('closeTab'),
122
122
  }
123
123
  }
@@ -188,34 +188,39 @@ function svnEventOf(action: SvnActionDef): string {
188
188
 
189
189
  /**
190
190
  * 编辑器内置指令目录(顺序 = 快捷键设置页展示顺序)。
191
- * 前置 8 条的键位由 EditorView / QuickOpen 自行 capture 监听(历史实现),
192
- * 故不进 BRIDGE_COMMANDS,避免同一按键双执行。
193
- * @author ddj 2026年09月10号
191
+ * 键位派发已归官方 shortcuts 机制:默认键位表在 shared/keybindings.ts 的 SHORTCUT_PROFILES,
192
+ * 由 client/index.ts 经 shortcutsOfficial.registerOfficialShortcuts 注册,本目录只管
193
+ * 「标签/可用性/执行」。无默认键位的命令同样注册(官方 UI/插件子页内可自定义绑定)。
194
+ * @author ddj 2026年09月10号 / 2026年10月
194
195
  */
195
196
  export const EDITOR_COMMANDS: readonly CommandDef[] = [
196
197
  {
197
198
  id: 'edrv.save', label: '保存文件', category: '文件', order: 10,
198
- keybinding: 'Ctrl+S', available: needsModel,
199
+ available: needsModel,
199
200
  run: () => emit('save'),
200
201
  },
201
202
  {
202
203
  id: 'edrv.quickOpen', label: '快速打开文件', category: '文件', order: 20,
203
- keybinding: 'Ctrl+P', available: alwaysAvailable,
204
+ available: alwaysAvailable,
204
205
  run: () => emit('quickOpen'),
205
206
  },
206
207
  {
207
208
  id: 'edrv.toggleSidebar', label: '切换侧边栏', category: '视图', order: 10,
208
- keybinding: 'Ctrl+B', available: alwaysAvailable,
209
+ available: alwaysAvailable,
209
210
  run: () => emit('toggleSidebar'),
210
211
  },
211
212
  {
212
213
  id: 'edrv.searchInFiles', label: '在工作区中搜索', category: '视图', order: 20,
213
- keybinding: 'Ctrl+Shift+F', available: alwaysAvailable,
214
+ available: alwaysAvailable,
214
215
  run: () => emit('searchInFiles'),
215
216
  },
216
217
  {
217
218
  id: 'edrv.toggleMarkdownPreview', label: '切换 Markdown 预览', category: '视图', order: 25,
218
- keybinding: 'Ctrl+Shift+V', available: alwaysAvailable,
219
+ // 仅活动文件是 .md 时可用(不可用时官方派发返回 pass 不吞键,保留浏览器「无格式粘贴」语义)
220
+ available: () => {
221
+ const path = activeEditorPath()
222
+ return typeof path === 'string' && isMarkdownPath(path)
223
+ },
219
224
  run: () => emit('toggleMarkdownPreview'),
220
225
  },
221
226
  {
@@ -225,22 +230,22 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
225
230
  },
226
231
  {
227
232
  id: 'edrv.navigateBack', label: '后退(导航历史)', category: '导航', order: 10,
228
- keybinding: 'Alt+ArrowLeft|Ctrl+Alt+-', available: needsModel,
233
+ available: needsModel,
229
234
  run: () => emit('navigateBack'),
230
235
  },
231
236
  {
232
237
  id: 'edrv.navigateForward', label: '前进(导航历史)', category: '导航', order: 20,
233
- keybinding: 'Alt+ArrowRight|Ctrl+Shift+-', available: needsModel,
238
+ available: needsModel,
234
239
  run: () => emit('navigateForward'),
235
240
  },
236
241
  {
237
242
  id: 'edrv.nextTab', label: '下一个页签', category: '导航', order: 30,
238
- keybinding: 'Ctrl+Alt+ArrowRight|Ctrl+PageDown', available: needsModel,
243
+ available: needsModel,
239
244
  run: () => emit('nextTab'),
240
245
  },
241
246
  {
242
247
  id: 'edrv.prevTab', label: '上一个页签', category: '导航', order: 40,
243
- keybinding: 'Ctrl+Alt+ArrowLeft|Ctrl+PageUp', available: needsModel,
248
+ available: needsModel,
244
249
  run: () => emit('prevTab'),
245
250
  },
246
251
  {
@@ -291,41 +296,41 @@ function debugPaletteDefs(): CommandDef[] {
291
296
  return [
292
297
  {
293
298
  id: 'edrv.debugToggleBreakpoint', label: '调试:切换断点(光标行)', category: '调试', order: 10,
294
- keybinding: 'F9', available: needsModel,
299
+ available: needsModel,
295
300
  run: () => emit('debugToggleBreakpoint'),
296
301
  },
297
302
  {
298
303
  id: 'edrv.debugStartContinue', label: '调试:启动 / 继续', category: '调试', order: 20,
299
- keybinding: 'F5', available: canStart,
304
+ available: canStart,
300
305
  run: () => emit('debugStartContinue'),
301
306
  },
302
307
  {
303
308
  id: 'edrv.debugStepOver', label: '调试:单步跳过', category: '调试', order: 30,
304
- keybinding: 'F10', available: paused,
309
+ available: paused,
305
310
  run: () => emit('debugStepOver'),
306
311
  },
307
312
  {
308
313
  id: 'edrv.debugStepInto', label: '调试:单步步入', category: '调试', order: 40,
309
- keybinding: 'F11', available: paused,
314
+ available: paused,
310
315
  run: () => emit('debugStepInto'),
311
316
  },
312
317
  {
313
318
  id: 'edrv.debugStepOut', label: '调试:单步步出', category: '调试', order: 50,
314
- keybinding: 'Shift+F11', available: paused,
319
+ available: paused,
315
320
  run: () => emit('debugStepOut'),
316
321
  },
317
322
  {
318
323
  id: 'edrv.debugStop', label: '调试:停止', category: '调试', order: 60,
319
- keybinding: 'Shift+F5', available: canStop,
324
+ available: canStop,
320
325
  run: () => emit('debugStop'),
321
326
  },
322
327
  ]
323
328
  }
324
329
 
325
330
  /**
326
- * 桥接派发指令(无原生监听,键位由 commandBridge 统一 capture 处理)。
327
- * 新增「只填目录、不写监听」的编辑器指令一律放这里:键位、命令栏、设置页自动可用。
328
- * @author ddj 2026年09月10号
331
+ * 非编辑器原生路径的指令(键位统一由官方 shortcuts 机制派发;历史「桥接」分组名保留)。
332
+ * 新增「只填目录」的编辑器指令一律放这里:命令栏与官方快捷键目录自动可用。
333
+ * @author ddj 2026年09月10号 / 2026年10月
329
334
  */
330
335
  export const BRIDGE_COMMANDS: readonly CommandDef[] = [
331
336
  nextEditorRowDef(),
@@ -345,16 +350,6 @@ export const BRIDGE_COMMANDS: readonly CommandDef[] = [
345
350
  export function showCommandsDef(run: () => void): CommandDef {
346
351
  return {
347
352
  id: 'edrv.showCommands', label: '显示所有命令', category: '视图', order: 1,
348
- keybinding: 'Ctrl+Shift+P|F1', available: alwaysAvailable, run,
353
+ available: alwaysAvailable, run,
349
354
  }
350
355
  }
351
-
352
- /**
353
- * 需要全局键位派发的指令(桥接类 + 命令栏自身;不含 EditorView/QuickOpen 原生监听的那些)。
354
- * @author ddj 2026年09月10号
355
- * @param showCommands 命令栏命令定义
356
- * @returns 派发集合
357
- */
358
- export function dispatchedCommands(showCommands: CommandDef): readonly CommandDef[] {
359
- return [...BRIDGE_COMMANDS, showCommands]
360
- }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * dsh-vscode-mode client — SVN 更新窗口的纯视图逻辑。
3
+ * @author ddj 2026年09月24号
4
+ */
5
+ import type { SvnUpdatePhase, SvnUpdateRow } from '../../shared/svn.js'
6
+
7
+ /**
8
+ * 只有真实终态允许直接关闭窗口。
9
+ * @author ddj 2026年09月24号
10
+ * @param phase 任务阶段
11
+ * @returns 是否结束
12
+ */
13
+ export function updateDone(phase: SvnUpdatePhase | undefined): boolean {
14
+ return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
15
+ }
16
+
17
+ /**
18
+ * 后端结束并不等于前端已取完全部结果页。
19
+ * @author ddj 2026年09月24号
20
+ * @param phase 后台状态
21
+ * @param pending 尚有未加载的明细
22
+ * @returns 结果界面是否完成
23
+ */
24
+ export function updateReady(phase: SvnUpdatePhase | undefined, pending: boolean): boolean {
25
+ return updateDone(phase) && !pending
26
+ }
27
+
28
+ /**
29
+ * 取消或异常终止会在工作副本上留下 SVN 锁(E155037/E155004);
30
+ * 只按失败态原文判定,不推测其他失败的原因。
31
+ * @author ddj 2026年09月28号
32
+ * @param state 任务快照
33
+ * @returns 是否需要先执行 cleanup
34
+ */
35
+ export function updateLocked(state: { phase?: SvnUpdatePhase; error?: string; rawTail?: string } | null | undefined): boolean {
36
+ if (!state || state.phase !== 'failed') return false
37
+ const text = (state.error ?? '') + '\n' + (state.rawTail ?? '')
38
+ return /E155037|E155004|Previous operation has not finished|run 'cleanup'|Working copy .*locked|工作副本.*锁/i.test(text)
39
+ }
40
+
41
+ /**
42
+ * 菜单/二级对话框按 Esc 只关闭自己;连接中断后允许显式暂时关闭。
43
+ * @author ddj 2026年09月24号
44
+ * @param ready 已取完后台结果
45
+ * @param error 连接错误
46
+ * @param hasJob 是否知道后台 job
47
+ * @param overlay 子弹窗或菜单是否打开
48
+ * @returns 自动/显式关闭及确认要求
49
+ */
50
+ export function updateCloseMode(ready: boolean, error: string, hasJob: boolean, overlay: boolean) {
51
+ const disconnected = Boolean(error && hasJob && !ready)
52
+ return {
53
+ passive: (ready || Boolean(error && !hasJob)) && !overlay,
54
+ explicit: ready || Boolean(error),
55
+ confirm: disconnected,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 窗口只能在发起它的同一会话、同一工作区作用域中挂载。
61
+ * @author ddj 2026年09月24号
62
+ * @param dialog 发起窗口时记录的身份
63
+ * @param sessionId 当前会话
64
+ * @param scope 当前工作区作用域
65
+ * @returns 是否允许挂载
66
+ */
67
+ export function sameUpdateOwner(dialog: { sessionId?: string; scope?: string | null } | null,
68
+ sessionId?: string, scope?: string | null): boolean {
69
+ return Boolean(dialog && dialog.sessionId === sessionId && dialog.scope === scope)
70
+ }
71
+
72
+ /**
73
+ * 为数千条结果取可视区和小幅缓冲,行高由界面固定约束。
74
+ * @author ddj 2026年09月24号
75
+ * @param count 当前记录数
76
+ * @param scrollTop 列表滚动偏移
77
+ * @param height 列表高度
78
+ * @param rowHeight 固定行高
79
+ * @returns 可见的起止索引(尾部不含)
80
+ */
81
+ export function updateRange(count: number, scrollTop: number, height: number, rowHeight: number): { start: number; end: number } {
82
+ const start = Math.min(count, Math.max(0, Math.floor(Math.max(0, scrollTop) / rowHeight) - 8))
83
+ const end = Math.min(count, Math.ceil((Math.max(0, scrollTop) + Math.max(0, height)) / rowHeight) + 8)
84
+ return { start, end }
85
+ }
86
+
87
+ /**
88
+ * 输出不等于文件真实类型;只给已知扩展名提供可辨识的类型推断。
89
+ * @author ddj 2026年09月24号
90
+ * @param path SVN 输出路径
91
+ * @returns 推断类型,未知为空
92
+ */
93
+ export function updateType(path: string): string {
94
+ const ext = path.split('.').pop()?.toLowerCase()
95
+ const known: Record<string, string> = {
96
+ bytes: 'application/octet-stream', png: 'image/png', jpg: 'image/jpeg',
97
+ jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', pdf: 'application/pdf',
98
+ }
99
+ return ext ? known[ext] ?? '' : ''
100
+ }
101
+
102
+ /**
103
+ * 行动作是否可用取决于更新是否结束、路径是否在工作区且磁盘是否有文件。
104
+ * @author ddj 2026年09月24号
105
+ * @param row 更新行
106
+ * @param phase 任务阶段
107
+ * @param kind 磁盘判型;未读到时仅开放父目录/复制
108
+ * @param revision 完成修订号
109
+ * @returns 菜单动作集合
110
+ */
111
+ export function updateActs(row: SvnUpdateRow, phase: SvnUpdatePhase, kind: string, revision: number | null) {
112
+ const valid = updateDone(phase) && Boolean(row.relPath)
113
+ const file = valid && kind === 'file' && row.action !== 'D'
114
+ const binary = Boolean(updateType(row.path))
115
+ return {
116
+ compare: file && Boolean(revision) && !binary,
117
+ log: file,
118
+ open: file,
119
+ openWith: file,
120
+ parent: valid,
121
+ copy: Boolean(row.path),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 目录上下文使用工作区相对路径,根文件的父目录表示为工作区根。
127
+ * @author ddj 2026年09月24号
128
+ * @param path 工作区相对路径
129
+ * @returns 父目录相对路径
130
+ */
131
+ export function updateParent(path: string): string {
132
+ const normalized = path.replace(/\\/g, '/')
133
+ const at = normalized.lastIndexOf('/')
134
+ return at < 0 ? '' : normalized.slice(0, at)
135
+ }
@@ -407,6 +407,9 @@ export async function updateSection(provider: SettingsProvider | undefined, ns:
407
407
  * 构建设置 section schema(install 路径与插件 Config 声明共用,保证两代形状同源)。
408
408
  * 字段集 = fileOpenTool/keybindings/sidebarMinWidth/maxOpenEditors/integrationBaseUrl/
409
409
  * aiInline/aiProvider/aiModel/aiEffort/svnPath/tortoisePath/nativeOpenExts,全部带默认值
410
+ * (keybindings 自 2026-10 起废弃停写:键位持久化归官方 shortcuts 存储
411
+ * userData/keybindings.json / dsh.keybindings.v1;字段保留仅为旧档迁移读取与降级回滚,
412
+ * 见 client/shortcutsOfficial.ts 的 migrateLegacyKeybindings。)
410
413
  * (Config 启动校验在 undefined/空配置下自动填充,rc/alpha 两代 cordis 均通过)。
411
414
  * options.volatile:DSH 0.1.7 线的 Config 导出专用——SettingsForms.describe 只下发
412
415
  * 含 volatile 字段的 entry(volatileForm 门槛),不标记则 ns 永不出现在 describe、
package/src/index.ts CHANGED
@@ -123,6 +123,8 @@ export function apply(ctx: Ctx, config?: unknown): void {
123
123
  })
124
124
  // 卸载时清空文件版本基准表(观察器为模块内单例,不清会跨装配残留陈旧版本)
125
125
  ctx.effect(() => () => fileVersions.dispose())
126
+ // 卸载时结束仍在运行的 SVN 更新,避免无主工作副本更新继续写入
127
+ ctx.effect(() => () => svnRpc.dispose())
126
128
  // 卸载/重启时结束调试会话并强杀适配器子进程(防残留注入器/适配器孤儿)
127
129
  ctx.effect(() => () => dapRpc.dispose())
128
130
  // 宿主进程退出回收:ctx.effect 清理不覆盖进程退出,缺此注册会留下跨重启的孤儿服务器
@@ -0,0 +1,80 @@
1
+ /**
2
+ * dsh-vscode-mode host — 补全结果的前缀感知截断(纯函数,node 可测)。
3
+ * 背景:RPC 层按固定上限硬截断补全条目,而本仓库的 xLua 绑定桩
4
+ * (Assets/Scripts/EmmyLuaDefine,形如 CS_IslandSplash_Extensions_Common_ArrayExtension)
5
+ * 有上万个全局,字母序会把项目自身全局(ECommon / EActivity 等)挤到上限之外;
6
+ * 截断发生在 Monaco 前缀过滤之前,因此**之后无论输入什么都取不回**该条目。
7
+ * 改为按当前输入前缀分桶后截断:优先保留真实命中的条目;未超限时零行为变化。
8
+ * 作者 ddj 2026年09月24号
9
+ */
10
+ import type { LspCompletionItem } from '../shared/lsp.js'
11
+
12
+ /** 单次补全返回条目上限(RPC 载荷护栏,与 references 同口径)。 */
13
+ export const COMPLETION_ITEM_CAP = 500
14
+
15
+ /** 截断结果:items 恒不超上限;truncated 标记本轮是否发生了丢弃。 */
16
+ export interface CappedCompletions {
17
+ items: LspCompletionItem[]
18
+ truncated: boolean
19
+ }
20
+
21
+ /**
22
+ * 判断 needle 是否为 haystack 的子序列(两侧均已小写)。
23
+ *
24
+ * 与 Monaco 候选列表的模糊匹配同口径,保证「分桶判为命中」的条目确实是
25
+ * 用户输入能筛出来的那些,避免分桶顺序与前端过滤结果互相矛盾。
26
+ * @author ddj 2026年09月24号
27
+ * @param haystack 候选文本(已小写)
28
+ * @param needle 输入前缀(已小写、非空)
29
+ * @returns 是否为子序列
30
+ */
31
+ function isSubsequence(haystack: string, needle: string): boolean {
32
+ let matched = 0
33
+ for (let i = 0; i < haystack.length && matched < needle.length; i++) {
34
+ if (haystack[i] === needle[matched]) matched++
35
+ }
36
+ return matched === needle.length
37
+ }
38
+
39
+ /**
40
+ * 取补全项的匹配文本(Monaco 口径:filterText 优先,回落 label;统一小写)。
41
+ * @author ddj 2026年09月24号
42
+ * @param item 补全项
43
+ * @returns 小写匹配文本;无可用文本时为空串
44
+ */
45
+ function textOfItem(item: LspCompletionItem): string {
46
+ const raw = typeof item?.filterText === 'string' && item.filterText ? item.filterText : item?.label
47
+ return typeof raw === 'string' ? raw.toLowerCase() : ''
48
+ }
49
+
50
+ /**
51
+ * 按输入前缀分桶后截断补全列表。
52
+ *
53
+ * 分桶优先级:startsWith > includes > 子序列 > 其余;桶内保持服务器原始顺序
54
+ * (服务器已按相关性排序,不重新打分)。无前缀或未超限时与旧行为完全一致。
55
+ * @author ddj 2026年09月24号
56
+ * @param items 服务器归一化后的补全条目
57
+ * @param prefix 当前输入前缀(Monaco getWordUntilPosition 的词)
58
+ * @param cap 条目上限(缺省 COMPLETION_ITEM_CAP;测试可注入小值)
59
+ * @returns 有界条目与截断标记
60
+ */
61
+ export function capCompletions(
62
+ items: LspCompletionItem[],
63
+ prefix?: string,
64
+ cap: number = COMPLETION_ITEM_CAP,
65
+ ): CappedCompletions {
66
+ if (!Array.isArray(items)) return { items: [], truncated: false }
67
+ if (items.length <= cap) return { items, truncated: false }
68
+ const needle = String(prefix ?? '').toLowerCase()
69
+ if (!needle) return { items: items.slice(0, cap), truncated: true }
70
+ const buckets: LspCompletionItem[][] = [[], [], [], []]
71
+ for (const item of items) {
72
+ const text = textOfItem(item)
73
+ if (text.startsWith(needle)) buckets[0].push(item)
74
+ else if (text.includes(needle)) buckets[1].push(item)
75
+ else if (isSubsequence(text, needle)) buckets[2].push(item)
76
+ else buckets[3].push(item)
77
+ }
78
+ const ordered = buckets[0].concat(buckets[1], buckets[2], buckets[3])
79
+ return { items: ordered.slice(0, cap), truncated: true }
80
+ }
@@ -12,11 +12,13 @@ interface Entry {
12
12
  server: LspServer
13
13
  refs: number
14
14
  failures: number
15
+ readyAt: number
15
16
  restartTimer: ReturnType<typeof setTimeout> | null
16
17
  }
17
18
 
18
19
  const BACKOFF_MS = [1000, 5000, 30000]
19
20
  const MAX_FAILURES = 3
21
+ const STABLE_MS = 60000
20
22
 
21
23
  export interface LspManager {
22
24
  /** 获取(或惰性创建)某工作区+语言的服务器;refs+1。 */
@@ -51,6 +53,19 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
51
53
 
52
54
  const log = (line: string): void => logger?.('[lsp-manager] ' + line)
53
55
 
56
+ /**
57
+ * 重试耗尽后保留退出根因,并让 UI 不再误报“未启动”。
58
+ * @author ddj 2026年09月24号
59
+ * @param entry 服务器条目
60
+ * @returns 当前可见状态
61
+ */
62
+ const statusOf = (entry: Entry): LspServerStatus => {
63
+ const status = entry.server.status()
64
+ return entry.failures >= MAX_FAILURES
65
+ ? { ...status, phase: 'unavailable', reason: (status.reason ?? '服务器已退出') + '(已停止自动重试)' }
66
+ : status
67
+ }
68
+
54
69
  /** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
55
70
  const dropEntry = (entry: Entry): void => {
56
71
  if (entry.restartTimer) {
@@ -62,20 +77,22 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
62
77
  }
63
78
 
64
79
  const scheduleRestart = (entry: Entry): void => {
65
- if (entry.restartTimer) return
80
+ if (entry.restartTimer || entry.refs === 0 || entries.get(entry.key) !== entry) return
66
81
  if (entry.failures >= MAX_FAILURES) {
67
82
  log('unavailable after ' + entry.failures + ' failures: ' + entry.key)
83
+ manager.onStatusChange?.(statusOf(entry))
68
84
  return
69
85
  }
70
- const delay = BACKOFF_MS[Math.min(entry.failures, BACKOFF_MS.length - 1)]
86
+ const delay = BACKOFF_MS[Math.min(Math.max(entry.failures - 1, 0), BACKOFF_MS.length - 1)]
71
87
  entry.restartTimer = setTimeout(() => {
72
88
  entry.restartTimer = null
73
- if (!entries.has(entry.key)) return
89
+ if (entries.get(entry.key) !== entry || entry.refs === 0) return
74
90
  void (async () => {
75
91
  const ok = await entry.server.start()
76
- if (!ok) entry.failures++
77
- else entry.failures = 0
78
- if (!ok && entries.has(entry.key)) scheduleRestart(entry)
92
+ if (!ok && entries.get(entry.key) === entry && !entry.restartTimer) {
93
+ entry.failures++
94
+ scheduleRestart(entry)
95
+ }
79
96
  })()
80
97
  }, delay)
81
98
  }
@@ -87,13 +104,28 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
87
104
  if (!entry) {
88
105
  const spec = resolveSpec(languageId)
89
106
  const server = createLspServer(spec, root, languageId, log)
90
- server.onStateChange = (status) => manager.onStatusChange?.(status)
91
- entry = { key, server, refs: 0, failures: 0, restartTimer: null }
107
+ entry = { key, server, refs: 0, failures: 0, readyAt: 0, restartTimer: null }
92
108
  entries.set(key, entry)
109
+ server.onStateChange = (status) => {
110
+ const live = entries.get(key)
111
+ if (live?.server === server) {
112
+ if (status.phase === 'ready' && live.readyAt === 0) live.readyAt = Date.now()
113
+ if (status.phase === 'idle' && status.reason?.startsWith('服务器已退出')) {
114
+ if (live.readyAt && Date.now() - live.readyAt >= STABLE_MS) live.failures = 0
115
+ live.failures++
116
+ live.readyAt = 0
117
+ scheduleRestart(live)
118
+ }
119
+ }
120
+ manager.onStatusChange?.(live?.server === server ? statusOf(live) : status)
121
+ }
93
122
  // 惰性启动:若 spec ready 才启动,否则保持 idle(供 status 展示 reason)
94
123
  if (spec.ready) {
95
124
  void server.start().then((ok) => {
96
- if (!ok && entries.has(key)) entry!.failures++
125
+ if (!ok && entries.get(key) === entry && server.phase === 'idle' && !entry!.restartTimer) {
126
+ entry!.failures++
127
+ scheduleRestart(entry!)
128
+ }
97
129
  })
98
130
  }
99
131
  }
@@ -126,7 +158,7 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
126
158
  },
127
159
 
128
160
  statusAll(): LspServerStatus[] {
129
- return [...entries.values()].map((entry) => entry.server.status())
161
+ return [...entries.values()].map(statusOf)
130
162
  },
131
163
 
132
164
  peek(root: string, languageId: string): LspServer | undefined {
package/src/lsp/rpc.ts CHANGED
@@ -10,6 +10,7 @@ import { searchRoot } from '../search/ripgrep.js'
10
10
  import { sessionOf, cwdOf } from '../registry.js'
11
11
  import { resolveProviderSpec, langOfPath, configFromPlugin, mergeConfig, sanitizeLang, LSP_LANGUAGES, type LspConfig, type LspLangConfig } from './config.js'
12
12
  import { clearProviderCache, candidatesFor } from './providers.js'
13
+ import { capCompletions } from './completionCap.js'
13
14
  import { onRuntimeProvisioned, envInstallStates } from './dotnetProvision.js'
14
15
  import { envRequirementsFor, installRequirement } from './envRequirements.js'
15
16
  import type { LspManager } from './manager.js'
@@ -451,11 +452,13 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
451
452
  args.context,
452
453
  )
453
454
  if (!completions) return { ok: true, completions: undefined }
454
- const truncated = completions.items.length > 500
455
- return {
456
- ok: true,
457
- completions: truncated ? { ...completions, items: completions.items.slice(0, 500), truncated: true } : completions,
458
- }
455
+ // 前缀感知截断:超限时按当前输入前缀分桶,保证正在打的符号不被挤出上限
456
+ // (本仓库上万个 CS_* 绑定桩会把 ECommon 等项目全局顶到 500 条之外)。
457
+ const capped = capCompletions(completions.items, args.prefix)
458
+ const payload = { ...completions, items: capped.items }
459
+ if (capped.truncated) payload.truncated = true
460
+ else delete payload.truncated
461
+ return { ok: true, completions: payload }
459
462
  } catch (error) {
460
463
  return { ok: false, error: 'LSP 补全查询失败:' + String(error) }
461
464
  }
package/src/lsp/server.ts CHANGED
@@ -57,6 +57,19 @@ interface OpenDoc {
57
57
  }
58
58
 
59
59
  const RESTARTABLE = true
60
+ const MAX_DOC_VERSION = 2147483647
61
+
62
+ /**
63
+ * 使用合法、递增的 Int32 文档版本;旧客户端的毫秒时间戳不能直发给 Roslyn。
64
+ * @author ddj 2026年09月24号
65
+ * @param incoming 客户端传来的版本
66
+ * @param previous 当前服务器记录的版本
67
+ * @returns 可发送给语言服务器的版本
68
+ */
69
+ export function lspDocVersion(incoming: number, previous?: number): number {
70
+ const next = Math.min(MAX_DOC_VERSION, (previous ?? 0) + 1)
71
+ return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next
72
+ }
60
73
 
61
74
  /**
62
75
  * 创建语言服务器会话。
@@ -237,21 +250,22 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
237
250
  sync(path: string, text: string, version: number): void {
238
251
  const existing = docs.get(path)
239
252
  const uri = existing?.uri ?? docUriOf(path)
253
+ const safeVersion = lspDocVersion(version, existing?.version)
240
254
  if (existing) {
241
- existing.version = version
255
+ existing.version = safeVersion
242
256
  existing.text = text
243
257
  if (phase === 'ready' || phase === 'indexing') {
244
258
  client?.notify('textDocument/didChange', {
245
- textDocument: { uri, version },
259
+ textDocument: { uri, version: safeVersion },
246
260
  contentChanges: [{ text }],
247
261
  })
248
262
  }
249
263
  return
250
264
  }
251
- docs.set(path, { path, uri, version, text })
265
+ docs.set(path, { path, uri, version: safeVersion, text })
252
266
  if (phase === 'ready' || phase === 'indexing') {
253
267
  client?.notify('textDocument/didOpen', {
254
- textDocument: { uri, languageId, version, text },
268
+ textDocument: { uri, languageId, version: safeVersion, text },
255
269
  })
256
270
  }
257
271
  },