dsh-vscode-mode 0.5.2 → 0.6.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 (78) hide show
  1. package/README.md +78 -5
  2. package/lib/client.js +5579 -236
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2358 -126
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/compat.ts +1 -1
  8. package/src/client/dap/BpWidget.ts +262 -0
  9. package/src/client/dap/bpMenu.ts +91 -0
  10. package/src/client/dap/bpRowMenu.ts +53 -0
  11. package/src/client/dap/breakpoints.ts +222 -0
  12. package/src/client/dap/decorate.ts +128 -0
  13. package/src/client/dap/hintLine.ts +29 -0
  14. package/src/client/dap/hover.ts +166 -0
  15. package/src/client/dap/hoverMode.ts +159 -0
  16. package/src/client/dap/hoverTree.ts +722 -0
  17. package/src/client/dap/launchInsert.ts +229 -0
  18. package/src/client/dap/launchSnippetProvider.ts +206 -0
  19. package/src/client/dap/modelPath.ts +23 -0
  20. package/src/client/dap/panelSplit.ts +105 -0
  21. package/src/client/dap/pidPick.ts +24 -0
  22. package/src/client/dap/store.ts +571 -0
  23. package/src/client/dap/toolbarDrag.ts +51 -0
  24. package/src/client/dap/trace.ts +22 -0
  25. package/src/client/dap/variableTree.ts +62 -0
  26. package/src/client/editorLimit.ts +31 -0
  27. package/src/client/index.ts +13 -0
  28. package/src/client/markdownPreview.ts +24 -0
  29. package/src/client/md/componentType.ts +28 -0
  30. package/src/client/md/mdPanel.ts +98 -0
  31. package/src/client/monaco/lsp/index.ts +15 -0
  32. package/src/client/monaco/lsp/providers.ts +2 -0
  33. package/src/client/monaco/theme.ts +15 -0
  34. package/src/client/searchSeed.ts +64 -0
  35. package/src/client/sidebar/panels/DebugPanel.ts +613 -0
  36. package/src/client/sidebar/panels/SearchPanel.ts +70 -15
  37. package/src/client/sidebar/panels/SvnPanel.ts +167 -23
  38. package/src/client/sidebar/panels/index.ts +19 -0
  39. package/src/client/sidebar/types.ts +2 -0
  40. package/src/client/styles/editor.css +186 -1
  41. package/src/client/svnActions.ts +18 -1
  42. package/src/client/svnLog.ts +3 -3
  43. package/src/client/svnStatus.ts +140 -2
  44. package/src/client/tabActions.ts +54 -0
  45. package/src/client/ui/EditorView.ts +711 -29
  46. package/src/client/ui/McpSettings.ts +29 -0
  47. package/src/client/ui/SideBySideDiff.tsx +214 -29
  48. package/src/client/ui/SvnDiffPanel.ts +21 -8
  49. package/src/client/ui/SvnLogDialog.ts +44 -2
  50. package/src/client/ui/SvnPatchDialog.ts +63 -0
  51. package/src/client/ui/SvnSumDialog.ts +77 -0
  52. package/src/client/ui/commandCatalog.ts +54 -0
  53. package/src/client/ui/horizontalWheel.ts +85 -0
  54. package/src/client/ui/svnExport.ts +71 -0
  55. package/src/client-primitives.d.ts +34 -0
  56. package/src/dap/configSnippets.ts +269 -0
  57. package/src/dap/discovery.ts +198 -0
  58. package/src/dap/launchConfig.ts +127 -0
  59. package/src/dap/manager.ts +588 -0
  60. package/src/dap/processList.ts +76 -0
  61. package/src/dap/protocol.ts +80 -0
  62. package/src/dap/provider.ts +49 -0
  63. package/src/dap/rpc.ts +192 -0
  64. package/src/dap/sourcePath.ts +82 -0
  65. package/src/fileOpenSettings.ts +3 -0
  66. package/src/index.ts +7 -3
  67. package/src/lsp/providers.ts +3 -2
  68. package/src/reveal.ts +31 -20
  69. package/src/rpc.ts +11 -3
  70. package/src/search/ripgrep.ts +127 -11
  71. package/src/shared/dap.ts +262 -0
  72. package/src/shared/editorLimit.ts +46 -0
  73. package/src/shared/keybindings.ts +10 -0
  74. package/src/shared/rpc.ts +40 -2
  75. package/src/shared/svn.ts +147 -0
  76. package/src/shared/svnActions.ts +20 -0
  77. package/src/svn.ts +320 -11
  78. package/src/workspace.ts +0 -75
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
10
10
  import { svnCurrentStatus } from '../svnStatus.js'
11
+ import { dapStore } from '../dap/store.js'
11
12
  import { svnActionOn, svnActionsFor } from '../../shared/svnActions.js'
12
13
  import type { SvnActionDef } from '../../shared/svnActions.js'
13
14
 
@@ -160,6 +161,8 @@ function svnEventOf(action: SvnActionDef): string {
160
161
  add: 'svnAdd',
161
162
  revert: 'svnRevertCli',
162
163
  log: 'svnLog',
164
+ 'create-patch': 'svnCreatePatch',
165
+ 'diff-summarize': 'svnDiffSum',
163
166
  cleanup: 'svnCleanup',
164
167
  'tortoise-commit': 'svnTortoiseCommit',
165
168
  'tortoise-log': 'svnTortoiseLog',
@@ -197,6 +200,11 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
197
200
  keybinding: 'Ctrl+Shift+F', available: alwaysAvailable,
198
201
  run: () => emit('searchInFiles'),
199
202
  },
203
+ {
204
+ id: 'edrv.toggleMarkdownPreview', label: '切换 Markdown 预览', category: '视图', order: 25,
205
+ keybinding: 'Ctrl+Shift+V', available: alwaysAvailable,
206
+ run: () => emit('toggleMarkdownPreview'),
207
+ },
200
208
  {
201
209
  id: 'edrv.showLogs', label: '查看诊断日志', category: '视图', order: 30,
202
210
  available: alwaysAvailable,
@@ -256,6 +264,51 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
256
264
  ...svnPaletteDefs(),
257
265
  ]
258
266
 
267
+ /**
268
+ * 调试(DAP)命令组:全部桥接派发,EditorView 统一接线调 dapStore。
269
+ * 可用性:步进类仅在暂停态可用(无编辑器时不吞键);F5 需有配置或已暂停。
270
+ * @author ddj 2026年09月29号
271
+ * @returns 命令定义数组
272
+ */
273
+ function debugPaletteDefs(): CommandDef[] {
274
+ const phase = () => dapStore.getSnapshot().phase
275
+ const paused = () => phase() === 'paused'
276
+ const canStart = () => paused() || dapStore.getSnapshot().configs.length > 0
277
+ const canStop = () => phase() !== 'idle' && phase() !== 'terminated'
278
+ return [
279
+ {
280
+ id: 'edrv.debugToggleBreakpoint', label: '调试:切换断点(光标行)', category: '调试', order: 10,
281
+ keybinding: 'F9', available: needsModel,
282
+ run: () => emit('debugToggleBreakpoint'),
283
+ },
284
+ {
285
+ id: 'edrv.debugStartContinue', label: '调试:启动 / 继续', category: '调试', order: 20,
286
+ keybinding: 'F5', available: canStart,
287
+ run: () => emit('debugStartContinue'),
288
+ },
289
+ {
290
+ id: 'edrv.debugStepOver', label: '调试:单步跳过', category: '调试', order: 30,
291
+ keybinding: 'F10', available: paused,
292
+ run: () => emit('debugStepOver'),
293
+ },
294
+ {
295
+ id: 'edrv.debugStepInto', label: '调试:单步步入', category: '调试', order: 40,
296
+ keybinding: 'F11', available: paused,
297
+ run: () => emit('debugStepInto'),
298
+ },
299
+ {
300
+ id: 'edrv.debugStepOut', label: '调试:单步步出', category: '调试', order: 50,
301
+ keybinding: 'Shift+F11', available: paused,
302
+ run: () => emit('debugStepOut'),
303
+ },
304
+ {
305
+ id: 'edrv.debugStop', label: '调试:停止', category: '调试', order: 60,
306
+ keybinding: 'Shift+F5', available: canStop,
307
+ run: () => emit('debugStop'),
308
+ },
309
+ ]
310
+ }
311
+
259
312
  /**
260
313
  * 桥接派发指令(无原生监听,键位由 commandBridge 统一 capture 处理)。
261
314
  * 新增「只填目录、不写监听」的编辑器指令一律放这里:键位、命令栏、设置页自动可用。
@@ -266,6 +319,7 @@ export const BRIDGE_COMMANDS: readonly CommandDef[] = [
266
319
  prevEditorRowDef(),
267
320
  addSelectionRefDef(),
268
321
  closeTabDef(),
322
+ ...debugPaletteDefs(),
269
323
  ]
270
324
 
271
325
  /**
@@ -0,0 +1,85 @@
1
+ /**
2
+ * dsh-vscode-mode client — 溢出容器的滚轮横向滚动(页签栏用)。
3
+ *
4
+ * 需求 3:页签栏出现水平滚动条时,用鼠标滚轮(垂直滚动轮)也能横向滚动。
5
+ * 浏览器对 `overflow-x: auto` 的容器**不会**把垂直滚轮折算成横向滚动(只有原生横向
6
+ * 滚轮/触控板横滑或 Shift+滚轮才横滚),故需要显式接管。
7
+ *
8
+ * 设计取舍:
9
+ * - **不溢出就不接管**:内容放得下时滚轮必须保持页面垂直滚动语义,绝不能 preventDefault。
10
+ * - **Shift+滚轮不接管**:Chrome/Edge 对横向溢出容器原生支持 Shift+滚轮横滚,
11
+ * 我们再次处理会导致位移翻倍。
12
+ * - 触控板的横向手势(deltaX)与纵向手势(deltaY)都按主分量取用。
13
+ *
14
+ * `wheelScrollStep` 为纯函数(node 可测);`attachHorizontalWheel` 只做 DOM 装配。
15
+ * 作者 ddj 2026年09月18号
16
+ */
17
+
18
+ /** 行模式(deltaMode === 1)下一「行」折算的像素数(主流浏览器约 16px 行高)。 */
19
+ const LINE_HEIGHT_PX = 16
20
+
21
+ /** 滚轮输入(DOM WheelEvent 的相关字段子集,便于单测构造)。 */
22
+ export interface WheelInput {
23
+ deltaX: number
24
+ deltaY: number
25
+ /** 0 = 像素 / 1 = 行 / 2 = 页。 */
26
+ deltaMode: number
27
+ shiftKey: boolean
28
+ }
29
+
30
+ /** 容器几何(只需可滚宽度与可视宽度)。 */
31
+ export interface WheelGeometry {
32
+ scrollWidth: number
33
+ clientWidth: number
34
+ }
35
+
36
+ /**
37
+ * 计算一次滚轮事件应产生的横向位移。
38
+ * @author ddj 2026年09月18号
39
+ * @param input 滚轮事件字段
40
+ * @param geometry 容器几何(未溢出时返回 0,即不接管)
41
+ * @returns 横向位移(px,带符号);0 表示本事件不处理(调用方不得 preventDefault)
42
+ */
43
+ export function wheelScrollStep(input: WheelInput, geometry: WheelGeometry): number {
44
+ const scrollable = Number(geometry?.scrollWidth) - Number(geometry?.clientWidth)
45
+ if (!Number.isFinite(scrollable) || scrollable <= 0) return 0
46
+ // Shift+滚轮交给浏览器原生横滚(处理两次会翻倍)
47
+ if (input?.shiftKey === true) return 0
48
+ const dx = Number(input?.deltaX) || 0
49
+ const dy = Number(input?.deltaY) || 0
50
+ const raw = Math.abs(dx) >= Math.abs(dy) ? dx : dy
51
+ if (!raw) return 0
52
+ const mode = input?.deltaMode | 0
53
+ if (mode === 1) return Math.round(raw * LINE_HEIGHT_PX)
54
+ if (mode === 2) return Math.round(raw * Number(geometry.clientWidth || 0))
55
+ return Math.round(raw)
56
+ }
57
+
58
+ /** 可挂载滚轮的节点最小形状(浏览器元素或测试替身)。 */
59
+ export interface WheelTarget {
60
+ addEventListener: (type: string, listener: (event: unknown) => void, options?: unknown) => void
61
+ removeEventListener: (type: string, listener: (event: unknown) => void, options?: unknown) => void
62
+ }
63
+
64
+ /**
65
+ * 给横向溢出容器挂上「垂直滚轮驱动横向滚动」。
66
+ * 监听器为 `passive: false`(需要 preventDefault 阻止页面滚动),仅在需要位移时阻止默认行为。
67
+ * @author ddj 2026年09月18号
68
+ * @param node 目标元素(空则返回空注销器,不做任何事)
69
+ * @returns 注销函数(幂等调用安全)
70
+ */
71
+ export function attachHorizontalWheel(node: WheelTarget | null | undefined): () => void {
72
+ if (!node || typeof node.addEventListener !== 'function') return () => {}
73
+ const onWheel = (event: unknown): void => {
74
+ const e = event as WheelInput & { preventDefault?: () => void }
75
+ const step = wheelScrollStep(
76
+ { deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode, shiftKey: e.shiftKey },
77
+ { scrollWidth: (node as unknown as { scrollWidth: number }).scrollWidth, clientWidth: (node as unknown as { clientWidth: number }).clientWidth },
78
+ )
79
+ if (!step) return
80
+ e.preventDefault?.()
81
+ ;(node as unknown as { scrollLeft: number }).scrollLeft += step
82
+ }
83
+ node.addEventListener('wheel', onWheel, { passive: false })
84
+ return () => node.removeEventListener('wheel', onWheel, { passive: false })
85
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * dsh-vscode-mode client — 前端导出工具(W1-4:变更列表/日志列表 → CSV/HTML 文件下载)。
3
+ *
4
+ * 纯前端拼装:Blob + 隐式 `<a download>` 触发,不经 host、不写工作副本;
5
+ * CSV 前置 UTF-8 BOM 保证 Excel 直开中文不乱码;导出物只在本地,不含凭据、不外发。
6
+ * 下载失败(无 Blob/下载权限等)静默返回 false,由调用方决定是否提示。
7
+ * 作者 ddj 2026年09月20号
8
+ */
9
+
10
+ /**
11
+ * 二维表 → CSV 文本(RFC 4180:含逗号/引号/换行的字段加引号包裹、内部引号翻倍;行尾 CRLF)。
12
+ * @author ddj 2026年09月20号
13
+ * @param rows 二维字符串表(表头行由调用方一并传入)
14
+ * @returns CSV 文本(前置 UTF-8 BOM)
15
+ */
16
+ export function csvOf(rows: unknown[][]): string {
17
+ const esc = (cell: unknown): string => {
18
+ const text = String(cell ?? '')
19
+ return /[",\r\n]/.test(text) ? '"' + text.replaceAll('"', '""') + '"' : text
20
+ }
21
+ const body = (rows || []).map((row) => (row || []).map(esc).join(',')).join('\r\n')
22
+ return '\uFEFF' + body + '\r\n'
23
+ }
24
+
25
+ /**
26
+ * 二维表 → 简单 HTML 报表(W1-4;全部经 HTML 转义防注入,UTF-8 声明 + 内联样式)。
27
+ * @author ddj 2026年09月20号
28
+ * @param headers 表头字符串数组
29
+ * @param rows 二维字符串表
30
+ * @param caption 页面标题(可空)
31
+ * @returns 完整 HTML 文档文本
32
+ */
33
+ export function htmlTableOf(headers: unknown[], rows: unknown[][], caption?: string): string {
34
+ const esc = (value: unknown): string => String(value ?? '')
35
+ .replace(/&/g, '&amp;')
36
+ .replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;')
38
+ .replace(/"/g, '&quot;')
39
+ const head = '<tr>' + (headers || []).map((cell) => '<th>' + esc(cell) + '</th>').join('') + '</tr>'
40
+ const body = (rows || []).map((row) => '<tr>' + (row || []).map((cell) => '<td>' + esc(cell) + '</td>').join('') + '</tr>').join('')
41
+ return '<!doctype html><html><head><meta charset="utf-8"><title>' + esc(caption || '导出') + '</title>'
42
+ + '<style>body{font-family:system-ui,sans-serif;font-size:13px;color:#1f2933;margin:16px}'
43
+ + 'table{border-collapse:collapse}th,td{border:1px solid #d5dbdb;padding:2px 8px;text-align:left;vertical-align:top}'
44
+ + 'th{background:#f0f4f4}h3{margin:0 0 10px}</style>'
45
+ + '</head><body><h3>' + esc(caption || '') + '</h3><table>' + head + body + '</table></body></html>'
46
+ }
47
+
48
+ /**
49
+ * 文本下载(Blob + `a[download]` 一次点击;异步 revoke 免内存泄漏)。
50
+ * @author ddj 2026年09月20号
51
+ * @param filename 下载文件名(建议带扩展名)
52
+ * @param text 文件内容(文本直接写入,CSV 已含 BOM 由 csvOf 负责)
53
+ * @param mime MIME 类型(不带 charset,函数内补 utf-8)
54
+ * @returns 是否触发成功(false = 浏览器能力缺失,调用方可提示)
55
+ */
56
+ export function downloadText(filename: string, text: string, mime: string): boolean {
57
+ try {
58
+ const blob = new Blob([text], { type: mime + ';charset=utf-8' })
59
+ const url = URL.createObjectURL(blob)
60
+ const link = document.createElement('a')
61
+ link.href = url
62
+ link.download = filename
63
+ document.body.appendChild(link)
64
+ link.click()
65
+ link.remove()
66
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
67
+ return true
68
+ } catch (error) {
69
+ return false
70
+ }
71
+ }
@@ -34,4 +34,38 @@ declare module '@deepseek-ai/dsh-client-ui-primitives' {
34
34
  export const IconListPenOutline16: (props: EdrvIconProps) => unknown
35
35
  /** 代码图标(活动栏「大纲」)。 */
36
36
  export const IconCodeOutline16: (props: EdrvIconProps) => unknown
37
+
38
+ /** 代码块复制按钮文案(MarkdownText 的 labels.code)。 */
39
+ export interface MarkdownCodeLabels {
40
+ /** 复制按钮空闲态文案。 */
41
+ copyLabel: string
42
+ /** 复制后确认窗口期文案。 */
43
+ copiedLabel: string
44
+ }
45
+
46
+ /** Markdown 渲染的本地化 chrome(labels 引用须稳定,否则流式渲染缓存失效)。 */
47
+ export interface MarkdownLabels {
48
+ code: MarkdownCodeLabels
49
+ /** 脚注段落标题。 */
50
+ footnotes: string
51
+ }
52
+
53
+ /**
54
+ * Markdown 渲染原语(GFM + KaTeX;raw HTML 与不安全协议被禁用)。
55
+ * `streaming: false` 走一次性完整解析;`variant: 'body'` 为文档级排版。
56
+ * 旧版 DSH 可能无此导出,调用方须做 typeof 守卫并降级。
57
+ */
58
+ export const MarkdownText: (props: {
59
+ text: string
60
+ streaming?: boolean
61
+ labels: MarkdownLabels
62
+ variant?: 'body' | 'compact'
63
+ }) => unknown
64
+
65
+ /** Markdown 导航作用域:普通外链与本地文件链接的打开能力(不提供则保持原生/纯文本)。 */
66
+ export function MarkdownDelegateProvider(props: {
67
+ children?: unknown
68
+ openExternalLink?: (href: string) => void
69
+ openFile?: (path: string, options?: { line?: number }) => void
70
+ }): unknown
37
71
  }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * dsh-vscode-mode host — launch.json「添加配置」片段提取。
3
+ * 扫描 VSCode 扩展清单 contributes.debuggers[]:
4
+ * - configurationSnippets[](label/description/body)优先;缺失时 initialConfigurations[] 兜底
5
+ * (条目本身就是配置体,label 取 body.name → debugger.label → type);
6
+ * - %key% 占位符按 package.nls.json → package.nls.zh-cn.json 合并表解析(zh 覆盖 base),
7
+ * 解析失败剥 %…% 外壳(避免裸占位符上屏);
8
+ * - body 缺 type/name 时补齐(parseLaunchConfigs 要求两者,缺了插进去也不可见)。
9
+ * 与 discovery 各自独立缓存(片段随扩展安装变化;force 重扫,测试隔离)。
10
+ * 作者 ddj 2026年09月22号
11
+ */
12
+ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { vscodeExtensionsDirs } from '../lsp/providers.js'
15
+ import { dshHome } from '../paths.js'
16
+ import type { DapAdapterDecl, DapConfigSnippet } from '../shared/dap.js'
17
+
18
+ /** 清单 debuggers 条目(片段提取消费的子集)。 */
19
+ interface SnippetDebuggerEntry {
20
+ type?: unknown
21
+ label?: unknown
22
+ configurationSnippets?: unknown
23
+ initialConfigurations?: unknown
24
+ }
25
+
26
+ /** 归一后的片段原料(nls 解析前)。 */
27
+ interface RawSnippetItem {
28
+ label?: string
29
+ description?: string
30
+ body: Record<string, unknown>
31
+ }
32
+
33
+ /** nls 键值表(package.nls*.json 扁平结构)。 */
34
+ type NlsTable = Record<string, string>
35
+
36
+ /** 发现缓存(null = 未加载;按 home 隔离,测试用 force/clear 复位)。 */
37
+ let cache: DapConfigSnippet[] | null = null
38
+ let cacheHome = ''
39
+
40
+ // --region 片段发现
41
+
42
+ /**
43
+ * 全部可添加调试配置片段(按 label 字典序稳定输出,type+label 去重)。
44
+ * @author ddj 2026年09月22号
45
+ * @param force 强制重扫(扩展安装后 / 测试隔离用)
46
+ * @param home DSH home(缺省真实;测试可注入)
47
+ * @returns 片段列表
48
+ */
49
+ export function dapConfigSnippets(force = false, home = dshHome()): DapConfigSnippet[] {
50
+ if (!force && cache !== null && home === cacheHome) return cache
51
+ cache = scanSnippets(home)
52
+ cacheHome = home
53
+ return cache
54
+ }
55
+
56
+ /** 清空片段缓存(扩展安装后 / 测试 afterEach 复位)。 */
57
+ export function clearSnippetCache(): void {
58
+ cache = null
59
+ cacheHome = ''
60
+ }
61
+
62
+ /**
63
+ * 按适配器可用性标注片段(与调试工具条同源裁决 debuggerDecls):
64
+ * type 命中 decl → 透传 available/reason;未命中(清单无该 type 的可用适配器声明)
65
+ * → available=false +「未发现该类型适配器」。下拉据此过滤「选得进、跑不了」的条目。
66
+ * @author ddj 2026年09月22号
67
+ * @param snippets 片段列表(dapConfigSnippets 产物)
68
+ * @param decls 适配器声明表(debuggerDecls())
69
+ * @returns 带可用性标注的片段列表(新数组,不改入参)
70
+ */
71
+ export function annotateSnippets(
72
+ snippets: readonly DapConfigSnippet[],
73
+ decls: readonly DapAdapterDecl[],
74
+ ): DapConfigSnippet[] {
75
+ const byType = new Map(decls.map((decl) => [decl.type, decl]))
76
+ return snippets.map((snip) => {
77
+ const decl = byType.get(snip.type)
78
+ if (!decl) {
79
+ return { ...snip, available: false, reason: '未发现该类型适配器' }
80
+ }
81
+ const annotated: DapConfigSnippet = { ...snip, available: decl.available }
82
+ if (!decl.available && decl.reason) annotated.reason = decl.reason
83
+ return annotated
84
+ })
85
+ }
86
+
87
+ /** 扫描全部扩展根收集片段(先到先得去重,输出按 label 排序)。 */
88
+ function scanSnippets(home: string): DapConfigSnippet[] {
89
+ const out: DapConfigSnippet[] = []
90
+ const seen = new Set<string>()
91
+ for (const root of vscodeExtensionsDirs(home)) {
92
+ for (const extDir of subDirsOf(root)) {
93
+ collectExt(extDir, out, seen)
94
+ }
95
+ }
96
+ return out.sort((a, b) => a.label.localeCompare(b.label))
97
+ }
98
+
99
+ /** 单扩展处理:有产出原料才读 nls(多数扩展无调试片段,不白读两个小文件)。 */
100
+ function collectExt(extDir: string, out: DapConfigSnippet[], seen: Set<string>): void {
101
+ const entries = debuggersOf(extDir)
102
+ const plans = entries
103
+ .map((entry) => ({ entry, type: strOf(entry.type), items: rawItemsOf(entry) }))
104
+ .filter((plan) => Boolean(plan.type) && plan.items.length > 0)
105
+ if (!plans.length) return
106
+ const nls = readNlsTable(extDir)
107
+ for (const plan of plans) {
108
+ for (const item of plan.items) {
109
+ const snippet = projectSnippet(plan.entry, plan.type, item, nls)
110
+ const key = plan.type + '\u0000' + snippet.label
111
+ if (seen.has(key)) continue
112
+ seen.add(key)
113
+ out.push(snippet)
114
+ }
115
+ }
116
+ }
117
+
118
+ /** 清单条目 → 片段投影(nls 解析 + type/name 补齐)。 */
119
+ function projectSnippet(
120
+ entry: SnippetDebuggerEntry,
121
+ type: string,
122
+ item: RawSnippetItem,
123
+ nls: NlsTable,
124
+ ): DapConfigSnippet {
125
+ const body = resolveNlsDeep(item.body, nls) as Record<string, unknown>
126
+ if (!strOf(body.type)) body.type = type
127
+ const label = resolveNlsText(item.label ?? '', nls)
128
+ || strOf(body.name)
129
+ || resolveNlsText(strOf(entry.label), nls)
130
+ || type
131
+ if (!strOf(body.name)) body.name = label
132
+ const snippet: DapConfigSnippet = {
133
+ label,
134
+ type: strOf(body.type) || type,
135
+ bodyText: JSON.stringify(body, null, 2),
136
+ }
137
+ const description = resolveNlsText(item.description ?? '', nls)
138
+ if (description) snippet.description = description
139
+ return snippet
140
+ }
141
+
142
+ // --endregion
143
+
144
+ // --region 清单与原料归一
145
+
146
+ /** 读扩展根下的扩展子目录(根不存在返回空;与 discovery 同口径)。 */
147
+ function subDirsOf(root: string): string[] {
148
+ let entries: Dirent[]
149
+ try {
150
+ entries = readdirSync(root, { withFileTypes: true })
151
+ } catch {
152
+ return []
153
+ }
154
+ return entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name))
155
+ }
156
+
157
+ /** 读某扩展的 contributes.debuggers(无清单/无声明/解析失败返回空)。 */
158
+ function debuggersOf(extDir: string): SnippetDebuggerEntry[] {
159
+ try {
160
+ const text = readFileSync(join(extDir, 'package.json'), 'utf8').replace(/^/, '')
161
+ const manifest = JSON.parse(text) as { contributes?: { debuggers?: unknown } }
162
+ const list = manifest.contributes?.debuggers
163
+ return Array.isArray(list) ? (list as SnippetDebuggerEntry[]) : []
164
+ } catch {
165
+ return []
166
+ }
167
+ }
168
+
169
+ /** 条目 → 原料列表:configurationSnippets 优先,initialConfigurations 兜底(条目即配置体)。 */
170
+ function rawItemsOf(entry: SnippetDebuggerEntry): RawSnippetItem[] {
171
+ const out: RawSnippetItem[] = []
172
+ const snippets = asArray(entry.configurationSnippets)
173
+ for (const raw of snippets) {
174
+ const snippet = asRecord(raw)
175
+ if (!snippet) continue
176
+ const body = asRecord(snippet.body)
177
+ if (!body) continue
178
+ out.push({ label: strOf(snippet.label), description: strOf(snippet.description), body })
179
+ }
180
+ if (out.length) return out
181
+ for (const raw of asArray(entry.initialConfigurations)) {
182
+ const body = asRecord(raw)
183
+ if (body) out.push({ body })
184
+ }
185
+ return out
186
+ }
187
+
188
+ // --endregion
189
+
190
+ // --region nls 占位符解析
191
+
192
+ /**
193
+ * 读扩展 nls 表(base 先入,zh-cn 后入覆盖;文件缺失/解析失败按空跳过)。
194
+ * @author ddj 2026年09月22号
195
+ * @param extDir 扩展根目录
196
+ * @returns %key% → 文本 表
197
+ */
198
+ function readNlsTable(extDir: string): NlsTable {
199
+ const table: NlsTable = {}
200
+ for (const file of ['package.nls.json', 'package.nls.zh-cn.json']) {
201
+ try {
202
+ const path = join(extDir, file)
203
+ if (!existsSync(path)) continue
204
+ const data = JSON.parse(readFileSync(path, 'utf8').replace(/^/, '')) as Record<string, unknown>
205
+ for (const [key, value] of Object.entries(data)) {
206
+ if (typeof value === 'string') table[key] = value
207
+ }
208
+ } catch {
209
+ /* 单个 nls 文件损坏按无表处理,不阻断提取 */
210
+ }
211
+ }
212
+ return table
213
+ }
214
+
215
+ /**
216
+ * 解析单串 %key% 占位(最多 3 轮:nls 值可再含占位;未命中剥 %…% 外壳)。
217
+ * @author ddj 2026年09月22号
218
+ * @param value 原文
219
+ * @param table nls 表
220
+ * @returns 解析后文本(无占位原样返回)
221
+ */
222
+ function resolveNlsText(value: string, table: NlsTable): string {
223
+ if (!value.includes('%')) return value
224
+ let out = value
225
+ for (let round = 0; round < 3; round += 1) {
226
+ const next = out.replace(/%([A-Za-z0-9_.-]+)%/g, (_match, key: string) => table[key] ?? key)
227
+ if (next === out) break
228
+ out = next
229
+ }
230
+ return out
231
+ }
232
+
233
+ /** 深度解析任意值内的 %key%(字符串逐个过 resolveNlsText,对象/数组递归)。 */
234
+ function resolveNlsDeep(value: unknown, table: NlsTable): unknown {
235
+ if (typeof value === 'string') return resolveNlsText(value, table)
236
+ if (Array.isArray(value)) return value.map((item) => resolveNlsDeep(item, table))
237
+ if (value && typeof value === 'object') {
238
+ const out: Record<string, unknown> = {}
239
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
240
+ out[key] = resolveNlsDeep(item, table)
241
+ }
242
+ return out
243
+ }
244
+ return value
245
+ }
246
+
247
+ // --endregion
248
+
249
+ // --region 小工具
250
+
251
+ /** unknown → 非空字符串(其余空串)。 */
252
+ function strOf(value: unknown): string {
253
+ return typeof value === 'string' && value ? value : ''
254
+ }
255
+
256
+ /** unknown → 普通对象(数组/原始值返回 null)。 */
257
+ function asRecord(value: unknown): Record<string, unknown> | null {
258
+ return value && typeof value === 'object' && !Array.isArray(value)
259
+ ? (value as Record<string, unknown>)
260
+ : null
261
+ }
262
+
263
+ /** unknown → 数组(单对象包一层,兼容 initialConfigurations 单对象写法)。 */
264
+ function asArray(value: unknown): unknown[] {
265
+ if (Array.isArray(value)) return value
266
+ return value && typeof value === 'object' ? [value] : []
267
+ }
268
+
269
+ // --endregion