dsh-vscode-mode 0.5.3 → 0.7.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 (77) hide show
  1. package/README.md +72 -2
  2. package/lib/client.js +12082 -6420
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2750 -147
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/copyText.ts +28 -0
  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/fileClipboard.ts +46 -0
  27. package/src/client/index.ts +12 -3
  28. package/src/client/monaco/lsp/index.ts +15 -0
  29. package/src/client/monaco/lsp/providers.ts +2 -0
  30. package/src/client/monaco/theme.ts +15 -0
  31. package/src/client/openWithDialog.ts +66 -0
  32. package/src/client/searchSeed.ts +36 -0
  33. package/src/client/sidebar/contextMenu.ts +51 -10
  34. package/src/client/sidebar/menuItems.ts +473 -50
  35. package/src/client/sidebar/panels/DebugPanel.ts +613 -0
  36. package/src/client/sidebar/panels/SearchPanel.ts +24 -3
  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 +8 -0
  40. package/src/client/styles/editor.css +176 -0
  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 +22 -10
  45. package/src/client/ui/EditorView.ts +625 -23
  46. package/src/client/ui/OfficialSideTab.ts +1 -0
  47. package/src/client/ui/PromptDialog.ts +63 -0
  48. package/src/client/ui/SideBySideDiff.tsx +214 -29
  49. package/src/client/ui/SideEditorTab.ts +1 -0
  50. package/src/client/ui/SvnDiffPanel.ts +21 -8
  51. package/src/client/ui/SvnLogDialog.ts +44 -2
  52. package/src/client/ui/SvnPatchDialog.ts +63 -0
  53. package/src/client/ui/SvnSumDialog.ts +77 -0
  54. package/src/client/ui/commandCatalog.ts +63 -0
  55. package/src/client/ui/svnExport.ts +71 -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/index.ts +7 -3
  66. package/src/lsp/providers.ts +3 -2
  67. package/src/reveal.ts +31 -20
  68. package/src/rpc.ts +209 -5
  69. package/src/search/ripgrep.ts +127 -11
  70. package/src/shared/dap.ts +262 -0
  71. package/src/shared/fsNames.ts +115 -0
  72. package/src/shared/keybindings.ts +9 -0
  73. package/src/shared/rpc.ts +52 -2
  74. package/src/shared/svn.ts +147 -0
  75. package/src/shared/svnActions.ts +20 -0
  76. package/src/svn.ts +320 -11
  77. package/src/workspace.ts +0 -75
package/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { Buffer as Buffer$1 } from "node:buffer";
9
- import { spawn } from "node:child_process";
9
+ import { execFile, spawn } from "node:child_process";
10
10
  import { inflateRawSync } from "node:zlib";
11
11
  //#region src/mcp.ts
12
12
  /**
@@ -130,11 +130,18 @@ const KEYBINDING_DEFAULTS = {
130
130
  "edrv.navigateForward": "Alt+ArrowRight|Ctrl+Shift+-",
131
131
  "edrv.nextTab": "Ctrl+Alt+ArrowRight|Ctrl+PageDown",
132
132
  "edrv.prevTab": "Ctrl+Alt+ArrowLeft|Ctrl+PageUp",
133
+ "edrv.goToLine": "Ctrl+G",
133
134
  "edrv.showCommands": "Ctrl+Shift+P|F1",
134
135
  "edrv.nextEditorRow": "Ctrl+Alt+ArrowDown",
135
136
  "edrv.prevEditorRow": "Ctrl+Alt+ArrowUp",
136
137
  "edrv.addSelectionRef": "Ctrl+U",
137
- "edrv.closeTab": "Ctrl+F4"
138
+ "edrv.closeTab": "Ctrl+F4",
139
+ "edrv.debugToggleBreakpoint": "F9",
140
+ "edrv.debugStartContinue": "F5",
141
+ "edrv.debugStepOver": "F10",
142
+ "edrv.debugStepInto": "F11",
143
+ "edrv.debugStepOut": "Shift+F11",
144
+ "edrv.debugStop": "Shift+F5"
138
145
  };
139
146
  //#endregion
140
147
  //#region src/shared/integration.ts
@@ -183,6 +190,23 @@ function tortoiseLaunchArgv(exe, action, absPath) {
183
190
  "/path:" + absPath
184
191
  ];
185
192
  }
193
+ /** W2-6 内容护栏嗅探窗(字符数;NUL/U+FFFD 只看前 8K,全量扫描大文本得不偿失)。 */
194
+ const GUARD_SNIFF_CHARS = 8192;
195
+ /**
196
+ * 文本级内容护栏(W2-6,纯函数):对喂给差异视图/补丁的文本做低成本嗅探。
197
+ * 二进制锚点取 NUL 字节(比 svn:mime-type 更兜底——mime 缺省的「伪文本」二进制也拦得住);
198
+ * 编码提示取 U+FFFD(host fs.readText 按 UTF-8 解码,无效序列会变成替换符)。
199
+ * @author ddj 2026年09月20号
200
+ * @param text 待嗅探文本(null/undefined 按空处理)
201
+ * @returns 护栏标记(干净内容返回空对象)
202
+ */
203
+ function contentGuardOf(text) {
204
+ if (typeof text !== "string" || !text) return {};
205
+ const window = text.slice(0, GUARD_SNIFF_CHARS);
206
+ if (window.includes("\0")) return { binary: true };
207
+ if (window.includes("�")) return { encodingHint: true };
208
+ return {};
209
+ }
186
210
  //#endregion
187
211
  //#region src/shared/logger.ts
188
212
  /**
@@ -3347,7 +3371,12 @@ function pathMatch(value, query) {
3347
3371
  const STDOUT_CAP$1 = 4 << 20;
3348
3372
  const STDERR_CAP$2 = 65536;
3349
3373
  const GRACE_MS$1 = 2e4;
3350
- /** 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。 */
3374
+ /**
3375
+ * 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。
3376
+ * 语义为**大小写敏感**的路径段匹配:`build` 只排除小写构建产物目录,
3377
+ * 不得用 `--glob-case-insensitive` 之类的全局开关,「Module/Build」这类
3378
+ * 源码目录(如 IslandSplash 的 Module/Build/Logic)必须保持可搜索。
3379
+ */
3351
3380
  const EXCLUDES = [
3352
3381
  "node_modules",
3353
3382
  ".git",
@@ -3375,13 +3404,31 @@ const FILE_EXCLUDES = [
3375
3404
  "**/*.map"
3376
3405
  ];
3377
3406
  /**
3378
- * 将 query 转成 rg 的字面 glob。
3379
- * @author ddj 2026年08月24号
3380
- * @param query 已规范化 query
3407
+ * 将查询文本转成大小写不敏感的 rg 正选 glob。
3408
+ * 逐字符把有大小写的字母展开成 `[aA]` 字符类,**不用**大小写不敏感开关:
3409
+ * ① 全局 `--glob-case-insensitive` 会把 `-g/--glob` 全部当成 `--iglob`,
3410
+ * 连带让 `!**\/build/**` 大小写不敏感,从而排除 `Module/Build/` 这类源码目录
3411
+ * (Ctrl+P 搜 BuildLogic 恒「无匹配文件」);
3412
+ * ② 把查询词改用 `--iglob` 也不行——rg 的正选 glob 会覆盖全部黑名单 glob,
3413
+ * node_modules/.git 等排除项集体失效。
3414
+ * 字符类方案同时满足「查询不区分大小写」且「排除项保持大小写敏感」。
3415
+ * @author ddj 2026年08月24号 / 2026年09月20号
3416
+ * @param query 已规范化查询文本
3381
3417
  * @returns 安全 glob
3382
3418
  */
3383
3419
  function queryGlob(query) {
3384
- return "**/*" + pathText(query).replace(/[\*?\[\]{}!]/g, (char) => "\\" + char) + "*";
3420
+ const value = pathText(query);
3421
+ let body = "";
3422
+ for (const char of value) {
3423
+ const lower = char.toLowerCase();
3424
+ const upper = char.toUpperCase();
3425
+ if (lower !== upper && lower.length === 1 && upper.length === 1) {
3426
+ body += "[" + lower + upper + "]";
3427
+ continue;
3428
+ }
3429
+ body += /[\*?\[\]{}!]/.test(char) ? "\\" + char : char;
3430
+ }
3431
+ return "**/*" + body + "*";
3385
3432
  }
3386
3433
  /**
3387
3434
  * 返回当前平台的 rg 可执行文件路径,不让可选依赖影响模块加载。
@@ -3496,32 +3543,45 @@ function rgExitFailure(code, stderrText) {
3496
3543
  };
3497
3544
  }
3498
3545
  /**
3499
- * 使用打包 ripgrep 搜索工作区文件。
3500
- * @author ddj 2026年08月24号
3501
- * @param input provider 输入
3502
- * @returns 有界搜索结果
3546
+ * 构造文件搜索 rg argv(查询 glob 大小写不敏感,排除 glob 大小写敏感)。
3547
+ * 大小写不敏感**只能**靠 queryGlob 的字符类实现:全局 `--glob-case-insensitive`
3548
+ * 会波及排除项(误排除 `Module/Build/`),查询词用 `--iglob` 又会覆盖全部黑名单 glob。
3549
+ * @author ddj 2026年09月20号
3550
+ * @param binary rg 可执行文件路径
3551
+ * @param root 搜索根目录
3552
+ * @param query 已规范化查询文本
3553
+ * @returns 完整 argv(末位为根目录)
3503
3554
  */
3504
- async function searchRipgrep(input) {
3505
- const sub = input.ctx.get("subprocess");
3506
- if (!sub) throw new Error("缺少 subprocess");
3507
- const binary = ripgrepPath();
3508
- if (!binary) throw new Error("ripgrep 不可用");
3509
- const root = input.root ?? await searchRoot(input.ctx, input.session);
3555
+ function filesArgv(binary, root, query) {
3510
3556
  const argv = [
3511
3557
  binary,
3512
3558
  "--no-config",
3513
3559
  "--files",
3514
3560
  "--hidden",
3515
3561
  "--no-ignore",
3516
- "--glob-case-insensitive",
3517
3562
  "--glob",
3518
- queryGlob(prepareQuery(input.query).text)
3563
+ queryGlob(query)
3519
3564
  ];
3520
3565
  for (const excluded of EXCLUDES) argv.push("--glob", "!**/" + excluded + "/**");
3521
3566
  for (const excluded of FILE_EXCLUDES) argv.push("--glob", "!" + excluded);
3522
3567
  argv.push("--", root);
3568
+ return argv;
3569
+ }
3570
+ /**
3571
+ * 使用打包 ripgrep 搜索工作区文件。
3572
+ * @author ddj 2026年08月24号
3573
+ * @param input provider 输入
3574
+ * @returns 有界搜索结果
3575
+ */
3576
+ async function searchRipgrep(input) {
3577
+ const sub = input.ctx.get("subprocess");
3578
+ if (!sub) throw new Error("缺少 subprocess");
3579
+ const binary = ripgrepPath();
3580
+ if (!binary) throw new Error("ripgrep 不可用");
3581
+ const root = input.root ?? await searchRoot(input.ctx, input.session);
3582
+ const query = prepareQuery(input.query);
3523
3583
  const handle = sub.spawn({
3524
- argv,
3584
+ argv: filesArgv(binary, root, query.text),
3525
3585
  cwd: root,
3526
3586
  stdio: {
3527
3587
  stdin: "ignore",
@@ -3556,6 +3616,93 @@ async function searchRipgrep(input) {
3556
3616
  };
3557
3617
  }
3558
3618
  /**
3619
+ * 构造 chunkname 定向查找的 rg argv。
3620
+ * 与 {@link filesArgv} 同源(同样的排除 glob 与大小写口径),差异只在查询 glob 由
3621
+ * chunkname 推导:chunkname 可能带 chunk 前缀(`@`)与无扩展名 stem,故先剥 `@`、
3622
+ * 取 basename、去掉扩展名后作为片段匹配,仍走 {@link queryGlob} 的字符类方案
3623
+ * ——避免 `--glob-case-insensitive` 波及排除项(见 queryGlob 注释)。
3624
+ * @author ddj 2026年09月21号
3625
+ * @param binary rg 可执行文件路径
3626
+ * @param root 搜索根目录
3627
+ * @param chunk 适配器上报的 chunkname
3628
+ * @returns 完整 argv(末位为根目录);chunk 为空时返回 null
3629
+ */
3630
+ function chunkFilesArgv(binary, root, chunk) {
3631
+ const clean = normalizeChunk(chunk);
3632
+ if (!clean) return null;
3633
+ const argv = [
3634
+ binary,
3635
+ "--no-config",
3636
+ "--files",
3637
+ "--hidden",
3638
+ "--no-ignore",
3639
+ "--glob",
3640
+ queryGlob(clean)
3641
+ ];
3642
+ for (const excluded of EXCLUDES) argv.push("--glob", "!**/" + excluded + "/**");
3643
+ for (const excluded of FILE_EXCLUDES) argv.push("--glob", "!" + excluded);
3644
+ argv.push("--", root);
3645
+ return argv;
3646
+ }
3647
+ /**
3648
+ * chunkname → 用于片段匹配的纯文件名片段。
3649
+ * 剥 `@` 前缀、统一分隔符、取 basename、去掉扩展名(xLua chunkname 惯例不含扩展名),
3650
+ * 使 `GuideSystemMgr` 能命中 `GuideSystemMgr.lua`。
3651
+ * @author ddj 2026年09月21号
3652
+ * @param chunk 适配器上报的 chunkname
3653
+ * @returns 纯片段(空输入返回空串)
3654
+ */
3655
+ function normalizeChunk(chunk) {
3656
+ const text = String(chunk ?? "").replace(/^@/, "").replaceAll("\\", "/").trim();
3657
+ if (!text) return "";
3658
+ return (text.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
3659
+ }
3660
+ /**
3661
+ * 按 chunkname 定向查找工作区文件(DAP findFile 反向匹配专用)。
3662
+ *
3663
+ * 为什么不用工作区全量文件索引:22 万文件的 Unity 工程里,全量枚举按到达序截断,
3664
+ * `Assets/Scripts/Lua/**` 会被 `GameData`/`Entitas` 等目录挤出配额,导致断点命中后
3665
+ * 无法定位源文件(实测前 6000 条中 .lua 数为 0)。此处改为让 rg 在遍历时过滤,
3666
+ * 命中规模与工作区总规模无关(实测同一工作区 0.37s 返回正确结果)。
3667
+ * @author ddj 2026年09月21号
3668
+ * @param ctx DSH 上下文
3669
+ * @param root 工作区根(subprocess 可访问路径)
3670
+ * @param chunk 适配器上报的 chunkname
3671
+ * @param maxResults 候选上限
3672
+ * @returns 绝对路径候选(任何失败都返回空数组,不抛错)
3673
+ */
3674
+ async function findFilesByChunk(ctx, root, chunk, maxResults = 50) {
3675
+ const sub = ctx.get("subprocess");
3676
+ const binary = ripgrepPath();
3677
+ if (!sub || !binary || !root) return [];
3678
+ const argv = chunkFilesArgv(binary, root, chunk);
3679
+ if (!argv) return [];
3680
+ let handle;
3681
+ try {
3682
+ handle = sub.spawn({
3683
+ argv,
3684
+ cwd: root,
3685
+ stdio: {
3686
+ stdin: "ignore",
3687
+ stdout: { maxBytes: STDOUT_CAP$1 },
3688
+ stderr: { maxBytes: STDERR_CAP$2 }
3689
+ },
3690
+ graceMs: GRACE_MS$1
3691
+ });
3692
+ } catch (error) {
3693
+ return [];
3694
+ }
3695
+ let code;
3696
+ try {
3697
+ const outcome = await handle.done;
3698
+ code = outcome.exitCode ?? outcome.code;
3699
+ } catch (error) {
3700
+ return [];
3701
+ }
3702
+ if (code !== 0 && code !== 1 && code !== 2) return [];
3703
+ return parseOutput(handle, maxResults).files;
3704
+ }
3705
+ /**
3559
3706
  * 构造 ripgrep provider。
3560
3707
  * @author ddj 2026年08月24号
3561
3708
  * @returns provider 实例
@@ -6524,14 +6671,114 @@ function pendingState(token, take) {
6524
6671
  function clientsAlive() {
6525
6672
  return Date.now() - lastPollAt <= PRESENCE_WINDOW_MS;
6526
6673
  }
6674
+ /**
6675
+ * 路径归一:去首尾空白 + 反斜杠转 '/'(Windows 输入等价处理)。
6676
+ * @author ddj 2026年09月22号
6677
+ * @param raw 原始输入
6678
+ * @returns 归一后的字符串
6679
+ */
6680
+ function normPath(raw) {
6681
+ return String(raw ?? "").trim().replace(/\\/g, "/");
6682
+ }
6683
+ /**
6684
+ * 单个名称段的通用违规检查(两套校验共用)。
6685
+ * @author ddj 2026年09月22号
6686
+ * @param seg 名称段
6687
+ * @returns 错误文案;null = 通过
6688
+ */
6689
+ function checkSegment(seg) {
6690
+ if (!seg) return "名称包含空路径段";
6691
+ if (seg === "." || seg === "..") return "名称不能包含 . 或 .. 路径段";
6692
+ if (seg.length > 255) return "名称过长(单段超过 255 字符)";
6693
+ if (/[\0-\x1f]/.test(seg)) return "名称包含非法控制字符";
6694
+ return null;
6695
+ }
6696
+ /**
6697
+ * 校验新建文件/文件夹的相对路径(允许 a/b.c 嵌套段)。
6698
+ * @author ddj 2026年09月22号
6699
+ * @param raw 用户输入的名称或相对路径
6700
+ * @returns 错误文案;null = 校验通过
6701
+ */
6702
+ function checkNewName(raw) {
6703
+ const text = normPath(raw);
6704
+ if (!text) return "名称不能为空";
6705
+ if (text.startsWith("/")) return "名称不能是绝对路径";
6706
+ if (/^[a-zA-Z]:/.test(text)) return "名称不能带盘符";
6707
+ for (const seg of text.split("/")) {
6708
+ const bad = checkSegment(seg);
6709
+ if (bad) return bad;
6710
+ }
6711
+ return null;
6712
+ }
6713
+ /**
6714
+ * 校验重命名的新名称(仅单个名称段,不接受路径)。
6715
+ * @author ddj 2026年09月22号
6716
+ * @param raw 用户输入的新名称
6717
+ * @returns 错误文案;null = 校验通过
6718
+ */
6719
+ function checkRenameName(raw) {
6720
+ const text = normPath(raw);
6721
+ if (!text) return "名称不能为空";
6722
+ if (text.includes("/")) return "新名称不能包含路径分隔符";
6723
+ return checkSegment(text);
6724
+ }
6725
+ /**
6726
+ * 拼接工作区相对路径(dir 为空 = 根;name 新建时可含多段)。
6727
+ * @author ddj 2026年09月22号
6728
+ * @param dir 目标目录(工作区相对)
6729
+ * @param name 名称
6730
+ * @returns 拼接后的相对路径
6731
+ */
6732
+ function joinRelPath(dir, name) {
6733
+ const base = normPath(dir).replace(/\/+$/, "");
6734
+ const add = normPath(name).replace(/^\/+/, "");
6735
+ return base ? base + "/" + add : add;
6736
+ }
6737
+ /**
6738
+ * 取相对路径的父目录(顶层条目返回空串 = 根)。
6739
+ * @author ddj 2026年09月22号
6740
+ * @param path 工作区相对路径
6741
+ * @returns 父目录相对路径
6742
+ */
6743
+ function parentRelOf(path) {
6744
+ const parts = normPath(path).split("/").filter(Boolean);
6745
+ parts.pop();
6746
+ return parts.join("/");
6747
+ }
6748
+ /**
6749
+ * 取相对路径的名称段(末段)。
6750
+ * @author ddj 2026年09月22号
6751
+ * @param path 工作区相对路径
6752
+ * @returns 末段名称
6753
+ */
6754
+ function baseNameOf(path) {
6755
+ return normPath(path).split("/").filter(Boolean).pop() ?? "";
6756
+ }
6757
+ /**
6758
+ * other 是否为 base 本身或其内部路径(复制/移动「不能以自身或其子目录为目标」护栏)。
6759
+ * @author ddj 2026年09月22号
6760
+ * @param base 基准相对路径(源)
6761
+ * @param other 待判定相对路径(目标目录)
6762
+ * @returns 是否为自身或子路径
6763
+ */
6764
+ function isSubPath(base, other) {
6765
+ const b = normPath(base).replace(/\/+$/, "");
6766
+ const o = normPath(other).replace(/\/+$/, "");
6767
+ if (!b) return false;
6768
+ return o === b || o.startsWith(b + "/");
6769
+ }
6527
6770
  //#endregion
6528
6771
  //#region src/reveal.ts
6529
6772
  /**
6530
6773
  * dsh-vscode-mode host — 在 OS 文件浏览器中打开/定位路径(reveal 能力)。
6531
- * 纯函数 revealCommand 平台分发 + revealInExplorer 经 ctx.subprocess.spawn 发射
6532
- * (argv 数组、无 shell 插值,沿 workspace/revert 的 subprocess 契约)。
6774
+ * 纯函数 revealCommand 平台分发 + revealSpawnOpts 启动选项 + revealInExplorer 发射。
6533
6775
  * 文件 → 资源管理器选中定位;目录 → 打开目录;Linux 无通用定位协议 → 打开所在目录。
6534
- * 作者 ddj 2026-08-27
6776
+ *
6777
+ * 刻意**不经** ctx.subprocess.spawn:DSH 的 Windows Job runner 硬编码
6778
+ * `windowsHide: true`(dsh-subprocess-local 的 launchWindowsJob 与 runner 内部 spawn),
6779
+ * 连 explorer.exe 的 GUI 窗口一并隐藏——RPC 回 ok:true 但窗口根本不出现。
6780
+ * GUI 拉起属 fire-and-forget,用 node child_process + detached + unref 才贴合语义。
6781
+ * 作者 ddj 2026年08月27号 / 2026年09月20号
6535
6782
  */
6536
6783
  /**
6537
6784
  * 构造平台 opener 的 argv(纯函数,platform 可注入便于单测)。
@@ -6557,32 +6804,47 @@ function revealCommand(absPath, isDir, platform = process.platform) {
6557
6804
  }
6558
6805
  }
6559
6806
  /**
6560
- * 经 host subprocess 服务发射 opener(fire-and-forget 语义)。
6561
- * Explorer 为 GUI 分离进程,非零退出码不视为失败;仅 spawn 级错误回失败。
6562
- * @author ddj 2026年08月27号
6563
- * @param ctx DSH host 上下文
6807
+ * 构造 opener 子进程启动选项。
6808
+ * `windowsHide` 必须为 **false**:置 true 会把 Explorer 等 GUI 窗口一起隐藏,
6809
+ * 症状即「RPC 回 ok:true 但窗口不出现」(本文件头注释记录的回归)。
6810
+ * @author ddj 2026年09月20号
6811
+ * @param cwd 子进程工作目录(打开文件时用其父目录)
6812
+ * @returns child_process.spawn 选项
6813
+ */
6814
+ function revealSpawnOpts(cwd) {
6815
+ return {
6816
+ cwd,
6817
+ stdio: "ignore",
6818
+ windowsHide: false,
6819
+ detached: true
6820
+ };
6821
+ }
6822
+ /**
6823
+ * 发射 opener 拉起 OS 文件浏览器(fire-and-forget 语义)。
6824
+ * Explorer 为 GUI 分离进程,非零退出码不视为失败,故只等 spawn 事件即返回;
6825
+ * 子进程脱离父进程组并 unref,避免阻塞宿主退出。
6826
+ * @author ddj 2026年08月27号 / 2026年09月20号
6564
6827
  * @param absPath 绝对路径
6565
6828
  * @param isDir 是否为目录
6566
6829
  * @returns 成功或失败原因
6567
6830
  */
6568
- async function revealInExplorer(ctx, absPath, isDir) {
6569
- const sub = ctx.get("subprocess");
6570
- if (!sub || typeof sub.spawn !== "function") return {
6831
+ async function revealInExplorer(absPath, isDir) {
6832
+ const [program, ...args] = revealCommand(absPath, isDir).argv;
6833
+ if (!program) return {
6571
6834
  ok: false,
6572
- error: "打开文件浏览器不可用:缺少 subprocess 服务"
6835
+ error: "打开失败:opener 命令为空"
6573
6836
  };
6574
- const { argv } = revealCommand(absPath, isDir);
6575
6837
  try {
6576
- await sub.spawn({
6577
- argv,
6578
- cwd: dirname(absPath),
6579
- stdio: {
6580
- stdout: { maxBytes: 65536 },
6581
- stderr: { maxBytes: 65536 },
6582
- stdin: "ignore"
6583
- },
6584
- graceMs: 1e4
6585
- }).done;
6838
+ await new Promise((resolve, reject) => {
6839
+ const child = spawn(program, args, revealSpawnOpts(dirname(absPath)));
6840
+ child.once("spawn", () => {
6841
+ child.unref();
6842
+ resolve();
6843
+ });
6844
+ child.once("error", (error) => {
6845
+ reject(error);
6846
+ });
6847
+ });
6586
6848
  return { ok: true };
6587
6849
  } catch (error) {
6588
6850
  return {
@@ -7374,12 +7636,110 @@ async function integrationBaseUrlOf(ctx) {
7374
7636
  const value = (ctx.get("settings")?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
7375
7637
  return (typeof value?.integrationBaseUrl === "string" ? value.integrationBaseUrl.trim() : "") || "http://127.0.0.1:3080";
7376
7638
  }
7639
+ /**
7640
+ * 文件操作目标解析:工作区相对路径 → 绝对路径,并做工作区边界检查。
7641
+ * edrv.fs.* 各方法共用(对齐 edrv.saveBinary 的 contains 护栏口径)。
7642
+ * @author ddj 2026年09月22号
7643
+ * @param ctx DSH 上下文
7644
+ * @param session 当前会话
7645
+ * @param rel 工作区相对路径
7646
+ * @returns 绝对路径;缺 fs/越界/解析失败返回错误文案
7647
+ */
7648
+ async function fsOpsTarget(ctx, session, rel) {
7649
+ const fs = ctx.get("fs");
7650
+ if (!fs) return { err: "缺少 fs" };
7651
+ try {
7652
+ const target = await resolveTarget(ctx, session, rel);
7653
+ const rootTarget = await fs.resolve(policyOf(ctx, session)?.workspaceRoot ?? ".", {});
7654
+ if (!fs.contains(rootTarget, target)) return { err: "拒绝操作:目标不在会话工作区内" };
7655
+ return { abs: fs.processPath(target) };
7656
+ } catch (error) {
7657
+ return { err: "路径解析失败:" + String(error) };
7658
+ }
7659
+ }
7660
+ /**
7661
+ * 取异常的系统错误码(EEXIST/EXDEV 等分支判定用)。
7662
+ * @author ddj 2026年09月22号
7663
+ * @param error 捕获到的异常
7664
+ * @returns 错误码;取不到返回空串
7665
+ */
7666
+ function fsErrCode(error) {
7667
+ const code = error?.code;
7668
+ return typeof code === "string" ? code : "";
7669
+ }
7670
+ /**
7671
+ * 复制/移动共用核心(edrv.fsCopy / edrv.fsMove):目标目录存在 + 同名拒绝覆盖 +
7672
+ * 「不能以自身或其子目录为目标」护栏;move 跨设备(EXDEV)回退「递归复制 + 删除源」。
7673
+ * @author ddj 2026年09月22号
7674
+ * @param ctx DSH 上下文
7675
+ * @param sc requireSession 成功结果(session + cwd)
7676
+ * @param args 请求载荷(from / toDir 均为工作区相对路径)
7677
+ * @param mode copy = 复制;move = 移动
7678
+ * @returns 成功返回 from/to 相对路径;失败返回错误文案
7679
+ */
7680
+ async function fsTransfer(ctx, sc, args, mode) {
7681
+ const fromRel = normalizeRel(args.from);
7682
+ const toDirRel = normalizeRel(args.toDir);
7683
+ if (fromRel === null || fromRel === "" || toDirRel === null) return { err: "路径不合法" };
7684
+ if (isSubPath(fromRel, toDirRel)) return { err: "不能以自身或其子目录为目标" };
7685
+ const src = await fsOpsTarget(ctx, sc.session, fromRel);
7686
+ if ("err" in src) return src;
7687
+ const dir = await fsOpsTarget(ctx, sc.session, toDirRel);
7688
+ if ("err" in dir) return dir;
7689
+ const dirInfo = await stat(dir.abs).catch(() => null);
7690
+ if (!dirInfo || !dirInfo.isDirectory()) return { err: "目标目录不存在" };
7691
+ const toRel = joinRelPath(toDirRel, baseNameOf(fromRel));
7692
+ const dst = await fsOpsTarget(ctx, sc.session, toRel);
7693
+ if ("err" in dst) return dst;
7694
+ if (!await stat(src.abs).catch(() => null)) return { err: "源不存在" };
7695
+ if (await stat(dst.abs).catch(() => null)) return { err: "目标目录已存在同名文件或目录" };
7696
+ try {
7697
+ await transferEntry(src.abs, dst.abs, mode);
7698
+ } catch (error) {
7699
+ return { err: (mode === "copy" ? "复制失败:" : "移动失败:") + String(error) };
7700
+ }
7701
+ invalidateIndex(ctx, sc.cwd, fromRel);
7702
+ invalidateIndex(ctx, sc.cwd, toRel);
7703
+ return {
7704
+ from: fromRel,
7705
+ to: toRel
7706
+ };
7707
+ }
7708
+ /**
7709
+ * 单条目复制/移动落盘(文件/目录通用;move 跨设备回退复制+删除源)。
7710
+ * @author ddj 2026年09月22号
7711
+ * @param absFrom 源绝对路径
7712
+ * @param absTo 目标绝对路径
7713
+ * @param mode copy = 复制;move = 移动
7714
+ */
7715
+ async function transferEntry(absFrom, absTo, mode) {
7716
+ if (mode === "copy") {
7717
+ await cp(absFrom, absTo, {
7718
+ recursive: true,
7719
+ force: false,
7720
+ errorOnExist: true
7721
+ });
7722
+ return;
7723
+ }
7724
+ try {
7725
+ await rename(absFrom, absTo);
7726
+ } catch (error) {
7727
+ if (fsErrCode(error) !== "EXDEV") throw error;
7728
+ await cp(absFrom, absTo, {
7729
+ recursive: true,
7730
+ force: false,
7731
+ errorOnExist: true
7732
+ });
7733
+ await rm(absFrom, { recursive: true });
7734
+ }
7735
+ }
7377
7736
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
7378
- function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers) {
7737
+ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers) {
7379
7738
  return {
7380
7739
  ...lspHandlers ?? {},
7381
7740
  ...aiHandlers ?? {},
7382
7741
  ...svnHandlers ?? {},
7742
+ ...dapHandlers ?? {},
7383
7743
  "edrv.list": async (args) => {
7384
7744
  const sc = await requireSession(ctx, args.sessionId);
7385
7745
  if ("err" in sc) return {
@@ -7493,6 +7853,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7493
7853
  };
7494
7854
  try {
7495
7855
  const target = await resolveTarget(ctx, sc.session, args.path);
7856
+ debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(args.path ?? "") + " resolved=" + fs.processPath(target), "debug");
7496
7857
  const info = await fs.stat(target);
7497
7858
  if (!info || info.type !== "file") return {
7498
7859
  ok: false,
@@ -7552,6 +7913,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7552
7913
  };
7553
7914
  try {
7554
7915
  const target = await resolveTarget(ctx, sc.session, args.path);
7916
+ debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(args.path ?? "") + " resolved=" + fs.processPath(target), "debug");
7555
7917
  const info = await fs.stat(target);
7556
7918
  if (!info || info.type !== "file") return {
7557
7919
  ok: false,
@@ -7615,6 +7977,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7615
7977
  };
7616
7978
  try {
7617
7979
  const target = await resolveTarget(ctx, sc.session, args.path);
7980
+ debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(args.path ?? "") + " resolved=" + fs.processPath(target), "debug");
7618
7981
  const rev = typeof args.rev === "string" && args.rev ? args.rev : null;
7619
7982
  const intent = rev ? {
7620
7983
  kind: "replaceIfVersion",
@@ -7655,6 +8018,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7655
8018
  };
7656
8019
  try {
7657
8020
  const target = await resolveTarget(ctx, sc.session, args.path);
8021
+ debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(args.path ?? "") + " resolved=" + fs.processPath(target), "debug");
7658
8022
  const info = await fs.stat(target);
7659
8023
  if (!info || info.type !== "file") return {
7660
8024
  ok: false,
@@ -7850,7 +8214,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7850
8214
  };
7851
8215
  },
7852
8216
  "edrv.dlog.reveal": async () => {
7853
- const revealed = await revealInExplorer(ctx, pluginLogRoot(), true);
8217
+ const revealed = await revealInExplorer(pluginLogRoot(), true);
7854
8218
  if (!revealed.ok) return {
7855
8219
  ok: false,
7856
8220
  error: revealed.error
@@ -7954,7 +8318,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7954
8318
  error: "路径不存在"
7955
8319
  };
7956
8320
  const abs = fs.processPath(target);
7957
- const outcome = await revealInExplorer(ctx, abs, info.type === "directory");
8321
+ const outcome = await revealInExplorer(abs, info.type === "directory");
7958
8322
  if (!outcome.ok) return {
7959
8323
  ok: false,
7960
8324
  error: outcome.error
@@ -7970,61 +8334,240 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7970
8334
  };
7971
8335
  }
7972
8336
  },
7973
- "mcp.list": async () => ({
7974
- ok: true,
7975
- ...listMcp(ctx)
7976
- }),
7977
- "mcp.save": async (args) => {
8337
+ "edrv.fsCreateFile": async (args) => {
8338
+ const sc = await requireSession(ctx, args.sessionId);
8339
+ if ("err" in sc) return {
8340
+ ok: false,
8341
+ error: sc.err
8342
+ };
8343
+ const bad = checkNewName(args.path);
8344
+ if (bad) return {
8345
+ ok: false,
8346
+ error: bad
8347
+ };
8348
+ const r = await fsOpsTarget(ctx, sc.session, args.path);
8349
+ if ("err" in r) return {
8350
+ ok: false,
8351
+ error: r.err
8352
+ };
7978
8353
  try {
7979
- return {
7980
- ok: true,
7981
- server: await saveMcp(ctx, args.config)
7982
- };
8354
+ await mkdir(dirname(r.abs), { recursive: true });
8355
+ await writeFile(r.abs, "", { flag: "wx" });
7983
8356
  } catch (error) {
7984
8357
  return {
7985
8358
  ok: false,
7986
- error: String(error)
8359
+ error: fsErrCode(error) === "EEXIST" ? "已存在同名文件或目录" : "新建文件失败:" + String(error)
7987
8360
  };
7988
8361
  }
8362
+ invalidateIndex(ctx, sc.cwd, args.path);
8363
+ return {
8364
+ ok: true,
8365
+ path: args.path
8366
+ };
7989
8367
  },
7990
- "mcp.remove": async (args) => {
8368
+ "edrv.fsCreateDir": async (args) => {
8369
+ const sc = await requireSession(ctx, args.sessionId);
8370
+ if ("err" in sc) return {
8371
+ ok: false,
8372
+ error: sc.err
8373
+ };
8374
+ const bad = checkNewName(args.path);
8375
+ if (bad) return {
8376
+ ok: false,
8377
+ error: bad
8378
+ };
8379
+ const r = await fsOpsTarget(ctx, sc.session, args.path);
8380
+ if ("err" in r) return {
8381
+ ok: false,
8382
+ error: r.err
8383
+ };
7991
8384
  try {
7992
- await removeMcp(ctx, args.id);
7993
- return { ok: true };
8385
+ await mkdir(dirname(r.abs), { recursive: true });
8386
+ await mkdir(r.abs);
7994
8387
  } catch (error) {
7995
8388
  return {
7996
8389
  ok: false,
7997
- error: String(error)
8390
+ error: fsErrCode(error) === "EEXIST" ? "已存在同名文件或目录" : "新建文件夹失败:" + String(error)
7998
8391
  };
7999
8392
  }
8393
+ invalidateIndex(ctx, sc.cwd, args.path);
8394
+ return {
8395
+ ok: true,
8396
+ path: args.path
8397
+ };
8000
8398
  },
8001
- "mcp.toggle": async (args) => {
8399
+ "edrv.fsRename": async (args) => {
8400
+ const sc = await requireSession(ctx, args.sessionId);
8401
+ if ("err" in sc) return {
8402
+ ok: false,
8403
+ error: sc.err
8404
+ };
8405
+ const bad = checkRenameName(args.newName);
8406
+ if (bad) return {
8407
+ ok: false,
8408
+ error: bad
8409
+ };
8410
+ const fromRel = normalizeRel(args.path);
8411
+ if (fromRel === null || fromRel === "") return {
8412
+ ok: false,
8413
+ error: "路径不合法"
8414
+ };
8415
+ const toRel = joinRelPath(parentRelOf(fromRel), args.newName);
8416
+ if (toRel === fromRel) return {
8417
+ ok: true,
8418
+ from: fromRel,
8419
+ to: fromRel
8420
+ };
8421
+ const src = await fsOpsTarget(ctx, sc.session, fromRel);
8422
+ if ("err" in src) return {
8423
+ ok: false,
8424
+ error: src.err
8425
+ };
8426
+ const dst = await fsOpsTarget(ctx, sc.session, toRel);
8427
+ if ("err" in dst) return {
8428
+ ok: false,
8429
+ error: dst.err
8430
+ };
8431
+ if (!await stat(src.abs).catch(() => null)) return {
8432
+ ok: false,
8433
+ error: "源不存在"
8434
+ };
8002
8435
  try {
8003
- return {
8004
- ok: true,
8005
- server: await toggleMcp(ctx, args.id, args.enabled)
8006
- };
8436
+ await rename(src.abs, dst.abs);
8007
8437
  } catch (error) {
8438
+ const code = fsErrCode(error);
8008
8439
  return {
8009
8440
  ok: false,
8010
- error: String(error)
8441
+ error: code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM" ? "已存在同名文件或目录" : "重命名失败:" + String(error)
8011
8442
  };
8012
8443
  }
8444
+ invalidateIndex(ctx, sc.cwd, fromRel);
8445
+ invalidateIndex(ctx, sc.cwd, toRel);
8446
+ return {
8447
+ ok: true,
8448
+ from: fromRel,
8449
+ to: toRel
8450
+ };
8013
8451
  },
8014
- "mcp.refresh": async (args) => {
8452
+ "edrv.fsDelete": async (args) => {
8453
+ const sc = await requireSession(ctx, args.sessionId);
8454
+ if ("err" in sc) return {
8455
+ ok: false,
8456
+ error: sc.err
8457
+ };
8458
+ const rel = normalizeRel(args.path);
8459
+ if (rel === null || rel === "") return {
8460
+ ok: false,
8461
+ error: "路径不合法"
8462
+ };
8463
+ const r = await fsOpsTarget(ctx, sc.session, rel);
8464
+ if ("err" in r) return {
8465
+ ok: false,
8466
+ error: r.err
8467
+ };
8015
8468
  try {
8016
- return {
8017
- ok: true,
8018
- server: await refreshMcp(ctx, args.id)
8019
- };
8469
+ await rm(r.abs, { recursive: true });
8020
8470
  } catch (error) {
8021
8471
  return {
8022
8472
  ok: false,
8023
- error: String(error)
8473
+ error: "删除失败:" + String(error)
8024
8474
  };
8025
8475
  }
8476
+ invalidateIndex(ctx, sc.cwd, rel);
8477
+ return {
8478
+ ok: true,
8479
+ path: rel
8480
+ };
8026
8481
  },
8027
- "mcp.projects": async () => {
8482
+ "edrv.fsCopy": async (args) => {
8483
+ const sc = await requireSession(ctx, args.sessionId);
8484
+ if ("err" in sc) return {
8485
+ ok: false,
8486
+ error: sc.err
8487
+ };
8488
+ const r = await fsTransfer(ctx, sc, args, "copy");
8489
+ if ("err" in r) return {
8490
+ ok: false,
8491
+ error: r.err
8492
+ };
8493
+ return {
8494
+ ok: true,
8495
+ from: r.from,
8496
+ to: r.to
8497
+ };
8498
+ },
8499
+ "edrv.fsMove": async (args) => {
8500
+ const sc = await requireSession(ctx, args.sessionId);
8501
+ if ("err" in sc) return {
8502
+ ok: false,
8503
+ error: sc.err
8504
+ };
8505
+ const r = await fsTransfer(ctx, sc, args, "move");
8506
+ if ("err" in r) return {
8507
+ ok: false,
8508
+ error: r.err
8509
+ };
8510
+ return {
8511
+ ok: true,
8512
+ from: r.from,
8513
+ to: r.to
8514
+ };
8515
+ },
8516
+ "mcp.list": async () => ({
8517
+ ok: true,
8518
+ ...listMcp(ctx)
8519
+ }),
8520
+ "mcp.save": async (args) => {
8521
+ try {
8522
+ return {
8523
+ ok: true,
8524
+ server: await saveMcp(ctx, args.config)
8525
+ };
8526
+ } catch (error) {
8527
+ return {
8528
+ ok: false,
8529
+ error: String(error)
8530
+ };
8531
+ }
8532
+ },
8533
+ "mcp.remove": async (args) => {
8534
+ try {
8535
+ await removeMcp(ctx, args.id);
8536
+ return { ok: true };
8537
+ } catch (error) {
8538
+ return {
8539
+ ok: false,
8540
+ error: String(error)
8541
+ };
8542
+ }
8543
+ },
8544
+ "mcp.toggle": async (args) => {
8545
+ try {
8546
+ return {
8547
+ ok: true,
8548
+ server: await toggleMcp(ctx, args.id, args.enabled)
8549
+ };
8550
+ } catch (error) {
8551
+ return {
8552
+ ok: false,
8553
+ error: String(error)
8554
+ };
8555
+ }
8556
+ },
8557
+ "mcp.refresh": async (args) => {
8558
+ try {
8559
+ return {
8560
+ ok: true,
8561
+ server: await refreshMcp(ctx, args.id)
8562
+ };
8563
+ } catch (error) {
8564
+ return {
8565
+ ok: false,
8566
+ error: String(error)
8567
+ };
8568
+ }
8569
+ },
8570
+ "mcp.projects": async () => {
8028
8571
  try {
8029
8572
  return {
8030
8573
  ok: true,
@@ -8618,8 +9161,8 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8618
9161
  * 统一入口:按方法分发到 handler 表。
8619
9162
  * @author ddj 2026年08月20号 / 2026年08月26号
8620
9163
  */
8621
- async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers) {
8622
- const handler = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers)[method];
9164
+ async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers) {
9165
+ const handler = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers)[method];
8623
9166
  if (!handler) return {
8624
9167
  ok: false,
8625
9168
  error: "未知方法: " + String(method)
@@ -8754,14 +9297,6 @@ function installIsolation(ctx) {
8754
9297
  }, "vscode-mode:mcp-isolation");
8755
9298
  }
8756
9299
  //#endregion
8757
- //#region src/workspace.ts
8758
- /** cwd → { at, files }(随会话销毁清理)。 */
8759
- const fileIndex = /* @__PURE__ */ new Map();
8760
- /** 会话销毁时清理文件索引缓存。 */
8761
- function dropFileIndex(cwd) {
8762
- fileIndex.delete(cwd);
8763
- }
8764
- //#endregion
8765
9300
  //#region src/lsp/jsonrpc.ts
8766
9301
  /**
8767
9302
  * dsh-vscode-mode host — LSP JSON-RPC 帧编解码(纯函数,node 可测)。
@@ -10247,6 +10782,11 @@ async function updateExtension(id) {
10247
10782
  * 产物 LspProviderSpec:{ ready, argv, cwd, reason },供 manager 启动服务器。
10248
10783
  * 作者 ddj 2026-08-27
10249
10784
  */
10785
+ /** 语言 → 扩展名(用于判断哪些文件触发该语言服务器)。 */
10786
+ const LSP_LANG_EXT = {
10787
+ lua: ["lua"],
10788
+ csharp: ["cs", "csx"]
10789
+ };
10250
10790
  /** 平台目录名(LuaLS/OmniSharp 的 server/bin/<平台>)。 */
10251
10791
  function platformServerDir(platform = process.platform) {
10252
10792
  if (platform === "win32") return "Windows";
@@ -10397,7 +10937,8 @@ function sdkEnvOf(sdkRoot) {
10397
10937
  if (version) env.DOTNET_SDK_PATH = join(sdkRoot, "sdk", version);
10398
10938
  return env;
10399
10939
  }
10400
- /** 读扩展目录 package.json 的 version(读取失败不影响入口使用,返回 undefined)。 */
10940
+ /** 读扩展目录 package.json 的 version(读取失败不影响入口使用,返回 undefined)。
10941
+ * 导出供 dap/discovery 复用(扩展清单版本排序同口径,避免第二份实现)。 */
10401
10942
  function manifestVersionOf(extDir) {
10402
10943
  try {
10403
10944
  const manifest = JSON.parse(readFileSync(join(extDir, "package.json"), "utf8"));
@@ -12732,6 +13273,21 @@ const READ_GRACE_MS = 3e4;
12732
13273
  const MUTATE_GRACE_MS = 12e4;
12733
13274
  /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
12734
13275
  const READ_CAP = 8388608;
13276
+ /**
13277
+ * W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
13278
+ * 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
13279
+ * 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
13280
+ */
13281
+ const PATCH_OUTPUT_CAP = 16777216;
13282
+ /** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
13283
+ const SUM_OUTPUT_CAP = 8388608;
13284
+ /**
13285
+ * W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
13286
+ * (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
13287
+ */
13288
+ const REMOTE_GRACE_MS = 15e3;
13289
+ /** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
13290
+ const SUM_GRACE_MS = 6e4;
12735
13291
  /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
12736
13292
  const SVN_ROOT_MAX_DEPTH = 10;
12737
13293
  /** 默认存在性探测(node fs;异常一律按不存在)。 */
@@ -12892,6 +13448,130 @@ function parseStatusXml(xml, cap = CHANGES_CAP) {
12892
13448
  truncated
12893
13449
  };
12894
13450
  }
13451
+ /**
13452
+ * 绝对路径 → 工作副本相对路径(W2-2/W2-3:M3/M5 实测 XML 中 path 为绝对路径)。
13453
+ * Windows 大小写不敏感文件系统:前缀匹配按小写比较,剥离后保留原大小写。
13454
+ * @author ddj 2026年09月20号
13455
+ * @param abs 绝对路径(`/` 或 `\` 分隔均可)
13456
+ * @param wcRoot 工作副本根绝对路径
13457
+ * @returns 相对路径(`/` 分隔);不在根下返回 null
13458
+ */
13459
+ function relPathOfAbs(abs, wcRoot) {
13460
+ const norm = (p) => String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
13461
+ const a = norm(abs);
13462
+ const r = norm(wcRoot);
13463
+ if (!r || !a.toLowerCase().startsWith(r.toLowerCase() + "/")) return null;
13464
+ return a.slice(r.length + 1);
13465
+ }
13466
+ /**
13467
+ * W2-1 补丁的 `-x` 扩展选项组合(纯函数)。
13468
+ * 冒烟实测修正(2026-09-20):本机 svn 内部 diff 的上下文行数只认 `-U<N>`,
13469
+ * `--unified=N` 报 E200016 erroneous argument(07 计划 §1 当年只核对了 help 文案,未实测)。
13470
+ * @author ddj 2026年09月20号
13471
+ * @param opts 空白语义 / EOL 忽略 / 上下文行数
13472
+ * @returns 空格连接的单个 `-x` 值;无选项时空串
13473
+ */
13474
+ function patchExtOf(opts) {
13475
+ const parts = [];
13476
+ if (opts.whitespace === "b") parts.push("-b");
13477
+ if (opts.whitespace === "w") parts.push("-w");
13478
+ if (opts.ignoreEol === true) parts.push("--ignore-eol-style");
13479
+ const unified = Math.floor(Number(opts.unified));
13480
+ if (Number.isFinite(unified) && unified >= 0 && unified <= 100 && unified !== 3) parts.push("-U" + unified);
13481
+ return parts.join(" ");
13482
+ }
13483
+ /**
13484
+ * 解析 `svn diff --summarize --xml` 输出(W2-2;M5 实测形态,svn 1.14.5):
13485
+ * `<diff><paths><path item="modified" kind="file" props="none">ABS:\\PATH</path>...`
13486
+ * - path 的**文本内容**是绝对路径(非属性)→ relPathOfAbs 归一为工作副本相对;
13487
+ * - 属性输出顺序不稳定(与 M1 log 同款教训),只按字段名取值、不做字符串快照;
13488
+ * - 区间无变更时 `<paths>` 为空 → entries 为空数组(非错误)。
13489
+ * @author ddj 2026年09月20号
13490
+ * @param xml summarize --xml 的 stdout
13491
+ * @param wcRoot 工作副本根(路径归一锚点)
13492
+ * @param cap 条目上限(超出标记 truncated)
13493
+ * @returns 变更条目与是否截断
13494
+ */
13495
+ function parseSummarizeXml(xml, wcRoot, cap = CHANGES_CAP) {
13496
+ const entries = [];
13497
+ let truncated = false;
13498
+ if (typeof xml !== "string" || !xml) return {
13499
+ entries,
13500
+ truncated
13501
+ };
13502
+ const re = /<path\b([^>]*?)>([\s\S]*?)<\/path>/g;
13503
+ let match;
13504
+ while ((match = re.exec(xml)) !== null) {
13505
+ const abs = unescapeXml(match[2].trim());
13506
+ const rel = relPathOfAbs(abs, wcRoot) ?? changePathOf(abs);
13507
+ if (!rel) continue;
13508
+ if (entries.length >= cap) {
13509
+ truncated = true;
13510
+ break;
13511
+ }
13512
+ entries.push({
13513
+ path: rel,
13514
+ item: attrOf(match[1], "item") || "none",
13515
+ kind: attrOf(match[1], "kind") || "none",
13516
+ props: attrOf(match[1], "props") || "none"
13517
+ });
13518
+ }
13519
+ return {
13520
+ entries,
13521
+ truncated
13522
+ };
13523
+ }
13524
+ /**
13525
+ * 解析 `svn status -u --xml` 输出(W2-3;M3 实测形态,svn 1.14.5):
13526
+ * `<entry path="ABS"><wc-status ...>...</wc-status><repos-status item="modified" props="none"/></entry>` + `<against revision="N"/>`
13527
+ * - 落后锚点 = 存在 `<repos-status>` 且 `item !== 'none'`(实测落后文件为 modified);
13528
+ * 远端无变化的条目**没有** repos-status 元素(省略,非 'none' 值);
13529
+ * - 离线/无凭据时会长时间挂起(M3 实测 120s+),调用方必须用 REMOTE_GRACE_MS 短超时。
13530
+ * @author ddj 2026年09月20号
13531
+ * @param xml status -u --xml 的 stdout
13532
+ * @param wcRoot 工作副本根(路径归一锚点)
13533
+ * @param cap 条目上限(超出标记 truncated)
13534
+ * @returns 落后清单 + against 修订
13535
+ */
13536
+ function parseRemoteStatusXml(xml, wcRoot, cap = CHANGES_CAP) {
13537
+ const outdated = [];
13538
+ let againstRev = null;
13539
+ let truncated = false;
13540
+ if (typeof xml !== "string" || !xml) return {
13541
+ outdated,
13542
+ againstRev,
13543
+ truncated
13544
+ };
13545
+ const against = /<against\b([^>]*?)\/?>/.exec(xml);
13546
+ if (against) {
13547
+ const rev = numAttrOf(against[1], "revision");
13548
+ if (rev !== void 0) againstRev = rev;
13549
+ }
13550
+ const re = /<entry\b([^>]*?)>([\s\S]*?)<\/entry>/g;
13551
+ let match;
13552
+ while ((match = re.exec(xml)) !== null) {
13553
+ const repos = /<repos-status\b([^>]*?)\/?>/.exec(match[2]);
13554
+ if (!repos) continue;
13555
+ const item = attrOf(repos[1], "item");
13556
+ if (!item || item === "none") continue;
13557
+ const rawPath = attrOf(match[1], "path");
13558
+ const rel = relPathOfAbs(rawPath, wcRoot) ?? changePathOf(rawPath);
13559
+ if (!rel) continue;
13560
+ if (outdated.length >= cap) {
13561
+ truncated = true;
13562
+ break;
13563
+ }
13564
+ outdated.push({
13565
+ path: rel,
13566
+ item
13567
+ });
13568
+ }
13569
+ return {
13570
+ outdated,
13571
+ againstRev,
13572
+ truncated
13573
+ };
13574
+ }
12895
13575
  /** 进程名(去路径去 .exe;前台助手用)。 */
12896
13576
  function processNameOf(exe) {
12897
13577
  return (exe.split(/[\\/]/).pop() ?? exe).replace(/\.exe$/i, "");
@@ -13349,6 +14029,48 @@ function createSvnRpc(deps) {
13349
14029
  error: sides[0].error ?? sides[1].error
13350
14030
  };
13351
14031
  };
14032
+ /**
14033
+ * 扫描冲突副本文件(W2-4;svn 冲突时的标准后缀 `.mine`/`.working`/`.rN`,纯本地 fs 只读)。
14034
+ * @author ddj 2026年09月20号
14035
+ * @param wcRoot 工作副本根
14036
+ * @param rel 冲突文件的工作副本相对路径
14037
+ * @returns 副本清单(rev 升序 → working → mine;默认对比对 = 首个 .rN ↔ .mine)
14038
+ */
14039
+ const conflictArtifactsOf = async (wcRoot, rel) => {
14040
+ const abs = join(wcRoot, rel);
14041
+ const base = basename(abs);
14042
+ const names = await readdir(dirname(abs));
14043
+ const rankOf = (kind) => kind === "rev" ? 0 : kind === "working" ? 1 : 2;
14044
+ const artifacts = [];
14045
+ for (const name of names) {
14046
+ let kind = null;
14047
+ let rev;
14048
+ if (name === base + ".mine") kind = "mine";
14049
+ else if (name === base + ".working") kind = "working";
14050
+ else if (name.startsWith(base + ".r") && /^\d+$/.test(name.slice(base.length + 2))) {
14051
+ kind = "rev";
14052
+ rev = Number.parseInt(name.slice(base.length + 2), 10);
14053
+ }
14054
+ if (kind === null) continue;
14055
+ const artifactPath = relPathOfAbs(join(dirname(abs), name), wcRoot);
14056
+ if (!artifactPath) continue;
14057
+ artifacts.push({
14058
+ path: artifactPath,
14059
+ name,
14060
+ kind,
14061
+ ...rev !== void 0 ? { rev } : {}
14062
+ });
14063
+ }
14064
+ return artifacts.sort((a, b) => rankOf(a.kind) - rankOf(b.kind) || (a.rev ?? 0) - (b.rev ?? 0) || a.name.localeCompare(b.name));
14065
+ };
14066
+ /** W2-6:双侧内容护栏标记(干净时返回空对象,展开进载荷不添字段)。 */
14067
+ const guardSides = (a, b) => {
14068
+ const merged = {
14069
+ ...contentGuardOf(a ?? ""),
14070
+ ...contentGuardOf(b ?? "")
14071
+ };
14072
+ return merged.binary || merged.encodingHint ? merged : {};
14073
+ };
13352
14074
  return { handlers: {
13353
14075
  "svn.status": async (args) => {
13354
14076
  const sc = await requireSvnSession(ctx, args.sessionId);
@@ -13441,13 +14163,15 @@ function createSvnRpc(deps) {
13441
14163
  base: null,
13442
14164
  working,
13443
14165
  reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
13444
- error: tailOfText(text)
14166
+ error: tailOfText(text),
14167
+ ...guardSides(null, working)
13445
14168
  };
13446
14169
  }
13447
14170
  return {
13448
14171
  ok: true,
13449
14172
  base: outcome.stdout,
13450
- working
14173
+ working,
14174
+ ...guardSides(outcome.stdout, working)
13451
14175
  };
13452
14176
  } catch (error) {
13453
14177
  return {
@@ -13528,9 +14252,11 @@ function createSvnRpc(deps) {
13528
14252
  error: "svn 命令不可用(可在设置页配置 svn 路径)"
13529
14253
  };
13530
14254
  try {
14255
+ const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision));
13531
14256
  return {
13532
14257
  ok: true,
13533
- ...await diffRevSides(status.wcRoot, rel, Math.floor(revision))
14258
+ ...result,
14259
+ ...guardSides(result.left, result.right)
13534
14260
  };
13535
14261
  } catch (error) {
13536
14262
  return {
@@ -13566,9 +14292,11 @@ function createSvnRpc(deps) {
13566
14292
  error: "svn 命令不可用(可在设置页配置 svn 路径)"
13567
14293
  };
13568
14294
  try {
14295
+ const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB));
13569
14296
  return {
13570
14297
  ok: true,
13571
- ...await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB))
14298
+ ...result,
14299
+ ...guardSides(result.left, result.right)
13572
14300
  };
13573
14301
  } catch (error) {
13574
14302
  return {
@@ -13631,12 +14359,14 @@ function createSvnRpc(deps) {
13631
14359
  left: null,
13632
14360
  right: working,
13633
14361
  reason: "not-exist",
13634
- error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim())
14362
+ error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim()),
14363
+ ...guardSides(null, working)
13635
14364
  };
13636
14365
  return {
13637
14366
  ok: true,
13638
14367
  left: leftOutcome.stdout,
13639
- right: working
14368
+ right: working,
14369
+ ...guardSides(leftOutcome.stdout, working)
13640
14370
  };
13641
14371
  } catch (error) {
13642
14372
  return {
@@ -13645,7 +14375,7 @@ function createSvnRpc(deps) {
13645
14375
  };
13646
14376
  }
13647
14377
  },
13648
- "svn.wcRev": async (args) => {
14378
+ "svn.patchText": async (args) => {
13649
14379
  const sc = await requireSvnSession(ctx, args.sessionId);
13650
14380
  if ("err" in sc) return {
13651
14381
  ok: false,
@@ -13653,8 +14383,8 @@ function createSvnRpc(deps) {
13653
14383
  };
13654
14384
  const rel = safeRel(args.path);
13655
14385
  if (rel === null || rel === "") return {
13656
- ok: true,
13657
- revision: null
14386
+ ok: false,
14387
+ error: "路径不合法"
13658
14388
  };
13659
14389
  const status = await statusOf(sc.cwd);
13660
14390
  if (!status.managed || !status.wcRoot) return {
@@ -13665,27 +14395,55 @@ function createSvnRpc(deps) {
13665
14395
  ok: false,
13666
14396
  error: "svn 命令不可用(可在设置页配置 svn 路径)"
13667
14397
  };
13668
- const outcome = await runSvn(status.wcRoot, [
13669
- "info",
13670
- "--xml",
13671
- "--",
13672
- rel
13673
- ], READ_GRACE_MS);
13674
- if (outcome.code !== 0) return {
13675
- ok: true,
13676
- revision: null
13677
- };
13678
- return {
13679
- ok: true,
13680
- revision: parseSvnInfoRevision(outcome.stdout)
13681
- };
14398
+ try {
14399
+ const ext = patchExtOf({
14400
+ whitespace: args.whitespace,
14401
+ ignoreEol: args.ignoreEol === true,
14402
+ unified: args.unified
14403
+ });
14404
+ const argv = [
14405
+ "diff",
14406
+ ...ext ? ["-x", ext] : [],
14407
+ "--",
14408
+ rel
14409
+ ];
14410
+ const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP);
14411
+ if (outcome.code !== 0) return {
14412
+ ok: false,
14413
+ error: "生成补丁失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
14414
+ };
14415
+ const text = outcome.stdout;
14416
+ const head = text.slice(0, 200);
14417
+ return {
14418
+ ok: true,
14419
+ text,
14420
+ truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
14421
+ binary: text.includes("Cannot display:")
14422
+ };
14423
+ } catch (error) {
14424
+ return {
14425
+ ok: false,
14426
+ error: "生成补丁失败:" + String(error)
14427
+ };
14428
+ }
13682
14429
  },
13683
- "svn.cleanup": async (args) => {
14430
+ "svn.diffSum": async (args) => {
13684
14431
  const sc = await requireSvnSession(ctx, args.sessionId);
13685
14432
  if ("err" in sc) return {
13686
14433
  ok: false,
13687
14434
  error: sc.err
13688
14435
  };
14436
+ const rel = safeRel(args.path);
14437
+ if (rel === null) return {
14438
+ ok: false,
14439
+ error: "路径不合法"
14440
+ };
14441
+ const revA = Number(args.revA);
14442
+ const revB = Number(args.revB);
14443
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
14444
+ ok: false,
14445
+ error: "版本号不合法"
14446
+ };
13689
14447
  const status = await statusOf(sc.cwd);
13690
14448
  if (!status.managed || !status.wcRoot) return {
13691
14449
  ok: false,
@@ -13695,29 +14453,32 @@ function createSvnRpc(deps) {
13695
14453
  ok: false,
13696
14454
  error: "svn 命令不可用(可在设置页配置 svn 路径)"
13697
14455
  };
13698
- const argv = ["cleanup"];
13699
- if (args.removeUnversioned === true) argv.push("--remove-unversioned");
13700
- if (args.removeIgnored === true) argv.push("--remove-ignored");
13701
14456
  try {
13702
- const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
13703
- const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
14457
+ const argv = [
14458
+ "diff",
14459
+ "--summarize",
14460
+ "--xml",
14461
+ "-r",
14462
+ String(Math.floor(revA)) + ":" + String(Math.floor(revB))
14463
+ ];
14464
+ if (rel) argv.push("--", rel);
14465
+ const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP);
13704
14466
  if (outcome.code !== 0) return {
13705
14467
  ok: false,
13706
- error: "清理失败:" + tailOfText(result.output)
14468
+ error: "目录对比失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
13707
14469
  };
13708
- changesCache.delete(status.wcRoot);
13709
14470
  return {
13710
14471
  ok: true,
13711
- ...result
14472
+ ...parseSummarizeXml(outcome.stdout, status.wcRoot)
13712
14473
  };
13713
14474
  } catch (error) {
13714
14475
  return {
13715
14476
  ok: false,
13716
- error: "清理失败:" + String(error)
14477
+ error: "目录对比失败:" + String(error)
13717
14478
  };
13718
14479
  }
13719
14480
  },
13720
- "svn.update": async (args) => {
14481
+ "svn.remoteStatus": async (args) => {
13721
14482
  const sc = await requireSvnSession(ctx, args.sessionId);
13722
14483
  if ("err" in sc) return {
13723
14484
  ok: false,
@@ -13729,7 +14490,7 @@ function createSvnRpc(deps) {
13729
14490
  error: "路径不合法"
13730
14491
  };
13731
14492
  const status = await statusOf(sc.cwd);
13732
- if (!status.managed) return {
14493
+ if (!status.managed || !status.wcRoot) return {
13733
14494
  ok: false,
13734
14495
  error: "当前工作区不受 SVN 管理"
13735
14496
  };
@@ -13738,73 +14499,1912 @@ function createSvnRpc(deps) {
13738
14499
  error: "svn 命令不可用(可在设置页配置 svn 路径)"
13739
14500
  };
13740
14501
  try {
13741
- const outcome = await runSvn(sc.cwd, ["update", rel || "."], UPDATE_GRACE_MS);
13742
- const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
14502
+ const argv = [
14503
+ "status",
14504
+ "-u",
14505
+ "--xml"
14506
+ ];
14507
+ if (rel) argv.push("--", rel);
14508
+ const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP);
13743
14509
  if (outcome.code !== 0) return {
13744
14510
  ok: false,
13745
- error: "更新失败:" + tailOfText(result.output)
14511
+ error: "远端检查失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
13746
14512
  };
13747
14513
  return {
13748
14514
  ok: true,
13749
- ...result
14515
+ ...parseRemoteStatusXml(outcome.stdout, status.wcRoot)
13750
14516
  };
13751
14517
  } catch (error) {
13752
14518
  return {
13753
14519
  ok: false,
13754
- error: "更新失败:" + String(error)
14520
+ error: "远端检查失败:" + String(error)
13755
14521
  };
13756
14522
  }
13757
14523
  },
13758
- "svn.tortoise": async (args) => {
14524
+ "svn.conflictArtifacts": async (args) => {
13759
14525
  const sc = await requireSvnSession(ctx, args.sessionId);
13760
14526
  if ("err" in sc) return {
13761
14527
  ok: false,
13762
14528
  error: sc.err
13763
14529
  };
13764
- const action = args.action;
13765
- if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
13766
- ok: false,
13767
- error: "未知动作: " + String(action)
13768
- };
13769
14530
  const rel = safeRel(args.path);
13770
- if (rel === null) return {
14531
+ if (rel === null || rel === "") return {
13771
14532
  ok: false,
13772
14533
  error: "路径不合法"
13773
14534
  };
13774
14535
  const status = await statusOf(sc.cwd);
13775
- if (!status.managed) return {
14536
+ if (!status.managed || !status.wcRoot) return {
13776
14537
  ok: false,
13777
14538
  error: "当前工作区不受 SVN 管理"
13778
14539
  };
13779
- if (!status.tortoise) return {
14540
+ try {
14541
+ return {
14542
+ ok: true,
14543
+ artifacts: await conflictArtifactsOf(status.wcRoot, rel)
14544
+ };
14545
+ } catch (error) {
14546
+ return {
14547
+ ok: false,
14548
+ error: "扫描冲突副本失败:" + String(error)
14549
+ };
14550
+ }
14551
+ },
14552
+ "svn.diffLocalPair": async (args) => {
14553
+ const sc = await requireSvnSession(ctx, args.sessionId);
14554
+ if ("err" in sc) return {
13780
14555
  ok: false,
13781
- error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
14556
+ error: sc.err
14557
+ };
14558
+ const left = safeRel(args.left);
14559
+ const right = safeRel(args.right);
14560
+ if (!left || !right) return {
14561
+ ok: false,
14562
+ error: "路径不合法(需工作副本内相对路径)"
14563
+ };
14564
+ const status = await statusOf(sc.cwd);
14565
+ if (!status.managed || !status.wcRoot) return {
14566
+ ok: false,
14567
+ error: "当前工作区不受 SVN 管理"
13782
14568
  };
13783
14569
  const fs = ctx.get("fs");
13784
- if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
14570
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
13785
14571
  ok: false,
13786
14572
  error: "缺少 fs"
13787
14573
  };
13788
14574
  try {
13789
- const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
13790
- if (!await fs.stat(target)) return {
13791
- ok: false,
13792
- error: "路径不存在"
13793
- };
13794
- const abs = fs.processPath(target);
13795
- launchTortoise(status.tortoiseExe, action, abs);
14575
+ const sides = [];
14576
+ for (const rel of [left, right]) {
14577
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
14578
+ const info = await fs.stat(resolved);
14579
+ if (!info || info.type !== "file") return {
14580
+ ok: false,
14581
+ error: "文件不存在:" + rel
14582
+ };
14583
+ if ((info.size ?? 0) > READ_CAP) return {
14584
+ ok: false,
14585
+ error: "文件过大(>8MB):" + rel
14586
+ };
14587
+ sides.push(await fs.readText(resolved));
14588
+ }
13796
14589
  return {
13797
14590
  ok: true,
13798
- launched: abs
14591
+ left: sides[0],
14592
+ right: sides[1],
14593
+ ...guardSides(sides[0], sides[1])
13799
14594
  };
13800
14595
  } catch (error) {
13801
14596
  return {
13802
14597
  ok: false,
13803
- error: "启动 TortoiseSVN 失败:" + String(error)
14598
+ error: "读取冲突副本失败:" + String(error)
13804
14599
  };
13805
14600
  }
13806
- }
13807
- } };
14601
+ },
14602
+ "svn.fileSizes": async (args) => {
14603
+ const sc = await requireSvnSession(ctx, args.sessionId);
14604
+ if ("err" in sc) return {
14605
+ ok: false,
14606
+ error: sc.err
14607
+ };
14608
+ const list = Array.isArray(args.paths) ? args.paths.slice(0, 200) : [];
14609
+ if (!list.length) return {
14610
+ ok: true,
14611
+ sizes: []
14612
+ };
14613
+ const fs = ctx.get("fs");
14614
+ if (!fs?.resolve || !fs?.stat) return {
14615
+ ok: false,
14616
+ error: "缺少 fs"
14617
+ };
14618
+ const sizes = [];
14619
+ for (const raw of list) {
14620
+ const rel = safeRel(raw);
14621
+ if (rel === null || rel === "") {
14622
+ sizes.push({
14623
+ path: String(raw ?? ""),
14624
+ size: null
14625
+ });
14626
+ continue;
14627
+ }
14628
+ try {
14629
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
14630
+ const info = await fs.stat(resolved);
14631
+ sizes.push({
14632
+ path: rel,
14633
+ size: info && info.type === "file" ? info.size ?? null : null
14634
+ });
14635
+ } catch {
14636
+ sizes.push({
14637
+ path: rel,
14638
+ size: null
14639
+ });
14640
+ }
14641
+ }
14642
+ return {
14643
+ ok: true,
14644
+ sizes
14645
+ };
14646
+ },
14647
+ "svn.wcRev": async (args) => {
14648
+ const sc = await requireSvnSession(ctx, args.sessionId);
14649
+ if ("err" in sc) return {
14650
+ ok: false,
14651
+ error: sc.err
14652
+ };
14653
+ const rel = safeRel(args.path);
14654
+ if (rel === null || rel === "") return {
14655
+ ok: true,
14656
+ revision: null
14657
+ };
14658
+ const status = await statusOf(sc.cwd);
14659
+ if (!status.managed || !status.wcRoot) return {
14660
+ ok: false,
14661
+ error: "当前工作区不受 SVN 管理"
14662
+ };
14663
+ if (!status.svnCli) return {
14664
+ ok: false,
14665
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
14666
+ };
14667
+ const outcome = await runSvn(status.wcRoot, [
14668
+ "info",
14669
+ "--xml",
14670
+ "--",
14671
+ rel
14672
+ ], READ_GRACE_MS);
14673
+ if (outcome.code !== 0) return {
14674
+ ok: true,
14675
+ revision: null
14676
+ };
14677
+ return {
14678
+ ok: true,
14679
+ revision: parseSvnInfoRevision(outcome.stdout)
14680
+ };
14681
+ },
14682
+ "svn.cleanup": async (args) => {
14683
+ const sc = await requireSvnSession(ctx, args.sessionId);
14684
+ if ("err" in sc) return {
14685
+ ok: false,
14686
+ error: sc.err
14687
+ };
14688
+ const status = await statusOf(sc.cwd);
14689
+ if (!status.managed || !status.wcRoot) return {
14690
+ ok: false,
14691
+ error: "当前工作区不受 SVN 管理"
14692
+ };
14693
+ if (!status.svnCli) return {
14694
+ ok: false,
14695
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
14696
+ };
14697
+ const argv = ["cleanup"];
14698
+ if (args.removeUnversioned === true) argv.push("--remove-unversioned");
14699
+ if (args.removeIgnored === true) argv.push("--remove-ignored");
14700
+ try {
14701
+ const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
14702
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
14703
+ if (outcome.code !== 0) return {
14704
+ ok: false,
14705
+ error: "清理失败:" + tailOfText(result.output)
14706
+ };
14707
+ changesCache.delete(status.wcRoot);
14708
+ return {
14709
+ ok: true,
14710
+ ...result
14711
+ };
14712
+ } catch (error) {
14713
+ return {
14714
+ ok: false,
14715
+ error: "清理失败:" + String(error)
14716
+ };
14717
+ }
14718
+ },
14719
+ "svn.update": async (args) => {
14720
+ const sc = await requireSvnSession(ctx, args.sessionId);
14721
+ if ("err" in sc) return {
14722
+ ok: false,
14723
+ error: sc.err
14724
+ };
14725
+ const rel = safeRel(args.path);
14726
+ if (rel === null) return {
14727
+ ok: false,
14728
+ error: "路径不合法"
14729
+ };
14730
+ const status = await statusOf(sc.cwd);
14731
+ if (!status.managed) return {
14732
+ ok: false,
14733
+ error: "当前工作区不受 SVN 管理"
14734
+ };
14735
+ if (!status.svnCli) return {
14736
+ ok: false,
14737
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
14738
+ };
14739
+ try {
14740
+ const outcome = await runSvn(sc.cwd, ["update", rel || "."], UPDATE_GRACE_MS);
14741
+ const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
14742
+ if (outcome.code !== 0) return {
14743
+ ok: false,
14744
+ error: "更新失败:" + tailOfText(result.output)
14745
+ };
14746
+ return {
14747
+ ok: true,
14748
+ ...result
14749
+ };
14750
+ } catch (error) {
14751
+ return {
14752
+ ok: false,
14753
+ error: "更新失败:" + String(error)
14754
+ };
14755
+ }
14756
+ },
14757
+ "svn.tortoise": async (args) => {
14758
+ const sc = await requireSvnSession(ctx, args.sessionId);
14759
+ if ("err" in sc) return {
14760
+ ok: false,
14761
+ error: sc.err
14762
+ };
14763
+ const action = args.action;
14764
+ if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
14765
+ ok: false,
14766
+ error: "未知动作: " + String(action)
14767
+ };
14768
+ const rel = safeRel(args.path);
14769
+ if (rel === null) return {
14770
+ ok: false,
14771
+ error: "路径不合法"
14772
+ };
14773
+ const status = await statusOf(sc.cwd);
14774
+ if (!status.managed) return {
14775
+ ok: false,
14776
+ error: "当前工作区不受 SVN 管理"
14777
+ };
14778
+ if (!status.tortoise) return {
14779
+ ok: false,
14780
+ error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
14781
+ };
14782
+ const fs = ctx.get("fs");
14783
+ if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
14784
+ ok: false,
14785
+ error: "缺少 fs"
14786
+ };
14787
+ try {
14788
+ const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
14789
+ if (!await fs.stat(target)) return {
14790
+ ok: false,
14791
+ error: "路径不存在"
14792
+ };
14793
+ const abs = fs.processPath(target);
14794
+ launchTortoise(status.tortoiseExe, action, abs);
14795
+ return {
14796
+ ok: true,
14797
+ launched: abs
14798
+ };
14799
+ } catch (error) {
14800
+ return {
14801
+ ok: false,
14802
+ error: "启动 TortoiseSVN 失败:" + String(error)
14803
+ };
14804
+ }
14805
+ }
14806
+ } };
14807
+ }
14808
+ //#endregion
14809
+ //#region src/shared/dap.ts
14810
+ /** 全部合法动作(运行时校验用)。 */
14811
+ const DAP_ACTIONS = [
14812
+ "continue",
14813
+ "next",
14814
+ "stepIn",
14815
+ "stepOut",
14816
+ "pause",
14817
+ "disconnect"
14818
+ ];
14819
+ /** 调试配置文件(工作区相对路径;插件专属,与 VS Code 的 .vscode/launch.json 互不干扰)。 */
14820
+ const DAP_LAUNCH_REL = ".dsh/launch.json";
14821
+ /** 旧共用配置路径(仅首读迁移源:复制到 DAP_LAUNCH_REL 后不再读取,永不回写)。 */
14822
+ const DAP_LEGACY_LAUNCH_REL = ".vscode/launch.json";
14823
+ /**
14824
+ * 配置 → DAP request 类型:显式 request 优先;emmylua_attach 旧配置缺 request 时按 attach 兼容。
14825
+ * @author ddj 2026年09月21号
14826
+ * @param config 调试配置(取 type/request 两字段)
14827
+ * @returns 'attach' | 'launch'
14828
+ */
14829
+ function requestOfConfig(config) {
14830
+ if (config.request === "attach" || config.request === "launch") return config.request;
14831
+ return config.type === "emmylua_attach" ? "attach" : "launch";
14832
+ }
14833
+ /**
14834
+ * 是否应发送 configurationDone(DAP 标准握手收尾:断点下发完 → configurationDone → 目标开跑)。
14835
+ * 仅当适配器在 initialize 响应声明 supportsConfigurationDoneRequest 时发送;
14836
+ * emmylua 适配器未声明该能力(实测响应体仅 5 个 supports* 字段)→ 不发,Lua 行为不变。
14837
+ * @author ddj 2026年09月21号
14838
+ * @param caps initialize 响应 body(capabilities)
14839
+ */
14840
+ function needsConfigurationDone(caps) {
14841
+ return caps?.supportsConfigurationDoneRequest === true;
14842
+ }
14843
+ /**
14844
+ * stopped 事件 body → 当前线程 id:多线程适配器(coreclr 等)取真实 threadId,
14845
+ * 缺省/非法回退 1(单线程适配器 emmylua 语义不变)。
14846
+ * @author ddj 2026年09月21号
14847
+ * @param body stopped 事件 body
14848
+ */
14849
+ function threadIdOfStopped(body) {
14850
+ const id = body.threadId;
14851
+ return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : 1;
14852
+ }
14853
+ /**
14854
+ * setBreakpoints 响应 body → 断点回执(DAP 顺序与请求一致;缺行回退请求行)。
14855
+ * 适配器会把断点挪到最近可绑定行并给出 verified/message——必须消费而非造假回执。
14856
+ * @author ddj 2026年09月21号
14857
+ * @param body setBreakpoints 响应 body(breakpoints 数组)
14858
+ * @param items 本次请求的断点(回退基准)
14859
+ */
14860
+ function bpAcksOfResponse(body, items) {
14861
+ const list = body?.breakpoints;
14862
+ if (!Array.isArray(list)) return items.map((it) => ({
14863
+ line: it.line,
14864
+ verified: true
14865
+ }));
14866
+ return list.map((item, i) => {
14867
+ const b = item ?? {};
14868
+ const ack = {
14869
+ line: typeof b.line === "number" && b.line >= 1 ? b.line : items[i]?.line ?? 0,
14870
+ verified: b.verified === true
14871
+ };
14872
+ if (typeof b.message === "string" && b.message) ack.message = b.message;
14873
+ return ack;
14874
+ });
14875
+ }
14876
+ //#endregion
14877
+ //#region src/dap/sourcePath.ts
14878
+ /**
14879
+ * dsh-vscode-mode host — DAP source 路径归一化与候选排序。
14880
+ * 适配器可能上报绝对路径、工作区相对路径、file:// URI 或 Lua chunkname;
14881
+ * 这里统一转换,避免调用栈只显示 rawFile 但无法跳转。
14882
+ * 作者 ddj 2026年09月29号
14883
+ */
14884
+ /** 统一路径分隔符并去掉 chunkname 的 @ 前缀。 */
14885
+ function normalizeSourcePath(value) {
14886
+ let text = String(value ?? "").trim();
14887
+ if (!text) return "";
14888
+ if (/^file:\/\//i.test(text)) text = fileUriPath(text);
14889
+ return text.replace(/^@/, "").replace(/\\/g, "/");
14890
+ }
14891
+ /** 将 file:// URI 转成本地路径(兼容 Windows 盘符与 UNC)。 */
14892
+ function fileUriPath(value) {
14893
+ try {
14894
+ const url = new URL(value);
14895
+ let path = decodeURIComponent(url.pathname);
14896
+ if (url.hostname && url.hostname !== "localhost") path = "//" + url.hostname + path;
14897
+ if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1);
14898
+ return path;
14899
+ } catch {
14900
+ return value.replace(/^file:\/\//i, "");
14901
+ }
14902
+ }
14903
+ /** 取路径 basename(大小写保持原样)。 */
14904
+ function sourceBaseOf(value) {
14905
+ return normalizeSourcePath(value).split("/").pop() ?? "";
14906
+ }
14907
+ /** 规范化路径键(大小写不敏感,供候选缓存使用)。 */
14908
+ function sourceKeyOf(value) {
14909
+ return normalizeSourcePath(value).toLowerCase();
14910
+ }
14911
+ /**
14912
+ * 把 source 路径转换为工作区相对路径;工作区外路径保留规范化绝对路径,
14913
+ * 不再返回空串,调用方仍可交给现有 tabPathOf/absoluteOf 继续处理。
14914
+ */
14915
+ function sourcePathOf(value, workspacePath) {
14916
+ const source = normalizeSourcePath(value);
14917
+ if (!source) return "";
14918
+ const root = normalizeSourcePath(workspacePath).replace(/\/+$/, "");
14919
+ if (root && source.toLowerCase().startsWith((root + "/").toLowerCase())) return source.slice(root.length + 1);
14920
+ return source;
14921
+ }
14922
+ /**
14923
+ * 对 findFile 候选按 chunkname 相关度排序:完整路径后缀 > 路径片段 > basename/stem,
14924
+ * 同分时保持确定性字典序,避免同名 Lua 文件随机取第一项。
14925
+ */
14926
+ function rankSourceCandidates(chunk, files, workspacePath) {
14927
+ const needle = normalizeSourcePath(chunk).toLowerCase();
14928
+ const needleParts = needle.split("/").filter(Boolean);
14929
+ const needleBase = sourceBaseOf(needle).toLowerCase();
14930
+ const needleStem = needleBase.replace(/\.[^.]+$/, "");
14931
+ return [...files].sort((a, b) => scoreSource(b, needle, needleParts, needleBase, needleStem, workspacePath) - scoreSource(a, needle, needleParts, needleBase, needleStem, workspacePath) || a.localeCompare(b));
14932
+ }
14933
+ /** 计算单个候选相关度。 */
14934
+ function scoreSource(file, needle, parts, base, stem, workspacePath) {
14935
+ const rel = sourcePathOf(file, workspacePath).toLowerCase();
14936
+ const fileBase = sourceBaseOf(rel).toLowerCase();
14937
+ const fileStem = fileBase.replace(/\.[^.]+$/, "");
14938
+ let score = 0;
14939
+ if (needle && (rel === needle || rel.endsWith("/" + needle) || needle.endsWith("/" + rel))) score += 1e5;
14940
+ if (base && fileBase === base) score += 1e4;
14941
+ if (stem && fileStem === stem) score += 5e3;
14942
+ const relParts = rel.split("/").filter(Boolean);
14943
+ let suffix = 0;
14944
+ for (let i = 1; i <= Math.min(parts.length, relParts.length); i++) {
14945
+ if (parts[parts.length - i] !== relParts[relParts.length - i]) break;
14946
+ suffix += 1;
14947
+ }
14948
+ return score + suffix * 100;
14949
+ }
14950
+ //#endregion
14951
+ //#region src/dap/launchConfig.ts
14952
+ /**
14953
+ * 清洗 JSONC 为可 JSON.parse 文本:去 // 与 /* *\/ 注释、去尾逗号(字符串字面量感知)。
14954
+ * @author ddj 2026年09月29号
14955
+ * @param text 原始 launch.json 文本
14956
+ * @returns 清洗后文本
14957
+ */
14958
+ function stripJsonc(text) {
14959
+ let out = "";
14960
+ let i = 0;
14961
+ const n = text.length;
14962
+ let state = "code";
14963
+ while (i < n) {
14964
+ const ch = text[i];
14965
+ const next = i + 1 < n ? text[i + 1] : "";
14966
+ if (state === "string") {
14967
+ out += ch;
14968
+ if (ch === "\\") {
14969
+ out += next;
14970
+ i += 2;
14971
+ continue;
14972
+ }
14973
+ if (ch === "\"") state = "code";
14974
+ i += 1;
14975
+ continue;
14976
+ }
14977
+ if (ch === "\"") {
14978
+ state = "string";
14979
+ out += ch;
14980
+ i += 1;
14981
+ continue;
14982
+ }
14983
+ if (ch === "/" && next === "/") {
14984
+ while (i < n && text[i] !== "\n") i += 1;
14985
+ continue;
14986
+ }
14987
+ if (ch === "/" && next === "*") {
14988
+ i += 2;
14989
+ while (i + 1 < n && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
14990
+ i += 2;
14991
+ continue;
14992
+ }
14993
+ out += ch;
14994
+ i += 1;
14995
+ }
14996
+ return out.replace(/,(\s*[}\]])/g, "$1");
14997
+ }
14998
+ /**
14999
+ * 解析 launch.json 文本为全量调试配置列表(非法/缺 name/type 的条目跳过)。
15000
+ * @author ddj 2026年09月29号 / 2026年09月21号
15001
+ * @param text launch.json 文本(JSONC)
15002
+ * @param workspacePath 工作区根(${workspaceFolder}/${cwd} 替换基准;空 = 不替换)
15003
+ * @returns 全量配置(raw 原样透传)
15004
+ */
15005
+ function parseLaunchConfigs(text, workspacePath = "") {
15006
+ let data;
15007
+ try {
15008
+ data = JSON.parse(stripJsonc(text));
15009
+ } catch {
15010
+ return [];
15011
+ }
15012
+ const list = data?.configurations;
15013
+ if (!Array.isArray(list)) return [];
15014
+ const out = [];
15015
+ for (const item of list) {
15016
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
15017
+ const cfg = item;
15018
+ const type = asString(cfg.type);
15019
+ const name = asString(cfg.name);
15020
+ if (!type || !name) continue;
15021
+ out.push({
15022
+ name,
15023
+ type,
15024
+ request: asString(cfg.request),
15025
+ processName: asString(cfg.processName),
15026
+ pid: asPositive(cfg.pid),
15027
+ processId: asPositive(cfg.processId),
15028
+ host: asString(cfg.host),
15029
+ port: asPositive(cfg.port),
15030
+ sourcePaths: stringListOf(cfg.sourcePaths),
15031
+ ext: stringListOf(cfg.ext),
15032
+ captureLog: typeof cfg.captureLog === "boolean" ? cfg.captureLog : void 0,
15033
+ raw: expandRaw(cfg, workspacePath)
15034
+ });
15035
+ }
15036
+ return out;
15037
+ }
15038
+ /**
15039
+ * 配置是否含 ${command:...} 占位(VS Code 命令,本插件无法解析,需用户改为具体值)。
15040
+ * @author ddj 2026年09月21号
15041
+ * @param config 调试配置
15042
+ */
15043
+ function hasCmdPlaceholder(config) {
15044
+ return JSON.stringify(config.raw ?? {}).includes("${command:");
15045
+ }
15046
+ /** ${workspaceFolder}/${cwd} → 工作区根(大小写不敏感;其余占位保持原样交适配器)。 */
15047
+ function expandLaunchVars(text, workspacePath) {
15048
+ if (!workspacePath) return text;
15049
+ return text.replace(/\$\{workspaceFolder\}/gi, workspacePath).replace(/\$\{cwd\}/gi, workspacePath);
15050
+ }
15051
+ /** raw 逐字段递归展开(字符串内占位替换;数组/嵌套对象同口径)。 */
15052
+ function expandRaw(raw, workspacePath) {
15053
+ const out = {};
15054
+ for (const [key, value] of Object.entries(raw)) out[key] = expandValue(value, workspacePath);
15055
+ return out;
15056
+ }
15057
+ /** 单值展开。 */
15058
+ function expandValue(value, workspacePath) {
15059
+ if (typeof value === "string") return expandLaunchVars(value, workspacePath);
15060
+ if (Array.isArray(value)) return value.map((v) => expandValue(v, workspacePath));
15061
+ if (value && typeof value === "object") return expandRaw(value, workspacePath);
15062
+ return value;
15063
+ }
15064
+ /** unknown → 字符串(非字符串返回 undefined)。 */
15065
+ function asString(value) {
15066
+ return typeof value === "string" && value ? value : void 0;
15067
+ }
15068
+ /** unknown → 正数(number 且 >0;其余 undefined)。 */
15069
+ function asPositive(value) {
15070
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
15071
+ }
15072
+ /** 字符串数组字段清洗(非字符串元素剔除;空数组返回 undefined)。 */
15073
+ function stringListOf(raw) {
15074
+ if (!Array.isArray(raw)) return void 0;
15075
+ const items = raw.filter((v) => typeof v === "string" && v.length > 0);
15076
+ return items.length ? items : void 0;
15077
+ }
15078
+ //#endregion
15079
+ //#region src/dap/configSnippets.ts
15080
+ /**
15081
+ * dsh-vscode-mode host — launch.json「添加配置」片段提取。
15082
+ * 扫描 VSCode 扩展清单 contributes.debuggers[]:
15083
+ * - configurationSnippets[](label/description/body)优先;缺失时 initialConfigurations[] 兜底
15084
+ * (条目本身就是配置体,label 取 body.name → debugger.label → type);
15085
+ * - %key% 占位符按 package.nls.json → package.nls.zh-cn.json 合并表解析(zh 覆盖 base),
15086
+ * 解析失败剥 %…% 外壳(避免裸占位符上屏);
15087
+ * - body 缺 type/name 时补齐(parseLaunchConfigs 要求两者,缺了插进去也不可见)。
15088
+ * 与 discovery 各自独立缓存(片段随扩展安装变化;force 重扫,测试隔离)。
15089
+ * 作者 ddj 2026年09月22号
15090
+ */
15091
+ /** 发现缓存(null = 未加载;按 home 隔离,测试用 force/clear 复位)。 */
15092
+ let cache$1 = null;
15093
+ let cacheHome$1 = "";
15094
+ /**
15095
+ * 全部可添加调试配置片段(按 label 字典序稳定输出,type+label 去重)。
15096
+ * @author ddj 2026年09月22号
15097
+ * @param force 强制重扫(扩展安装后 / 测试隔离用)
15098
+ * @param home DSH home(缺省真实;测试可注入)
15099
+ * @returns 片段列表
15100
+ */
15101
+ function dapConfigSnippets(force = false, home = dshHome()) {
15102
+ if (!force && cache$1 !== null && home === cacheHome$1) return cache$1;
15103
+ cache$1 = scanSnippets(home);
15104
+ cacheHome$1 = home;
15105
+ return cache$1;
15106
+ }
15107
+ /**
15108
+ * 按适配器可用性标注片段(与调试工具条同源裁决 debuggerDecls):
15109
+ * type 命中 decl → 透传 available/reason;未命中(清单无该 type 的可用适配器声明)
15110
+ * → available=false +「未发现该类型适配器」。下拉据此过滤「选得进、跑不了」的条目。
15111
+ * @author ddj 2026年09月22号
15112
+ * @param snippets 片段列表(dapConfigSnippets 产物)
15113
+ * @param decls 适配器声明表(debuggerDecls())
15114
+ * @returns 带可用性标注的片段列表(新数组,不改入参)
15115
+ */
15116
+ function annotateSnippets(snippets, decls) {
15117
+ const byType = new Map(decls.map((decl) => [decl.type, decl]));
15118
+ return snippets.map((snip) => {
15119
+ const decl = byType.get(snip.type);
15120
+ if (!decl) return {
15121
+ ...snip,
15122
+ available: false,
15123
+ reason: "未发现该类型适配器"
15124
+ };
15125
+ const annotated = {
15126
+ ...snip,
15127
+ available: decl.available
15128
+ };
15129
+ if (!decl.available && decl.reason) annotated.reason = decl.reason;
15130
+ return annotated;
15131
+ });
15132
+ }
15133
+ /** 扫描全部扩展根收集片段(先到先得去重,输出按 label 排序)。 */
15134
+ function scanSnippets(home) {
15135
+ const out = [];
15136
+ const seen = /* @__PURE__ */ new Set();
15137
+ for (const root of vscodeExtensionsDirs(home)) for (const extDir of subDirsOf$1(root)) collectExt(extDir, out, seen);
15138
+ return out.sort((a, b) => a.label.localeCompare(b.label));
15139
+ }
15140
+ /** 单扩展处理:有产出原料才读 nls(多数扩展无调试片段,不白读两个小文件)。 */
15141
+ function collectExt(extDir, out, seen) {
15142
+ const plans = debuggersOf$1(extDir).map((entry) => ({
15143
+ entry,
15144
+ type: strOf(entry.type),
15145
+ items: rawItemsOf(entry)
15146
+ })).filter((plan) => Boolean(plan.type) && plan.items.length > 0);
15147
+ if (!plans.length) return;
15148
+ const nls = readNlsTable(extDir);
15149
+ for (const plan of plans) for (const item of plan.items) {
15150
+ const snippet = projectSnippet(plan.entry, plan.type, item, nls);
15151
+ const key = plan.type + "\0" + snippet.label;
15152
+ if (seen.has(key)) continue;
15153
+ seen.add(key);
15154
+ out.push(snippet);
15155
+ }
15156
+ }
15157
+ /** 清单条目 → 片段投影(nls 解析 + type/name 补齐)。 */
15158
+ function projectSnippet(entry, type, item, nls) {
15159
+ const body = resolveNlsDeep(item.body, nls);
15160
+ if (!strOf(body.type)) body.type = type;
15161
+ const label = resolveNlsText(item.label ?? "", nls) || strOf(body.name) || resolveNlsText(strOf(entry.label), nls) || type;
15162
+ if (!strOf(body.name)) body.name = label;
15163
+ const snippet = {
15164
+ label,
15165
+ type: strOf(body.type) || type,
15166
+ bodyText: JSON.stringify(body, null, 2)
15167
+ };
15168
+ const description = resolveNlsText(item.description ?? "", nls);
15169
+ if (description) snippet.description = description;
15170
+ return snippet;
15171
+ }
15172
+ /** 读扩展根下的扩展子目录(根不存在返回空;与 discovery 同口径)。 */
15173
+ function subDirsOf$1(root) {
15174
+ let entries;
15175
+ try {
15176
+ entries = readdirSync(root, { withFileTypes: true });
15177
+ } catch {
15178
+ return [];
15179
+ }
15180
+ return entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name));
15181
+ }
15182
+ /** 读某扩展的 contributes.debuggers(无清单/无声明/解析失败返回空)。 */
15183
+ function debuggersOf$1(extDir) {
15184
+ try {
15185
+ const text = readFileSync(join(extDir, "package.json"), "utf8").replace(/^/, "");
15186
+ const list = JSON.parse(text).contributes?.debuggers;
15187
+ return Array.isArray(list) ? list : [];
15188
+ } catch {
15189
+ return [];
15190
+ }
15191
+ }
15192
+ /** 条目 → 原料列表:configurationSnippets 优先,initialConfigurations 兜底(条目即配置体)。 */
15193
+ function rawItemsOf(entry) {
15194
+ const out = [];
15195
+ const snippets = asArray(entry.configurationSnippets);
15196
+ for (const raw of snippets) {
15197
+ const snippet = asRecord(raw);
15198
+ if (!snippet) continue;
15199
+ const body = asRecord(snippet.body);
15200
+ if (!body) continue;
15201
+ out.push({
15202
+ label: strOf(snippet.label),
15203
+ description: strOf(snippet.description),
15204
+ body
15205
+ });
15206
+ }
15207
+ if (out.length) return out;
15208
+ for (const raw of asArray(entry.initialConfigurations)) {
15209
+ const body = asRecord(raw);
15210
+ if (body) out.push({ body });
15211
+ }
15212
+ return out;
15213
+ }
15214
+ /**
15215
+ * 读扩展 nls 表(base 先入,zh-cn 后入覆盖;文件缺失/解析失败按空跳过)。
15216
+ * @author ddj 2026年09月22号
15217
+ * @param extDir 扩展根目录
15218
+ * @returns %key% → 文本 表
15219
+ */
15220
+ function readNlsTable(extDir) {
15221
+ const table = {};
15222
+ for (const file of ["package.nls.json", "package.nls.zh-cn.json"]) try {
15223
+ const path = join(extDir, file);
15224
+ if (!existsSync(path)) continue;
15225
+ const data = JSON.parse(readFileSync(path, "utf8").replace(/^/, ""));
15226
+ for (const [key, value] of Object.entries(data)) if (typeof value === "string") table[key] = value;
15227
+ } catch {}
15228
+ return table;
15229
+ }
15230
+ /**
15231
+ * 解析单串 %key% 占位(最多 3 轮:nls 值可再含占位;未命中剥 %…% 外壳)。
15232
+ * @author ddj 2026年09月22号
15233
+ * @param value 原文
15234
+ * @param table nls 表
15235
+ * @returns 解析后文本(无占位原样返回)
15236
+ */
15237
+ function resolveNlsText(value, table) {
15238
+ if (!value.includes("%")) return value;
15239
+ let out = value;
15240
+ for (let round = 0; round < 3; round += 1) {
15241
+ const next = out.replace(/%([A-Za-z0-9_.-]+)%/g, (_match, key) => table[key] ?? key);
15242
+ if (next === out) break;
15243
+ out = next;
15244
+ }
15245
+ return out;
15246
+ }
15247
+ /** 深度解析任意值内的 %key%(字符串逐个过 resolveNlsText,对象/数组递归)。 */
15248
+ function resolveNlsDeep(value, table) {
15249
+ if (typeof value === "string") return resolveNlsText(value, table);
15250
+ if (Array.isArray(value)) return value.map((item) => resolveNlsDeep(item, table));
15251
+ if (value && typeof value === "object") {
15252
+ const out = {};
15253
+ for (const [key, item] of Object.entries(value)) out[key] = resolveNlsDeep(item, table);
15254
+ return out;
15255
+ }
15256
+ return value;
15257
+ }
15258
+ /** unknown → 非空字符串(其余空串)。 */
15259
+ function strOf(value) {
15260
+ return typeof value === "string" && value ? value : "";
15261
+ }
15262
+ /** unknown → 普通对象(数组/原始值返回 null)。 */
15263
+ function asRecord(value) {
15264
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
15265
+ }
15266
+ /** unknown → 数组(单对象包一层,兼容 initialConfigurations 单对象写法)。 */
15267
+ function asArray(value) {
15268
+ if (Array.isArray(value)) return value;
15269
+ return value && typeof value === "object" ? [value] : [];
15270
+ }
15271
+ //#endregion
15272
+ //#region src/dap/processList.ts
15273
+ /**
15274
+ * dsh-vscode-mode host — 调试目标进程枚举(Windows)。
15275
+ * 用 PowerShell Get-Process 取 窗口标题/进程名/路径(完整 Unicode,无 GBK 乱码),
15276
+ * 等价替代 VS Code 扩展的 emmy_tool.exe list_processes(扩展侧 QuickPick 的数据源),
15277
+ * 匹配语义一致:processName 对 窗口标题 或 进程文件名 做**包含**匹配。
15278
+ * emmylua_attach 仅支持 Windows(适配器原生限制),非 Windows 返回空表。
15279
+ * 作者 ddj 2026年09月29号
15280
+ */
15281
+ /** PowerShell 单行命令:仅带主窗口的进程,JSON 输出(UTF-8)。 */
15282
+ const PROCESS_LIST_CMD = "$OutputEncoding=[Text.Encoding]::UTF8;[Console]::OutputEncoding=[Text.Encoding]::UTF8;Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object Id,ProcessName,MainWindowTitle,Path | ConvertTo-Json -Compress -Depth 2";
15283
+ /**
15284
+ * 解析 ConvertTo-Json 输出为进程列表(纯函数,可单测)。
15285
+ * 单条时 PowerShell 输出对象而非数组,两种形状都接受。
15286
+ * @author ddj 2026年09月29号
15287
+ * @param text PowerShell stdout
15288
+ * @returns 进程列表(无效行剔除)
15289
+ */
15290
+ function parseProcessListJson(text) {
15291
+ let data;
15292
+ try {
15293
+ data = JSON.parse(text);
15294
+ } catch {
15295
+ return [];
15296
+ }
15297
+ const rows = Array.isArray(data) ? data : [data];
15298
+ const out = [];
15299
+ for (const row of rows) {
15300
+ if (!row || typeof row !== "object") continue;
15301
+ const item = row;
15302
+ const pid = typeof item.Id === "number" ? item.Id : NaN;
15303
+ if (!Number.isFinite(pid) || pid <= 0) continue;
15304
+ const path = typeof item.Path === "string" ? item.Path : "";
15305
+ const name = path ? path.split(/[\\/]/).pop() ?? item.ProcessName ?? "" : (item.ProcessName ?? "") + ".exe";
15306
+ out.push({
15307
+ pid,
15308
+ title: typeof item.MainWindowTitle === "string" ? item.MainWindowTitle : "",
15309
+ path,
15310
+ name
15311
+ });
15312
+ }
15313
+ return out;
15314
+ }
15315
+ /**
15316
+ * 按 processName 过滤进程(与 VS Code 扩展口径一致:标题或文件名包含匹配;空串全量)。
15317
+ * @author ddj 2026年09月29号
15318
+ * @param items 全量进程
15319
+ * @param processName 过滤词
15320
+ */
15321
+ function filterProcesses(items, processName) {
15322
+ const needle = (processName ?? "").trim();
15323
+ if (!needle) return items;
15324
+ return items.filter((it) => it.title.includes(needle) || it.name.includes(needle));
15325
+ }
15326
+ /**
15327
+ * 枚举带主窗口的进程(Windows;spawn 失败/非 Windows 返回空表不抛错)。
15328
+ * @author ddj 2026年09月29号
15329
+ */
15330
+ function listProcesses() {
15331
+ if (process.platform !== "win32") return Promise.resolve([]);
15332
+ return new Promise((resolve) => {
15333
+ execFile("powershell.exe", [
15334
+ "-NoProfile",
15335
+ "-NonInteractive",
15336
+ "-Command",
15337
+ PROCESS_LIST_CMD
15338
+ ], {
15339
+ encoding: "utf8",
15340
+ windowsHide: true,
15341
+ timeout: 15e3,
15342
+ maxBuffer: 8 << 20
15343
+ }, (error, stdout) => {
15344
+ if (error && !stdout) {
15345
+ resolve([]);
15346
+ return;
15347
+ }
15348
+ resolve(parseProcessListJson(String(stdout ?? "")));
15349
+ });
15350
+ });
15351
+ }
15352
+ //#endregion
15353
+ //#region src/dap/discovery.ts
15354
+ /**
15355
+ * dsh-vscode-mode host — 调试适配器发现(清单驱动,VSCode contributes.debuggers 同源语义)。
15356
+ * 扫描全部扩展根(复用 lsp 侧 vscodeExtensionsDirs:~/.vscode/extensions、
15357
+ * ~/.vscode-server/extensions、插件扩展目录),读各扩展 package.json 的
15358
+ * contributes.debuggers[] 声明,解析为 DapAdapterDecl:
15359
+ * - program 按平台取 windows/linux/osx 覆盖,相对扩展根解析;
15360
+ * - runtime='node' → 由 node 跑 js 入口;无 runtime → program 即可执行(args 为清单声明);
15361
+ * - 同 type 多扩展并存 → 按「清单版本降序(numeric) → 路径升序」取唯一(与 LSP 候选口径一致);
15362
+ * - 入口不存在 / runtime 不支持 → available=false + reason(如 DotRush 按需下载的 clrdbg,
15363
+ * 诚实标注不假成功,调用方据此在下拉置灰并提示原因)。
15364
+ * 作者 ddj 2026年09月21号
15365
+ */
15366
+ /** 发现缓存(扩展清单会话内基本不变;测试用 force 重扫)。 */
15367
+ let cacheReady = false;
15368
+ let cacheHome = "";
15369
+ let cache = [];
15370
+ /**
15371
+ * 发现全部调试适配器声明(按 type 去重、type 字典序稳定输出)。
15372
+ * @author ddj 2026年09月21号
15373
+ * @param force 强制重扫(扩展安装后 / 测试隔离用)
15374
+ * @param home DSH home(缺省真实;测试可注入)
15375
+ */
15376
+ function debuggerDecls(force = false, home = dshHome()) {
15377
+ if (cacheReady && !force && home === cacheHome) return cache;
15378
+ cache = scanDecls(home);
15379
+ cacheHome = home;
15380
+ cacheReady = true;
15381
+ return cache;
15382
+ }
15383
+ /** 扫描全部扩展根并收集声明(同 type 去重后返回)。 */
15384
+ function scanDecls(home) {
15385
+ const hits = [];
15386
+ for (const root of vscodeExtensionsDirs(home)) for (const extDir of subDirsOf(root)) for (const entry of debuggersOf(extDir)) {
15387
+ const decl = declOf(extDir, entry);
15388
+ if (decl) hits.push({
15389
+ decl,
15390
+ version: manifestVersionOf(extDir) ?? ""
15391
+ });
15392
+ }
15393
+ return pickPerType(hits);
15394
+ }
15395
+ /** 同 type 去重:清单版本降序(numeric)→ 路径升序;输出按 type 字典序。 */
15396
+ function pickPerType(hits) {
15397
+ const byType = /* @__PURE__ */ new Map();
15398
+ for (const hit of hits) {
15399
+ if (!betterOf(hit, byType.get(hit.decl.type))) continue;
15400
+ byType.set(hit.decl.type, hit);
15401
+ }
15402
+ return [...byType.values()].map((h) => h.decl).sort((a, b) => a.type.localeCompare(b.type));
15403
+ }
15404
+ /** 候选比较:版本降序优先,平版本路径升序(无前任直接胜出)。 */
15405
+ function betterOf(hit, prev) {
15406
+ if (!prev) return true;
15407
+ const byVersion = compareVersion(hit.version, prev.version);
15408
+ if (byVersion !== 0) return byVersion > 0;
15409
+ return hit.decl.extensionPath.localeCompare(prev.decl.extensionPath) < 0;
15410
+ }
15411
+ /** 清单版本比较(numeric 感知,与 LSP 候选排序同口径):>0 表示 a 更新。 */
15412
+ function compareVersion(a, b) {
15413
+ return String(a || "").localeCompare(String(b || ""), void 0, { numeric: true });
15414
+ }
15415
+ /** 枚举扩展根下的扩展子目录(根不存在返回空)。 */
15416
+ function subDirsOf(root) {
15417
+ let entries;
15418
+ try {
15419
+ entries = readdirSync(root, { withFileTypes: true });
15420
+ } catch {
15421
+ return [];
15422
+ }
15423
+ return entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name));
15424
+ }
15425
+ /** 读某扩展的 contributes.debuggers 声明(无清单/无声明/解析失败返回空)。 */
15426
+ function debuggersOf(extDir) {
15427
+ try {
15428
+ const text = readFileSync(join(extDir, "package.json"), "utf8").replace(/^\uFEFF/, "");
15429
+ const list = JSON.parse(text).contributes?.debuggers;
15430
+ return Array.isArray(list) ? list : [];
15431
+ } catch {
15432
+ return [];
15433
+ }
15434
+ }
15435
+ /** 清单条目 → 声明(缺 type/program 视为无效条目跳过)。 */
15436
+ function declOf(extDir, entry) {
15437
+ const type = typeof entry.type === "string" ? entry.type : "";
15438
+ if (!type || typeof entry.program !== "string" || !entry.program) return null;
15439
+ const languages = stringsOf(entry.languages);
15440
+ const resolved = resolveProgram(extDir, entry);
15441
+ const runtime = typeof entry.runtime === "string" && entry.runtime ? entry.runtime : void 0;
15442
+ const runtimeBad = runtime !== void 0 && runtime !== "node";
15443
+ const available = resolved.available && !runtimeBad;
15444
+ const reason = !resolved.available ? resolved.reason : runtimeBad ? "暂不支持运行时 \"" + runtime + "\"(仅支持 node 与原生可执行)" : void 0;
15445
+ return {
15446
+ type,
15447
+ label: typeof entry.label === "string" && entry.label ? entry.label : type,
15448
+ extensionId: extensionIdOf(extDir),
15449
+ extensionPath: extDir,
15450
+ program: resolved.program,
15451
+ args: stringsOf(entry.args),
15452
+ runtime,
15453
+ languages,
15454
+ exts: extsOf(entry, languages),
15455
+ available,
15456
+ reason
15457
+ };
15458
+ }
15459
+ /** 平台 program 解析:windows/linux/osx(darwin) 覆盖优先,相对扩展根(剥 ./)。 */
15460
+ function resolveProgram(extDir, entry) {
15461
+ const plat = process.platform;
15462
+ const override = plat === "win32" ? entry.windows?.program : plat === "darwin" ? entry.osx?.program ?? entry.darwin?.program : entry.linux?.program;
15463
+ const rel = typeof override === "string" && override ? override : entry.program;
15464
+ if (typeof rel !== "string" || !rel) return {
15465
+ program: "",
15466
+ available: false,
15467
+ reason: "清单未声明本平台 program"
15468
+ };
15469
+ const program = join(extDir, ...rel.replace(/^\.\//, "").split(/[\\/]/));
15470
+ if (!existsSync(program)) return {
15471
+ program,
15472
+ available: false,
15473
+ reason: "适配器入口不存在(扩展可能需按需下载):" + rel
15474
+ };
15475
+ return {
15476
+ program,
15477
+ available: true
15478
+ };
15479
+ }
15480
+ /** 来源扩展 id(目录名 publisher.name-version 取 version 前段;解析失败回退目录名)。 */
15481
+ function extensionIdOf(extDir) {
15482
+ const base = extDir.split(/[\\/]/).pop() ?? "";
15483
+ const dot = base.lastIndexOf("-");
15484
+ return dot > 0 ? base.slice(0, dot) : base;
15485
+ }
15486
+ /** 可下断点扩展名:优先清单 configurationAttributes 的 ext 缺省(emmylua 的 .lua 家族),
15487
+ * 否则按声明 languages 映射(LSP 侧同源);两者皆空 = 不限(不过滤断点)。 */
15488
+ function extsOf(entry, languages) {
15489
+ const attrs = entry.configurationAttributes;
15490
+ const fromManifest = stringsOf(attrs?.attach?.properties?.ext?.default ?? attrs?.launch?.properties?.ext?.default);
15491
+ if (fromManifest.length) return fromManifest;
15492
+ const out = [];
15493
+ for (const lang of languages) for (const ext of LSP_LANG_EXT[lang] ?? []) {
15494
+ const dotted = "." + ext.replace(/^\./, "");
15495
+ if (!out.includes(dotted)) out.push(dotted);
15496
+ }
15497
+ return out;
15498
+ }
15499
+ /** unknown → 字符串数组(非字符串元素剔除)。 */
15500
+ function stringsOf(value) {
15501
+ if (!Array.isArray(value)) return [];
15502
+ return value.filter((v) => typeof v === "string" && v.length > 0);
15503
+ }
15504
+ //#endregion
15505
+ //#region src/dap/provider.ts
15506
+ /**
15507
+ * dsh-vscode-mode host — 调试适配器规格解析:按调试类型从扩展清单声明(discovery)
15508
+ * 解析 spawn 规格,通用不限定语言:
15509
+ * - runtime='node' → node 跑 js 入口(emmylua 形态,等价旧 process.execPath 硬编码);
15510
+ * - 无 runtime → program 即原生可执行(clrdbg/monodbg 形态,args 为清单声明如 --interpreter=vscode);
15511
+ * - 其它 runtime 明确不支持(decl.available=false 带原因,不做假启动)。
15512
+ * 旧版仅探测 tangzx.emmylua 两型(attach/new),由清单驱动后自然覆盖并扩展到任意适配器。
15513
+ * 作者 ddj 2026年09月29号 / 2026年09月21号
15514
+ */
15515
+ /**
15516
+ * 解析某调试类型的适配器启动规格。
15517
+ * @author ddj 2026年09月29号 / 2026年09月21号
15518
+ * @param type 调试类型(launch.json type = 清单 contributes.debuggers.type)
15519
+ * @param force 强制重扫扩展清单(测试隔离用)
15520
+ * @param home DSH home(缺省真实;测试可注入)
15521
+ * @returns 启动规格;类型未发现 / 不可用 / runtime 不支持返回 null(原因见 discovery 声明)
15522
+ */
15523
+ function resolveAdapterSpec(type, force = false, home) {
15524
+ const decl = debuggerDecls(force, home).find((d) => d.type === type);
15525
+ if (!decl || !decl.available) return null;
15526
+ const base = {
15527
+ type,
15528
+ extensionPath: decl.extensionPath,
15529
+ extensionId: decl.extensionId,
15530
+ exts: decl.exts,
15531
+ detail: decl.extensionPath
15532
+ };
15533
+ if (decl.runtime === "node") return {
15534
+ ...base,
15535
+ command: process.execPath,
15536
+ args: [decl.program, ...decl.args]
15537
+ };
15538
+ if (!decl.runtime) return {
15539
+ ...base,
15540
+ command: decl.program,
15541
+ args: [...decl.args]
15542
+ };
15543
+ return null;
15544
+ }
15545
+ //#endregion
15546
+ //#region src/dap/protocol.ts
15547
+ /** 客户端 → 适配器请求帧构造。 */
15548
+ function dapRequest(seq, command, args) {
15549
+ return args === void 0 ? {
15550
+ seq,
15551
+ type: "request",
15552
+ command
15553
+ } : {
15554
+ seq,
15555
+ type: "request",
15556
+ command,
15557
+ arguments: args
15558
+ };
15559
+ }
15560
+ /** 响应守卫。 */
15561
+ function isDapResponse(msg) {
15562
+ return msg.type === "response" && typeof msg.request_seq === "number";
15563
+ }
15564
+ /** 事件守卫。 */
15565
+ function isDapEvent(msg) {
15566
+ return msg.type === "event" && typeof msg.event === "string";
15567
+ }
15568
+ /** 事件体安全取值。 */
15569
+ function eventBody(event) {
15570
+ return event.body && typeof event.body === "object" ? event.body : {};
15571
+ }
15572
+ /** 响应体安全取值。 */
15573
+ function responseBody(response) {
15574
+ return response.body && typeof response.body === "object" ? response.body : {};
15575
+ }
15576
+ /**
15577
+ * initialize 请求参数(适配器协商口径:path 格式、行列 1 基)。
15578
+ * @author ddj 2026年09月29号
15579
+ * @param adapterId 适配器 id
15580
+ */
15581
+ function initializeArgs(adapterId) {
15582
+ return {
15583
+ adapterID: adapterId,
15584
+ locale: "zh-cn",
15585
+ linesStartAt1: true,
15586
+ columnsStartAt1: true,
15587
+ pathFormat: "path",
15588
+ supportsVariableType: true,
15589
+ supportsVariablePaging: false,
15590
+ supportsRunInTerminalRequest: false
15591
+ };
15592
+ }
15593
+ //#endregion
15594
+ //#region src/dap/manager.ts
15595
+ /**
15596
+ * dsh-vscode-mode host — 调试会话管理器(DAP 桥核心,适配器通用)。
15597
+ * 单例调试会话:spawn 扩展清单声明的 DAP 适配器(node js 或原生 exe)↔ 本管理器翻译成
15598
+ * edrv.dap.* 视图。要点(emmylua 源自适配器源码逆向,探针验证;通用语义对齐 DAP 3.x):
15599
+ * - 帧协议与 LSP 相同(Content-Length)→ 复用 lsp/jsonrpc.ts;
15600
+ * - initialize → attach/launch 响应后适配器异步连目标,连上才发 initialized 事件,
15601
+ * 此时需把已存断点全量重发;随后按能力位发 configurationDone(emmylua 未声明该能力
15602
+ * 不发,行为不变;clrdbg 等声明了必须发,否则目标挂起等待);
15603
+ * - 停帧后 stackTrace/scopes/variables/evaluate 才有效(适配器未连接时 evaluate 永不回包,
15604
+ * 必须守卫 + 请求级超时);
15605
+ * - stackTrace 期间 emmylua 适配器可能发 findFileReq **反向请求**等 findFileRsp
15606
+ * (chunkname→文件由 IDE 侧解析),本管理器经注入的文件索引回调应答;
15607
+ * 其它适配器直接上报可打开的源码路径,不走该扩展事件;
15608
+ * - 线程:emmylua 固定 threadId=1;通用适配器取 stopped 事件的真实 threadId,
15609
+ * 栈/单步/暂停均携带;
15610
+ * - emmylua 的 scopes frameId 是 stacks 数组下标(私有语义),标准适配器用原生 frame id;
15611
+ * - 适配器对无效 pid 会未捕获异常崩溃 → attach 前必须先经进程枚举校验 pid。
15612
+ * 作者 ddj 2026年09月29号 / 2026年09月21号
15613
+ */
15614
+ const EVENT_CAP = 500;
15615
+ const REQUEST_TIMEOUT_MS = 8e3;
15616
+ /** emmylua 兼容 shim 的来源扩展 id(extensionPath/pid 等缺省注入仅对其生效)。 */
15617
+ const EMMYLUA_EXT_ID = "tangzx.emmylua";
15618
+ /** 判断 source 是否已经是可定位的源码路径(exts 由调用方按当前适配器给出)。 */
15619
+ function hasSourceExtension(value, exts) {
15620
+ const path = normalizeSourcePath(value).toLowerCase();
15621
+ if (!path) return false;
15622
+ return exts.some((ext) => path.endsWith(String(ext).toLowerCase()));
15623
+ }
15624
+ /**
15625
+ * 是否为「裸 chunkname」:无目录分隔符且非盘符/UNC 前缀。
15626
+ *
15627
+ * 这类值(如 xLua 上报的 `GuideSystemMgr`)被 {@link sourcePathOf} 处理后仍是原串,
15628
+ * 客户端会按工作区根拼接(`<cwd>/GuideSystemMgr`)——该路径必然不存在,
15629
+ * 于是断点命中后页签打不开、报「文件加载失败」。故反查失败时须判定为未解析,
15630
+ * 而不是把它当成文件路径下发。
15631
+ * @author ddj 2026年09月21号
15632
+ * @param value 适配器上报的 source 值
15633
+ * @returns 是否裸 chunkname
15634
+ */
15635
+ function isBareChunk(value) {
15636
+ const text = normalizeSourcePath(value);
15637
+ if (!text) return false;
15638
+ if (text.includes("/")) return false;
15639
+ return !/^[a-z]:/i.test(text);
15640
+ }
15641
+ /**
15642
+ * 单例调试会话。生命周期:start → starting →(initialized)running →(stopped)paused
15643
+ * →(terminated/disconnect)terminated → dispose 回 idle。
15644
+ * @author ddj 2026年09月29号
15645
+ */
15646
+ var DapSession = class {
15647
+ deps;
15648
+ child = null;
15649
+ phase = "idle";
15650
+ config = null;
15651
+ workspacePath = "";
15652
+ pid;
15653
+ error;
15654
+ /** spawn 的扩展根目录(emmylua 兼容 shim 注入 extensionPath)。 */
15655
+ extPath = "";
15656
+ /** 来源扩展 id(emmylua 兼容 shim 与 frameId 语义判定)。 */
15657
+ extId = "";
15658
+ /** 当前适配器可下断点扩展名(断点语言过滤;空 = 不限)。 */
15659
+ exts = [];
15660
+ /** initialize 响应 capabilities(configurationDone 门控)。 */
15661
+ caps = null;
15662
+ /** 当前停帧线程(多线程适配器取 stopped 事件真实 threadId;emmylua 恒 1)。 */
15663
+ threadId = 1;
15664
+ /** 断点(绝对路径),initialized 与每次 setBreakpoints 时全量下发。 */
15665
+ breakpoints = [];
15666
+ /** findFileReq chunkname → 已排序候选首路径(frame source.path 缺失时回退)。 */
15667
+ sourceFiles = /* @__PURE__ */ new Map();
15668
+ /** 停帧调用栈缓存(paused 期间供 stackTrace/topFrame)。 */
15669
+ frames = [];
15670
+ stateTop = null;
15671
+ events = [];
15672
+ eventSeq = 0;
15673
+ msgSeq = 1;
15674
+ /** 适配器 stderr 尾部缓冲(保留末尾 STDERR_TAIL_CAP 字符,退出 fail 消息附带用)。 */
15675
+ stderrTail = "";
15676
+ pending = /* @__PURE__ */ new Map();
15677
+ constructor(deps) {
15678
+ this.deps = deps;
15679
+ }
15680
+ /** 当前状态视图。 */
15681
+ stateOf() {
15682
+ return {
15683
+ phase: this.phase,
15684
+ configName: this.config?.name,
15685
+ configType: this.config?.type,
15686
+ pid: this.pid,
15687
+ error: this.error,
15688
+ topFrame: this.phase === "paused" ? this.stateTop : null
15689
+ };
15690
+ }
15691
+ /**
15692
+ * 启动调试会话(异步推进:spawn → initialize → attach/launch)。
15693
+ * @author ddj 2026年09月29号
15694
+ * @param spec 适配器启动规格
15695
+ * @param config 调试配置
15696
+ * @param workspacePath 工作区绝对路径(断点/findFile 基准)
15697
+ * @param pid 附加目标 pid(attach 型必填且必须已校验存在)
15698
+ */
15699
+ start(spec, config, workspacePath, pid) {
15700
+ if (this.child) this.killChild();
15701
+ this.config = config;
15702
+ this.workspacePath = workspacePath;
15703
+ this.extPath = spec.extensionPath;
15704
+ this.extId = spec.extensionId;
15705
+ this.exts = spec.exts;
15706
+ this.caps = null;
15707
+ this.threadId = 1;
15708
+ this.pid = pid;
15709
+ this.error = void 0;
15710
+ this.stderrTail = "";
15711
+ this.frames = [];
15712
+ this.sourceFiles.clear();
15713
+ this.stateTop = null;
15714
+ this.setPhase("starting");
15715
+ try {
15716
+ this.spawnAdapter(spec);
15717
+ this.handshake(spec, config);
15718
+ } catch (error) {
15719
+ this.fail("启动适配器失败:" + String(error));
15720
+ }
15721
+ }
15722
+ /** 停止会话(disconnect 尽力发送 + 杀进程树)。 */
15723
+ stop() {
15724
+ if (!this.child) {
15725
+ this.setPhase("idle");
15726
+ return;
15727
+ }
15728
+ this.request("disconnect").catch(() => {});
15729
+ this.killChild();
15730
+ this.pushEvent({ kind: "terminated" });
15731
+ this.setPhase("terminated");
15732
+ }
15733
+ /** 释放会话(插件卸载/进程退出):杀进程树并复位。 */
15734
+ dispose() {
15735
+ this.killChild();
15736
+ this.setPhase("idle");
15737
+ }
15738
+ /**
15739
+ * poll:状态 + since 之后的增量事件(纯读,无副作用)。
15740
+ * @author ddj 2026年09月29号
15741
+ * @param since 客户端已有游标
15742
+ */
15743
+ poll(since) {
15744
+ const events = this.events.filter((e) => e.seq > since);
15745
+ const nextSeq = this.eventSeq;
15746
+ return {
15747
+ state: this.stateOf(),
15748
+ events,
15749
+ nextSeq
15750
+ };
15751
+ }
15752
+ /**
15753
+ * 设置某文件断点(DAP per-source replace-all 语义)。
15754
+ * 不属于当前适配器语言的文件不入适配器(防跨语言串发),回执 verified=false 带原因;
15755
+ * 适配器在线时消费真实回执(可能挪行/判不可验证),离线时按旧口径乐观回执(start 后 flush 重发)。
15756
+ * @author ddj 2026年09月29号 / 2026年09月21号
15757
+ * @param absFile 目标文件绝对路径
15758
+ * @param items 该文件断点行列表
15759
+ */
15760
+ async setBreakpoints(absFile, items) {
15761
+ const allowed = this.bpAllowed(absFile);
15762
+ this.breakpoints = this.breakpoints.filter((bp) => bp.file !== absFile);
15763
+ if (allowed) for (const p of items) this.breakpoints.push({
15764
+ file: absFile,
15765
+ line: p.line,
15766
+ condition: p.condition,
15767
+ hitCondition: p.hitCondition,
15768
+ logMessage: p.logMessage
15769
+ });
15770
+ const res = allowed ? await this.request("setBreakpoints", {
15771
+ source: {
15772
+ name: absFile.split(/[\\/]/).pop() ?? "",
15773
+ path: absFile
15774
+ },
15775
+ breakpoints: items.map((p) => ({
15776
+ line: p.line,
15777
+ condition: p.condition,
15778
+ hitCondition: p.hitCondition,
15779
+ logMessage: p.logMessage
15780
+ }))
15781
+ }) : null;
15782
+ if (!res || !isDapResponse(res) || !res.success) return items.map((p) => ({
15783
+ line: p.line,
15784
+ verified: allowed,
15785
+ ...allowed ? {} : { message: "当前调试器不处理该文件类型" }
15786
+ }));
15787
+ return bpAcksOfResponse(responseBody(res), items);
15788
+ }
15789
+ /** 调用栈(paused 限定;threadId 取当前停帧线程)。 */
15790
+ async stackTrace() {
15791
+ if (this.phase !== "paused") return this.frames;
15792
+ const res = await this.request("stackTrace", { threadId: this.threadId });
15793
+ if (!res || !isDapResponse(res) || !res.success) return this.frames;
15794
+ const raw = responseBody(res).stackFrames;
15795
+ if (!Array.isArray(raw)) return this.frames;
15796
+ this.frames = await Promise.all(raw.map((item, index) => this.frameViewOf(item, index)));
15797
+ this.stateTop = this.frames[0] ? {
15798
+ file: this.frames[0].file,
15799
+ rawFile: this.frames[0].rawFile,
15800
+ line: this.frames[0].line,
15801
+ name: this.frames[0].name
15802
+ } : null;
15803
+ return this.frames;
15804
+ }
15805
+ /** 作用域列表(paused 限定;frameId 语义随适配器:emmylua=栈下标,标准=原生 frame id)。 */
15806
+ async scopes(frameId) {
15807
+ if (this.phase !== "paused") return [];
15808
+ const res = await this.request("scopes", { frameId });
15809
+ if (!res || !isDapResponse(res) || !res.success) return [];
15810
+ const raw = responseBody(res).scopes;
15811
+ if (!Array.isArray(raw)) return [];
15812
+ return raw.map((item) => {
15813
+ const s = item;
15814
+ return {
15815
+ name: String(s.name ?? ""),
15816
+ ref: typeof s.variablesReference === "number" ? s.variablesReference : 0,
15817
+ hint: typeof s.presentationHint === "string" ? s.presentationHint : void 0
15818
+ };
15819
+ });
15820
+ }
15821
+ /** 变量子级(paused 限定)。 */
15822
+ async variables(ref) {
15823
+ if (this.phase !== "paused") return [];
15824
+ const res = await this.request("variables", { variablesReference: ref });
15825
+ if (!res || !isDapResponse(res) || !res.success) return [];
15826
+ const raw = responseBody(res).variables;
15827
+ if (!Array.isArray(raw)) return [];
15828
+ return raw.map((item) => {
15829
+ const v = item;
15830
+ return {
15831
+ name: String(v.name ?? ""),
15832
+ value: String(v.value ?? ""),
15833
+ type: typeof v.type === "string" ? v.type : void 0,
15834
+ ref: typeof v.variablesReference === "number" ? v.variablesReference : 0
15835
+ };
15836
+ });
15837
+ }
15838
+ /** 表达式求值(REPL/监视;paused 限定,避免适配器挂起)。 */
15839
+ async evaluate(expression, frameId) {
15840
+ if (this.phase !== "paused") return {
15841
+ result: "未暂停:仅在断点暂停时可求值",
15842
+ ref: 0
15843
+ };
15844
+ const res = await this.request("evaluate", {
15845
+ expression,
15846
+ frameId: frameId ?? 0,
15847
+ context: "repl"
15848
+ });
15849
+ if (!res || !isDapResponse(res) || !res.success) return {
15850
+ result: "求值失败",
15851
+ ref: 0
15852
+ };
15853
+ const body = responseBody(res);
15854
+ return {
15855
+ result: String(body.result ?? ""),
15856
+ type: typeof body.type === "string" ? body.type : void 0,
15857
+ ref: typeof body.variablesReference === "number" ? body.variablesReference : 0
15858
+ };
15859
+ }
15860
+ /** 调试控制动作(线程型命令携带当前停帧线程;emmylua 单线程 threadId 恒 1 不受影响)。 */
15861
+ action(action) {
15862
+ if (!DAP_ACTIONS.includes(action)) return;
15863
+ const command = action === "disconnect" ? "disconnect" : action;
15864
+ this.request(command, action === "disconnect" ? void 0 : { threadId: this.threadId }).catch(() => {});
15865
+ if (action === "continue" || action === "next" || action === "stepIn" || action === "stepOut") this.setPhase("running");
15866
+ }
15867
+ /** spawn 适配器进程并接好帧解析。 */
15868
+ spawnAdapter(spec) {
15869
+ const child = spawn(spec.command, spec.args, {
15870
+ stdio: [
15871
+ "pipe",
15872
+ "pipe",
15873
+ "pipe"
15874
+ ],
15875
+ windowsHide: true
15876
+ });
15877
+ this.child = child;
15878
+ const parser = createFrameParser();
15879
+ child.stdout?.on("data", (chunk) => {
15880
+ for (const message of parser.push(chunk)) this.handleMessage(message);
15881
+ });
15882
+ child.stderr?.on("data", (chunk) => {
15883
+ log.debug("[dap-adapter] " + chunk.toString("utf8").trim());
15884
+ this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-2e3);
15885
+ });
15886
+ child.on("error", (error) => this.fail("适配器进程错误:" + String(error)));
15887
+ child.on("exit", (code) => {
15888
+ if (this.child === child && this.phase !== "idle" && this.phase !== "terminated") this.fail("适配器退出(code=" + code + ")" + this.stderrSuffix());
15889
+ });
15890
+ }
15891
+ /**
15892
+ * stderr 尾部摘要(供 fail 消息附带):取末尾若干非空行、以 | 折叠单行化。
15893
+ * 无 stderr 内容时返回空串(不改变既有消息文案)。
15894
+ * @author ddj 2026年09月22号
15895
+ * @returns 形如「;stderr: xxx | yyy」的摘要,或空串
15896
+ */
15897
+ stderrSuffix() {
15898
+ const text = this.stderrTail.trim();
15899
+ if (!text) return "";
15900
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
15901
+ if (!lines.length) return "";
15902
+ return ";stderr: " + lines.slice(-6).join(" | ");
15903
+ }
15904
+ /** initialize → attach/launch 握手(initialized 事件后重发断点 + 按能力位收尾)。 */
15905
+ async handshake(spec, config) {
15906
+ const init = await this.request("initialize", initializeArgs(spec.type));
15907
+ if (!init || !isDapResponse(init) || !init.success) {
15908
+ this.fail("initialize 失败:" + (init && isDapResponse(init) ? init.message ?? "" : "超时"));
15909
+ return;
15910
+ }
15911
+ this.caps = responseBody(init);
15912
+ const request = requestOfConfig(config);
15913
+ const res = await this.request(request, this.requestArgs(config));
15914
+ if (!res || !isDapResponse(res) || !res.success) this.fail((request === "attach" ? "attach 失败:" : "launch 失败:") + (res && isDapResponse(res) ? res.message ?? "" : "超时"));
15915
+ }
15916
+ /**
15917
+ * attach/launch 请求参数:launch.json 原样透传(含未识别字段,对齐 VS Code 语义),
15918
+ * emmylua 兼容缺省注入(对齐扩展自身 resolveDebugConfiguration:extensionPath/pid 等)。
15919
+ * @author ddj 2026年09月21号
15920
+ * @param config 调试配置
15921
+ */
15922
+ requestArgs(config) {
15923
+ const args = { ...config.raw ?? {} };
15924
+ if (args.cwd === void 0 && this.workspacePath) args.cwd = this.workspacePath;
15925
+ if (this.extId !== EMMYLUA_EXT_ID) {
15926
+ if (!(config.type === "unity") && this.pid && requestOfConfig(config) === "attach" && args.processId === void 0) args.processId = this.pid;
15927
+ return args;
15928
+ }
15929
+ if (args.extensionPath === void 0) args.extensionPath = this.extPath;
15930
+ if (requestOfConfig(config) === "attach") {
15931
+ args.pid = this.pid ?? (typeof args.pid === "number" && args.pid > 0 ? args.pid : 0);
15932
+ if (!Array.isArray(args.sourcePaths)) args.sourcePaths = [this.workspacePath];
15933
+ if (!Array.isArray(args.ext)) args.ext = [".lua"];
15934
+ if (args.captureLog === void 0) args.captureLog = false;
15935
+ return args;
15936
+ }
15937
+ if (args.host === void 0) args.host = "localhost";
15938
+ if (args.port === void 0) args.port = 9966;
15939
+ if (args.ideConnectDebugger === void 0) args.ideConnectDebugger = true;
15940
+ if (!Array.isArray(args.sourcePaths)) args.sourcePaths = [this.workspacePath];
15941
+ if (!Array.isArray(args.ext)) args.ext = [".lua"];
15942
+ return args;
15943
+ }
15944
+ /** DAP 消息分流:响应 → pending;事件 → handleEvent。 */
15945
+ handleMessage(message) {
15946
+ if (isDapResponse(message)) {
15947
+ const entry = this.pending.get(message.request_seq);
15948
+ if (entry) {
15949
+ this.pending.delete(message.request_seq);
15950
+ entry.resolve(message);
15951
+ }
15952
+ return;
15953
+ }
15954
+ if (isDapEvent(message)) this.handleEvent(message);
15955
+ }
15956
+ /** 事件处理:initialized/stopped/terminated/output/log/findFileReq。 */
15957
+ handleEvent(event) {
15958
+ const body = eventBody(event);
15959
+ if (event.event === "initialized") {
15960
+ this.setPhase("running");
15961
+ this.flushBreakpoints();
15962
+ if (needsConfigurationDone(this.caps)) this.request("configurationDone").catch(() => {});
15963
+ return;
15964
+ }
15965
+ if (event.event === "stopped") {
15966
+ this.threadId = threadIdOfStopped(body);
15967
+ this.handleStopped(String(body.reason ?? "breakpoint"));
15968
+ return;
15969
+ }
15970
+ if (event.event === "terminated") {
15971
+ if (this.phase === "starting") {
15972
+ const hint = this.earlyTermHint();
15973
+ this.pushEvent({
15974
+ kind: "output",
15975
+ text: hint + "\n"
15976
+ });
15977
+ this.deps.trace?.(this.workspacePath, "[DEBUG terminated] " + hint);
15978
+ }
15979
+ this.pushEvent({ kind: "terminated" });
15980
+ this.setPhase("terminated");
15981
+ return;
15982
+ }
15983
+ if (event.event === "continued") {
15984
+ this.setPhase("running");
15985
+ this.pushEvent({ kind: "continued" });
15986
+ return;
15987
+ }
15988
+ if (event.event === "findFileReq") {
15989
+ this.respondFindFile(body);
15990
+ return;
15991
+ }
15992
+ if (event.event === "output" || event.event === "log") this.pushEvent({
15993
+ kind: "output",
15994
+ text: String(body.output ?? body.message ?? "")
15995
+ });
15996
+ }
15997
+ /** 停帧:置 paused + 预拉调用栈(首帧进 state.topFrame 驱动跳转高亮)。 */
15998
+ async handleStopped(reason) {
15999
+ this.pushEvent({
16000
+ kind: "stopped",
16001
+ reason
16002
+ });
16003
+ this.setPhase("paused");
16004
+ try {
16005
+ await this.stackTrace();
16006
+ } catch {}
16007
+ }
16008
+ /**
16009
+ * 初始化前终止的控制台提示:指明失败阶段并给出适配器日志位置(根因常在适配器自家日志)。
16010
+ * DotRush 扩展的 monodbg 日志固定在扩展根 bin/DebuggerMono/logs,直接给绝对路径;
16011
+ * 其它适配器回退到通用指引(宿主日志 [dap-adapter] 段有 stderr 镜像)。
16012
+ * @author ddj 2026年09月22号
16013
+ * @returns 提示文本(不含换行)
16014
+ */
16015
+ earlyTermHint() {
16016
+ return "会话在初始化完成前终止(attach/连接目标失败)" + (this.extId === "nromanov.dotrush" && this.extPath ? ";适配器日志:" + join(this.extPath, "bin", "DebuggerMono", "logs", "Error.log") : ";请查看适配器自身日志与宿主日志 [dap-adapter] 段");
16017
+ }
16018
+ /** findFileReq 反向应答:按完整 chunkname 排序工作区候选并缓存首命中。 */
16019
+ async respondFindFile(body) {
16020
+ const seq = typeof body.seq === "number" ? body.seq : -1;
16021
+ const chunk = String(body.file ?? "");
16022
+ const files = await this.deps.findFiles(this.workspacePath, chunk);
16023
+ log.debug("[dap-trace] findFileReq seq=" + seq + " chunk=" + chunk + " candidates=" + files.length + " first=" + (files[0] ?? ""));
16024
+ this.deps.trace?.(this.workspacePath, "[DEBUG findFileReq] seq=" + seq + " chunk=" + chunk + " candidates=" + files.length + " first=" + (files[0] ?? ""));
16025
+ const first = files[0];
16026
+ if (first) {
16027
+ this.sourceFiles.set(sourceKeyOf(chunk), first);
16028
+ this.sourceFiles.set(sourceKeyOf(sourceBaseOf(chunk)), first);
16029
+ }
16030
+ this.send({
16031
+ seq: this.msgSeq++,
16032
+ type: "request",
16033
+ command: "findFileRsp",
16034
+ arguments: {
16035
+ seq,
16036
+ files
16037
+ }
16038
+ });
16039
+ }
16040
+ /** 发送已存断点(initialized 后调用一次;只发属于当前适配器语言的文件)。 */
16041
+ flushBreakpoints() {
16042
+ if (!this.breakpoints.length) return;
16043
+ const byFile = /* @__PURE__ */ new Map();
16044
+ for (const bp of this.breakpoints) {
16045
+ if (!this.bpAllowed(bp.file)) continue;
16046
+ const list = byFile.get(bp.file) ?? [];
16047
+ list.push(bp);
16048
+ byFile.set(bp.file, list);
16049
+ }
16050
+ for (const [file, list] of byFile) this.request("setBreakpoints", {
16051
+ source: {
16052
+ name: file.split(/[\\/]/).pop() ?? "",
16053
+ path: file
16054
+ },
16055
+ breakpoints: list.map((bp) => ({
16056
+ line: bp.line,
16057
+ condition: bp.condition,
16058
+ hitCondition: bp.hitCondition,
16059
+ logMessage: bp.logMessage
16060
+ }))
16061
+ }).catch(() => {});
16062
+ }
16063
+ /** 断点文件是否属于当前适配器(按声明扩展名过滤;未知语言不过滤,兼容旧链路)。 */
16064
+ bpAllowed(file) {
16065
+ if (!this.exts.length) return true;
16066
+ const lower = file.toLowerCase();
16067
+ return this.exts.some((ext) => lower.endsWith(ext));
16068
+ }
16069
+ /** 栈帧源码扩展名判定集:emmylua 沿用旧口径(.lua 家族 + 配置 ext),通用适配器用声明 exts。 */
16070
+ frameExts() {
16071
+ if (this.extId !== EMMYLUA_EXT_ID) return this.exts;
16072
+ return [.../* @__PURE__ */ new Set([
16073
+ ...this.config?.ext ?? [],
16074
+ ".lua",
16075
+ ".lua.bytes"
16076
+ ])];
16077
+ }
16078
+ /** 栈帧 id:emmylua 的 scopes 以 stacks 下标为 frameId(私有语义);标准适配器用原生 id。 */
16079
+ frameIdOf(raw, index) {
16080
+ if (this.extId === EMMYLUA_EXT_ID) return index;
16081
+ return typeof raw.id === "number" ? raw.id : index;
16082
+ }
16083
+ /** 发送请求(seq 自增 + 超时守卫;适配器不回包时拒绝而非悬挂)。 */
16084
+ request(command, args) {
16085
+ if (!this.child?.stdin?.writable) return Promise.resolve(null);
16086
+ const seq = this.msgSeq++;
16087
+ const frame = dapRequest(seq, command, args);
16088
+ return new Promise((resolve) => {
16089
+ const timer = setTimeout(() => {
16090
+ this.pending.delete(seq);
16091
+ resolve(null);
16092
+ }, REQUEST_TIMEOUT_MS);
16093
+ this.pending.set(seq, {
16094
+ resolve,
16095
+ timer
16096
+ });
16097
+ this.child.stdin.write(encodeMessage(frame));
16098
+ });
16099
+ }
16100
+ /** 直发原始帧(反向请求应答等无需等待响应的场景)。 */
16101
+ send(frame) {
16102
+ if (!this.child?.stdin?.writable) return;
16103
+ this.child.stdin.write(encodeMessage(frame));
16104
+ }
16105
+ /** 追加事件(环形截断)。 */
16106
+ pushEvent(partial) {
16107
+ this.eventSeq += 1;
16108
+ this.events.push({
16109
+ seq: this.eventSeq,
16110
+ ...partial
16111
+ });
16112
+ if (this.events.length > EVENT_CAP) this.events.splice(0, this.events.length - EVENT_CAP);
16113
+ }
16114
+ /** 记录错误并终止会话。 */
16115
+ fail(message) {
16116
+ this.error = message;
16117
+ log.warn("[dap] " + message);
16118
+ this.pushEvent({
16119
+ kind: "output",
16120
+ text: message + "\n"
16121
+ });
16122
+ this.pushEvent({ kind: "terminated" });
16123
+ this.killChild();
16124
+ this.setPhase("terminated");
16125
+ }
16126
+ /** 杀适配器进程树(Windows taskkill /T /F,同 LSP transport 口径)。 */
16127
+ killChild() {
16128
+ const child = this.child;
16129
+ this.child = null;
16130
+ for (const entry of this.pending.values()) {
16131
+ clearTimeout(entry.timer);
16132
+ entry.resolve(null);
16133
+ }
16134
+ this.pending.clear();
16135
+ if (!child) return;
16136
+ try {
16137
+ child.kill();
16138
+ } catch {}
16139
+ if (process.platform === "win32" && child.pid) try {
16140
+ spawn("taskkill", [
16141
+ "/PID",
16142
+ String(child.pid),
16143
+ "/T",
16144
+ "/F"
16145
+ ], {
16146
+ windowsHide: true,
16147
+ stdio: "ignore"
16148
+ });
16149
+ } catch {}
16150
+ setTimeout(() => {
16151
+ try {
16152
+ child.kill("SIGKILL");
16153
+ } catch {}
16154
+ }, 1500).unref?.();
16155
+ }
16156
+ /** 相位切换(running→running 等幂等跳过)。 */
16157
+ setPhase(phase) {
16158
+ if (this.phase === phase) return;
16159
+ this.phase = phase;
16160
+ if (phase === "idle") {
16161
+ this.events = [];
16162
+ this.config = null;
16163
+ this.pid = void 0;
16164
+ this.error = void 0;
16165
+ }
16166
+ }
16167
+ /** 适配器上报路径 → 帧视图(file 始终保留可打开的规范路径)。 */
16168
+ async frameViewOf(item, index) {
16169
+ const raw = item;
16170
+ const source = raw.source ?? {};
16171
+ const sourcePath = typeof source.path === "string" ? source.path : "";
16172
+ const sourceName = typeof source.name === "string" ? source.name : "";
16173
+ const cached = this.sourceFiles.get(sourceKeyOf(sourcePath)) ?? this.sourceFiles.get(sourceKeyOf(sourceName));
16174
+ let selected = cached || sourcePath || sourceName;
16175
+ const needsLookup = !selected || !hasSourceExtension(selected, this.frameExts());
16176
+ log.debug("[dap-trace] frame rawPath=" + sourcePath + " name=" + sourceName + " cached=" + (cached ?? "") + " needsLookup=" + needsLookup + " selected=" + selected);
16177
+ this.deps.trace?.(this.workspacePath, "[DEBUG frame] rawPath=" + sourcePath + " name=" + sourceName + " cached=" + (cached ?? "") + " needsLookup=" + needsLookup + " selected=" + selected);
16178
+ if (!cached && needsLookup) {
16179
+ const candidates = await this.deps.findFiles(this.workspacePath, sourceName || sourcePath);
16180
+ log.debug("[dap-trace] frameLookup source=" + (sourceName || sourcePath) + " candidates=" + candidates.length + " first=" + (candidates[0] ?? ""));
16181
+ this.deps.trace?.(this.workspacePath, "[DEBUG frameLookup] source=" + (sourceName || sourcePath) + " candidates=" + candidates.length + " first=" + (candidates[0] ?? ""));
16182
+ if (candidates[0]) {
16183
+ selected = candidates[0];
16184
+ this.sourceFiles.set(sourceKeyOf(sourceName || sourcePath), candidates[0]);
16185
+ } else if (isBareChunk(sourceName || sourcePath)) selected = "";
16186
+ }
16187
+ const finalFile = selected ? sourcePathOf(selected, this.workspacePath) : "";
16188
+ log.debug("[dap-trace] frameFinal name=" + sourceName + " file=" + finalFile + " line=" + String(raw.line ?? 0));
16189
+ this.deps.trace?.(this.workspacePath, "[DEBUG frameFinal] name=" + sourceName + " file=" + finalFile + " line=" + String(raw.line ?? 0));
16190
+ return {
16191
+ id: this.frameIdOf(raw, index),
16192
+ name: String(raw.name ?? ""),
16193
+ file: finalFile,
16194
+ rawFile: sourceName || sourcePath || selected,
16195
+ line: typeof raw.line === "number" ? raw.line : 0
16196
+ };
16197
+ }
16198
+ };
16199
+ //#endregion
16200
+ //#region src/dap/rpc.ts
16201
+ /**
16202
+ * dsh-vscode-mode host — 调试 RPC(edrv.dap.*)装配。
16203
+ * 单例 DapSession + 适配器发现(扩展清单驱动,通用不限定语言)+ 进程枚举 +
16204
+ * launch.json 读取(全量透传)+ findFile 反向匹配(chunkname → ripgrep 定向查找源文件)。
16205
+ * 作者 ddj 2026年09月29号 / 2026年09月21号
16206
+ */
16207
+ /**
16208
+ * 解析工作区根为 subprocess 执行世界可见的真实路径。
16209
+ * DAP 会话无 DSH Session 对象,不能让 fs.resolve('.') 落回 host 进程目录,
16210
+ * 因此显式以 cwd 为基准解析 `.`(= cwd 本身)。
16211
+ * @author ddj 2026年09月21号
16212
+ * @param ctx DSH 上下文
16213
+ * @param workspacePath 工作区绝对路径
16214
+ * @returns 可交给 rg 的根路径;fs 不可用或解析失败返回空串
16215
+ */
16216
+ async function rootPathOf(ctx, workspacePath) {
16217
+ const fs = ctx.get("fs");
16218
+ if (!fs || !workspacePath) return "";
16219
+ try {
16220
+ const target = await fs.resolve(".", { cwd: workspacePath });
16221
+ return fs.processPath(target) || "";
16222
+ } catch (error) {
16223
+ return "";
16224
+ }
16225
+ }
16226
+ /**
16227
+ * 创建调试 RPC(单例会话随插件装配创建,ctx.effect 里调 disposeDap 清理)。
16228
+ * @author ddj 2026年09月29号
16229
+ * @param ctx DSH 上下文
16230
+ */
16231
+ function createDapRpc(ctx) {
16232
+ const session = new DapSession({
16233
+ findFiles: async (workspacePath, chunk) => {
16234
+ const clean = normalizeSourcePath(chunk);
16235
+ if (!clean) return [];
16236
+ const root = await rootPathOf(ctx, workspacePath);
16237
+ if (!root) {
16238
+ debugRecord(ctx, workspacePath, "[DEBUG findFiles] chunk=" + clean + " root=(无法解析) candidates=0", "debug");
16239
+ return [];
16240
+ }
16241
+ const ranked = rankSourceCandidates(clean, await findFilesByChunk(ctx, root, clean), workspacePath);
16242
+ log.debug("[dap-trace] findFiles chunk=" + clean + " cwd=" + workspacePath + " root=" + root + " candidates=" + ranked.length + " first=" + (ranked[0] ?? ""));
16243
+ debugRecord(ctx, workspacePath, "[DEBUG findFiles] chunk=" + clean + " root=" + root + " candidates=" + ranked.length + " first=" + (ranked[0] ?? ""), "debug");
16244
+ return ranked;
16245
+ },
16246
+ trace: (workspacePath, message) => debugRecord(ctx, workspacePath, message, "debug")
16247
+ });
16248
+ return {
16249
+ handlers: {
16250
+ "edrv.dap.configs": async (args) => {
16251
+ const configs = await readLaunchConfigs(ctx, args.workspacePath);
16252
+ return {
16253
+ ok: true,
16254
+ configs,
16255
+ adapters: debuggerDecls().map((d) => ({
16256
+ type: d.type,
16257
+ label: d.label,
16258
+ available: d.available,
16259
+ reason: d.reason
16260
+ })),
16261
+ source: configs.length ? "launchjson" : "none"
16262
+ };
16263
+ },
16264
+ "edrv.dap.snippets": async () => ({
16265
+ ok: true,
16266
+ snippets: annotateSnippets(dapConfigSnippets(), debuggerDecls())
16267
+ }),
16268
+ "edrv.dap.processes": async (args) => {
16269
+ return {
16270
+ ok: true,
16271
+ items: filterProcesses(await listProcesses(), args.processName)
16272
+ };
16273
+ },
16274
+ "edrv.dap.start": async (args) => {
16275
+ const config = args.config;
16276
+ if (hasCmdPlaceholder(config)) return {
16277
+ ok: false,
16278
+ error: "配置含 ${command:...} 占位(VS Code 命令),本插件无法解析,请在 launch.json 中改为具体值"
16279
+ };
16280
+ const spec = resolveAdapterSpec(config.type);
16281
+ if (!spec) return {
16282
+ ok: false,
16283
+ error: unknownTypeMessage(config.type)
16284
+ };
16285
+ let pid = args.pid && args.pid > 0 ? args.pid : void 0;
16286
+ if (requestOfConfig(config) === "attach" && !pid && !config.processId) {
16287
+ const resolved = await resolvePid(config.processName);
16288
+ if (typeof resolved === "string") return {
16289
+ ok: false,
16290
+ error: resolved
16291
+ };
16292
+ pid = resolved;
16293
+ }
16294
+ session.start(spec, config, args.workspacePath, pid);
16295
+ return {
16296
+ ok: true,
16297
+ phase: "starting"
16298
+ };
16299
+ },
16300
+ "edrv.dap.stop": async () => {
16301
+ session.stop();
16302
+ return { ok: true };
16303
+ },
16304
+ "edrv.dap.poll": async (args) => ({
16305
+ ok: true,
16306
+ ...session.poll(args.since)
16307
+ }),
16308
+ "edrv.dap.setBreakpoints": async (args) => {
16309
+ const abs = join(args.workspacePath, args.file.replace(/\\/g, "/"));
16310
+ return {
16311
+ ok: true,
16312
+ breakpoints: await session.setBreakpoints(abs, args.points)
16313
+ };
16314
+ },
16315
+ "edrv.dap.stackTrace": async () => ({
16316
+ ok: true,
16317
+ frames: await session.stackTrace()
16318
+ }),
16319
+ "edrv.dap.scopes": async (args) => ({
16320
+ ok: true,
16321
+ scopes: await session.scopes(args.frameId)
16322
+ }),
16323
+ "edrv.dap.variables": async (args) => ({
16324
+ ok: true,
16325
+ variables: await session.variables(args.ref)
16326
+ }),
16327
+ "edrv.dap.evaluate": async (args) => ({
16328
+ ok: true,
16329
+ ...await session.evaluate(args.expression, args.frameId)
16330
+ }),
16331
+ "edrv.dap.command": async (args) => {
16332
+ if (!DAP_ACTIONS.includes(args.action)) return {
16333
+ ok: false,
16334
+ error: "未知动作:" + args.action
16335
+ };
16336
+ if (args.action === "disconnect") session.stop();
16337
+ else session.action(args.action);
16338
+ return { ok: true };
16339
+ }
16340
+ },
16341
+ dispose: () => session.dispose()
16342
+ };
16343
+ }
16344
+ /**
16345
+ * processName → pid:唯一命中直用;0 命中或多命中返回错误文案(客户端引导用 processes 列表选择)。
16346
+ * @author ddj 2026年09月29号
16347
+ * @param processName 进程名(标题/文件名包含匹配)
16348
+ */
16349
+ async function resolvePid(processName) {
16350
+ const items = filterProcesses(await listProcesses(), processName);
16351
+ if (items.length === 1) return items[0].pid;
16352
+ if (items.length === 0) return "未找到匹配进程:" + (processName || "(空)");
16353
+ return "匹配到 " + items.length + " 个进程,请先在进程列表中选择(edrv.dap.processes)";
16354
+ }
16355
+ /**
16356
+ * 读工作区调试配置:优先 `.dsh/launch.json`(插件专属);缺失时从旧共用
16357
+ * `.vscode/launch.json` 一次性迁移(只读 legacy 源、永不回写;复制失败当次
16358
+ * 仍用 legacy 内容响应不丢配置,下次读重试);都缺失 → 空表。
16359
+ * @author ddj 2026年09月22号
16360
+ * @param ctx DSH 上下文
16361
+ * @param workspacePath 工作区绝对路径
16362
+ * @returns 解析后的配置列表
16363
+ */
16364
+ async function readLaunchConfigs(ctx, workspacePath) {
16365
+ const fs = ctx.get("fs");
16366
+ if (!fs || !workspacePath) return [];
16367
+ try {
16368
+ const target = await fs.resolve(DAP_LAUNCH_REL, { cwd: workspacePath });
16369
+ let text = "";
16370
+ let exists = true;
16371
+ try {
16372
+ text = String(await fs.readText(target) ?? "");
16373
+ } catch {
16374
+ exists = false;
16375
+ }
16376
+ if (exists) return parseLaunchConfigs(text, workspacePath);
16377
+ let legacy = "";
16378
+ let legacyExists = true;
16379
+ try {
16380
+ const source = await fs.resolve(DAP_LEGACY_LAUNCH_REL, { cwd: workspacePath });
16381
+ legacy = String(await fs.readText(source) ?? "");
16382
+ } catch {
16383
+ legacyExists = false;
16384
+ }
16385
+ if (!legacyExists) return [];
16386
+ try {
16387
+ const realPath = fs.processPath(target);
16388
+ await mkdir(join(realPath, ".."), { recursive: true });
16389
+ await writeFile(realPath, legacy, "utf8");
16390
+ log.debug("[dap] 已迁移 .vscode/launch.json → .dsh/launch.json(" + workspacePath + ")");
16391
+ } catch (error) {
16392
+ log.debug("[dap] launch.json 迁移失败(下次读重试,本次以旧源响应):" + String(error));
16393
+ }
16394
+ return parseLaunchConfigs(legacy, workspacePath);
16395
+ } catch (error) {
16396
+ log.debug("[dap] launch.json 读取失败(视为无配置):" + String(error));
16397
+ return [];
16398
+ }
16399
+ }
16400
+ /**
16401
+ * 未知调试类型的错误文案:列出已识别类型(含不可用原因),引导用户安装/排查。
16402
+ * @author ddj 2026年09月21号
16403
+ * @param type launch.json 中的调试类型
16404
+ */
16405
+ function unknownTypeMessage(type) {
16406
+ const known = debuggerDecls().map((d) => d.type + (d.available ? "" : "(" + (d.reason ?? "不可用") + ")")).join(" / ");
16407
+ return "未发现调试类型「" + type + "」的适配器(已识别:" + (known || "无,请安装带调试器的扩展") + ")";
13808
16408
  }
13809
16409
  //#endregion
13810
16410
  //#region src/index.ts
@@ -13866,6 +16466,9 @@ function apply(ctx, config) {
13866
16466
  ctx,
13867
16467
  settings: openSettings
13868
16468
  }).handlers;
16469
+ /** 调试 RPC(DAP 桥单例:spawn 扩展适配器 + 事件缓冲 + findFile 反向匹配)。 */
16470
+ const dapRpc = createDapRpc(ctx);
16471
+ const dapHandlers = dapRpc.handlers;
13869
16472
  /** 文件磁盘新鲜度观察器(客户端轮询 edrv.versions;变化时顺带失效目录树缓存)。 */
13870
16473
  const fileVersions = createFileVersions(ctx);
13871
16474
  ctx.on("tools/result", (exec, result) => {
@@ -13875,7 +16478,6 @@ function apply(ctx, config) {
13875
16478
  const cwd = cwdOf(session);
13876
16479
  if (cwd) {
13877
16480
  registry.delete(cwd);
13878
- dropFileIndex(cwd);
13879
16481
  searcher.dispose(cwd);
13880
16482
  contentSearcher.dispose(cwd);
13881
16483
  disposeIndex(cwd);
@@ -13883,7 +16485,7 @@ function apply(ctx, config) {
13883
16485
  const sid = session?.id;
13884
16486
  if (typeof sid === "string") lspRpc.disposeSession(sid);
13885
16487
  });
13886
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers), (warning) => warnings.push(warning));
16488
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers), (warning) => warnings.push(warning));
13887
16489
  installIsolation(ctx);
13888
16490
  ctx.effect(() => shellMenuLifecycle(ctx));
13889
16491
  sweepTreeCache();
@@ -13893,6 +16495,7 @@ function apply(ctx, config) {
13893
16495
  disposeAllServers();
13894
16496
  });
13895
16497
  ctx.effect(() => () => fileVersions.dispose());
16498
+ ctx.effect(() => () => dapRpc.dispose());
13896
16499
  hookExitReclaim();
13897
16500
  log.info("编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入" + (rulesInstalled ? "已接入" : "未接入") + ",技能组" + (skillsDispatched ? "装配中" : "未装配") + ")");
13898
16501
  }