dsh-vscode-mode 0.4.6 → 0.5.1

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 (55) hide show
  1. package/README.md +38 -2
  2. package/lib/client.js +9316 -4038
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1852 -68
  5. package/lib/index.js.map +1 -1
  6. package/package.json +8 -8
  7. package/src/client/ai/inlineProvider.ts +42 -1
  8. package/src/client/index.ts +50 -45
  9. package/src/client/monaco/lsp/index.ts +33 -3
  10. package/src/client/monaco/lsp/lspClient.ts +3 -1
  11. package/src/client/monaco/lsp/providers.ts +60 -6
  12. package/src/client/navHighlight.ts +96 -0
  13. package/src/client/officialSidebar.ts +67 -3
  14. package/src/client/paths.ts +2 -0
  15. package/src/client/rpc.ts +32 -12
  16. package/src/client/sessionScope.ts +183 -0
  17. package/src/client/sidebar/SidebarView.ts +6 -2
  18. package/src/client/sidebar/menuItems.ts +93 -2
  19. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  20. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  21. package/src/client/sidebar/panels/index.ts +44 -3
  22. package/src/client/sidebar/types.ts +20 -0
  23. package/src/client/snippets/provider.ts +26 -4
  24. package/src/client/styles/editor.css +177 -0
  25. package/src/client/svnActions.ts +153 -0
  26. package/src/client/svnLog.ts +356 -0
  27. package/src/client/svnStatus.ts +363 -0
  28. package/src/client/svnStore.ts +150 -0
  29. package/src/client/tabActions.ts +30 -5
  30. package/src/client/tabMenu.ts +51 -0
  31. package/src/client/ui/AiSettings.ts +8 -0
  32. package/src/client/ui/EditorView.ts +632 -35
  33. package/src/client/ui/LogDialog.ts +333 -0
  34. package/src/client/ui/McpSettings.ts +98 -0
  35. package/src/client/ui/ModalShell.ts +79 -0
  36. package/src/client/ui/SideBySideDiff.tsx +139 -0
  37. package/src/client/ui/SnippetsPicker.ts +70 -58
  38. package/src/client/ui/SvnDiffPanel.ts +50 -0
  39. package/src/client/ui/SvnLogDialog.ts +1141 -0
  40. package/src/client/ui/SvnLogStats.ts +243 -0
  41. package/src/client/ui/commandCatalog.ts +72 -0
  42. package/src/compat.ts +1 -1
  43. package/src/debugLog.ts +81 -12
  44. package/src/dshVersion.ts +8 -1
  45. package/src/fileOpenSettings.ts +40 -3
  46. package/src/index.ts +5 -1
  47. package/src/rpc.ts +41 -4
  48. package/src/shared/ai.ts +25 -0
  49. package/src/shared/rpc.ts +53 -1
  50. package/src/shared/svn.ts +377 -0
  51. package/src/shared/svnActions.ts +296 -0
  52. package/src/svn.ts +870 -0
  53. package/src/svnLog.ts +315 -0
  54. package/src/svnText.ts +175 -0
  55. package/src/svnXml.ts +110 -0
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { chmod, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { chmod, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, truncate, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -149,6 +149,40 @@ const SHELL_MENU_LEAF = "DSHEditor";
149
149
  /** 深链基址默认值(DSH Web GUI 默认端口,dsh-web.cmd 实证)。 */
150
150
  const INTEGRATION_BASE_DEFAULT = "http://127.0.0.1:3080";
151
151
  //#endregion
152
+ //#region src/shared/svn.ts
153
+ /** TortoiseSVN 缺省安装目录(Windows;对齐 SvnHelper 的 TortoiseSVN.path 默认值)。 */
154
+ const TORTOISE_DIR_DEFAULT = "C:\\Program Files\\TortoiseSVN";
155
+ /** TortoiseSVN 主程序名。 */
156
+ const TORTOISE_EXE = "TortoiseProc.exe";
157
+ /** SvnAction → TortoiseProc /command: 参数(全部同名,显式映射防拼写漂移)。 */
158
+ function tortoiseCommandOf(action) {
159
+ return {
160
+ update: "update",
161
+ commit: "commit",
162
+ log: "log",
163
+ diff: "diff",
164
+ blame: "blame",
165
+ revert: "revert"
166
+ }[action];
167
+ }
168
+ /**
169
+ * TortoiseProc 启动 argv(纯函数)。
170
+ * Windows CRT 引号规则:node 对含空格参数整体加引号,子进程 CommandLineToArgvW
171
+ * 还原为单参数,TortoiseSVN 解析器取 `/path:` 后全串为路径——无 shell 注入面。
172
+ * @author ddj 2026年09月16号
173
+ * @param exe TortoiseProc.exe 绝对路径
174
+ * @param action Tortoise 动作
175
+ * @param absPath 目标绝对路径(文件/目录)
176
+ * @returns argv 数组(无 shell 插值)
177
+ */
178
+ function tortoiseLaunchArgv(exe, action, absPath) {
179
+ return [
180
+ exe,
181
+ "/command:" + tortoiseCommandOf(action),
182
+ "/path:" + absPath
183
+ ];
184
+ }
185
+ //#endregion
152
186
  //#region src/shared/logger.ts
153
187
  /**
154
188
  * dsh-vscode-mode — 统一日志核心(双面共享,平台中立)。
@@ -416,6 +450,16 @@ function baseValueOf(config) {
416
450
  const raw = config?.integrationBaseUrl;
417
451
  return typeof raw === "string" && raw.trim() ? raw.trim() : INTEGRATION_BASE_DEFAULT;
418
452
  }
453
+ /** 配置/设置里的 svn CLI 覆盖(空 = 从 PATH 解析 'svn')。 */
454
+ function svnPathValueOf(config) {
455
+ const raw = config?.svnPath;
456
+ return typeof raw === "string" ? raw.trim() : "";
457
+ }
458
+ /** 配置/设置里的 TortoiseSVN 目录(空 = 默认安装目录)。 */
459
+ function tortoiseDirValueOf(config) {
460
+ const raw = config?.tortoisePath;
461
+ return typeof raw === "string" && raw.trim() ? raw.trim() : TORTOISE_DIR_DEFAULT;
462
+ }
419
463
  /**
420
464
  * 安装设置 section(版本自适应;任一策略不可用返回 false 不抛错)。
421
465
  * @author ddj 2026年08月24号 / 2026年09月02号
@@ -440,7 +484,9 @@ async function installOpenSettingsSection(ctx, ns, entry, hooks, loader = loadSe
440
484
  aiInline: deps.z.boolean().default(AI_CONFIG_DEFAULT.enabled),
441
485
  aiProvider: deps.z.string().default(AI_CONFIG_DEFAULT.provider),
442
486
  aiModel: deps.z.string().default(AI_CONFIG_DEFAULT.model),
443
- aiEffort: deps.z.string().default(AI_CONFIG_DEFAULT.effort)
487
+ aiEffort: deps.z.string().default(AI_CONFIG_DEFAULT.effort),
488
+ svnPath: deps.z.string().default(""),
489
+ tortoisePath: deps.z.string().default(TORTOISE_DIR_DEFAULT)
444
490
  }), entry, {
445
491
  setSource: (source) => hooks.setSource(source),
446
492
  onChange: hooks.onChange
@@ -483,13 +529,31 @@ function setupOpenSettings(ctx, config, onChange) {
483
529
  const stored = (provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
484
530
  aiCurrent = stored !== void 0 ? aiValueOf(stored) : aiCurrent;
485
531
  };
532
+ /** SVN 路径当前值(settings 未就绪回退配置值)。 */
533
+ let svnCurrent = {
534
+ svnPath: svnPathValueOf(config),
535
+ tortoisePath: tortoiseDirValueOf(config)
536
+ };
537
+ const svnSync = (stored) => {
538
+ svnCurrent = {
539
+ svnPath: svnPathValueOf(stored),
540
+ tortoisePath: tortoiseDirValueOf(stored)
541
+ };
542
+ };
543
+ /** 读取 settings 存储值(describe 未就绪返回 undefined)。 */
544
+ const storedValue = () => {
545
+ return provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS)?.value;
546
+ };
486
547
  installOpenSettingsSection(ctx, FILE_OPEN_SETTINGS_NS, {
487
548
  fileOpenTool: current,
488
- integrationBaseUrl: baseValueOf(config)
549
+ integrationBaseUrl: baseValueOf(config),
550
+ svnPath: svnCurrent.svnPath,
551
+ tortoisePath: svnCurrent.tortoisePath
489
552
  }, {
490
553
  setSource: (source) => {
491
554
  notify(source().fileOpenTool);
492
555
  aiCurrent = aiValueOf(source());
556
+ svnSync(source());
493
557
  syncRevision();
494
558
  },
495
559
  onChange: settingsChange
@@ -497,6 +561,7 @@ function setupOpenSettings(ctx, config, onChange) {
497
561
  ctx.inject?.(["settings"], (settingsCtx) => {
498
562
  provider = settingsCtx.get("settings");
499
563
  aiSync();
564
+ svnSync(storedValue());
500
565
  syncRevision();
501
566
  });
502
567
  return {
@@ -518,6 +583,7 @@ function setupOpenSettings(ctx, config, onChange) {
518
583
  syncRevision();
519
584
  },
520
585
  ai: () => aiCurrent,
586
+ svn: () => svnCurrent,
521
587
  aiUpdate: async (patch, expectedRevision) => {
522
588
  if (!provider?.update) {
523
589
  if (patch.enabled !== void 0) aiCurrent.enabled = patch.enabled;
@@ -758,10 +824,17 @@ function inDshRange(version, range) {
758
824
  }
759
825
  return true;
760
826
  }
761
- /** 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。 */
827
+ /**
828
+ * 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
829
+ * 逐线自新到旧匹配,先命中先返回(区间无上界,故顺序即优先级)。
830
+ * @author ddj 2026年09月02号 / 2026年09月18号
831
+ * @param input 版本串(如 '0.1.6-alpha.2')
832
+ * @returns 版本线标签
833
+ */
762
834
  function familyLabel(input) {
763
835
  const version = parseDshVersion(input);
764
836
  if (!version) return "未知";
837
+ if (inDshRange(version, { from: "0.1.6-alpha.2" })) return "0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)";
765
838
  if (inDshRange(version, { from: "0.1.6-alpha.1" })) return "0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)";
766
839
  if (inDshRange(version, { from: "0.1.5-alpha.1" })) return "0.1.5-alpha 及更新(官方右侧 Sidebar 编辑区 + sidebar.panellist)";
767
840
  if (inDshRange(version, { from: "0.1.3-alpha.1" })) return "0.1.3-alpha 及更新(对话文件链接=remote.session.openWorkspacePath)";
@@ -1699,7 +1772,7 @@ function detectGuards(ctx) {
1699
1772
  }];
1700
1773
  }
1701
1774
  /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
1702
- const TESTED_DSH_MAX = "0.1.6-alpha.1";
1775
+ const TESTED_DSH_MAX = "0.1.6-alpha.2";
1703
1776
  /** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
1704
1777
  function versionAdapters(dshVersion) {
1705
1778
  const adapters = [];
@@ -1745,7 +1818,7 @@ async function buildReport(ctx, options) {
1745
1818
  if (settingsInstallStrategy() === "none") warnings.push("设置 section 安装不可用:" + settingsInstallNote());
1746
1819
  const parsed = parseDshVersion(dshVersion);
1747
1820
  const testedMax = parseDshVersion(TESTED_DSH_MAX);
1748
- if (parsed && testedMax && compareDshVersions(parsed, testedMax) > 0) warnings.push("DSH " + dshVersion + " 高于已实测版本(0.1.6-alpha.1):设置 API 按能力探测运行,异常时请回报适配矩阵");
1821
+ if (parsed && testedMax && compareDshVersions(parsed, testedMax) > 0) warnings.push("DSH " + dshVersion + " 高于已实测版本(0.1.6-alpha.2):设置 API 按能力探测运行,异常时请回报适配矩阵");
1749
1822
  for (const guard of guards) if (!guard.active && guard.note) warnings.push(guard.note);
1750
1823
  return {
1751
1824
  pluginVersion: options?.version ?? pluginVersionOf(),
@@ -6444,12 +6517,13 @@ async function revealInExplorer(ctx, absPath, isDir) {
6444
6517
  //#endregion
6445
6518
  //#region src/debugLog.ts
6446
6519
  /**
6447
- * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)。
6520
+ * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
6448
6521
  * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
6449
6522
  * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
6450
6523
  * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
6451
- * 迁移自 src/rpc.ts(enqueueDebug/flushDebug),逻辑一字不改,仅终端回显改走统一日志器。
6452
- * 作者 ddj 2026-09-08
6524
+ * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
6525
+ * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
6526
+ * 作者 ddj 2026-09-08 / 2026-09-17
6453
6527
  */
6454
6528
  /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
6455
6529
  const debugBuffers = /* @__PURE__ */ new Map();
@@ -6463,8 +6537,19 @@ const DEBUG_BUF_CAP = 32768;
6463
6537
  const DEBUG_FLUSH_IDLE_MS = 1e3;
6464
6538
  /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
6465
6539
  const debugLegacyCleaned = /* @__PURE__ */ new Set();
6466
- /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀)。 */
6540
+ /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
6467
6541
  const debugLog = log.child("debug");
6542
+ /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
6543
+ const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
6544
+ /**
6545
+ * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
6546
+ * @author ddj 2026年09月17号
6547
+ * @param name 请求回传的文件名(可为空)
6548
+ * @returns 是否合法
6549
+ */
6550
+ function isDebugLogName(name) {
6551
+ return typeof name === "string" && DEBUG_LOG_NAME.test(name);
6552
+ }
6468
6553
  /**
6469
6554
  * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
6470
6555
  * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
@@ -6490,20 +6575,23 @@ function flushDebug(cwd, batch) {
6490
6575
  return task;
6491
6576
  }
6492
6577
  /**
6493
- * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(`[dsh-vscode-mode:debug]` 前缀)。
6494
- * @author ddj 2026年09月08号
6578
+ * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
6579
+ * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
6580
+ * @author ddj 2026年09月08号 / 2026年09月17号
6495
6581
  * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
6496
6582
  * @param cwd 工作区(日志文件按 cwd hash 隔离)
6497
6583
  * @param text 单条日志文本(不含换行)
6584
+ * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
6498
6585
  */
6499
- function debugRecord(_ctx, cwd, text) {
6586
+ function debugRecord(_ctx, cwd, text, level = "debug") {
6587
+ const tagged = "[" + level + "] " + text;
6500
6588
  const st = debugBuffers.get(cwd) ?? {
6501
6589
  lines: [],
6502
6590
  len: 0,
6503
6591
  timer: null
6504
6592
  };
6505
- st.lines.push(text);
6506
- st.len += text.length;
6593
+ st.lines.push(tagged);
6594
+ st.len += tagged.length;
6507
6595
  const flush = () => {
6508
6596
  st.timer = null;
6509
6597
  const batch = st.lines;
@@ -6516,7 +6604,65 @@ function debugRecord(_ctx, cwd, text) {
6516
6604
  flush();
6517
6605
  } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
6518
6606
  debugBuffers.set(cwd, st);
6519
- debugLog.error(text);
6607
+ debugLog[level](text);
6608
+ }
6609
+ /**
6610
+ * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
6611
+ * @author ddj 2026年09月17号
6612
+ * @param home DSH home(测试可注入;缺省按环境解析)
6613
+ * @returns 清单项数组
6614
+ */
6615
+ async function listDebugLogs(home = dshHome()) {
6616
+ const names = await readdir(pluginLogRoot(home)).catch(() => []);
6617
+ const items = [];
6618
+ for (const name of names) {
6619
+ if (!isDebugLogName(name)) continue;
6620
+ const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
6621
+ if (!info?.isFile()) continue;
6622
+ items.push({
6623
+ name,
6624
+ bytes: info.size,
6625
+ mtimeMs: info.mtimeMs
6626
+ });
6627
+ }
6628
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs);
6629
+ return items;
6630
+ }
6631
+ /**
6632
+ * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
6633
+ * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
6634
+ * @author ddj 2026年09月17号
6635
+ * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
6636
+ * @param maxBytes 尾部保留字节数(缺省 256KB)
6637
+ * @param home DSH home(测试可注入)
6638
+ * @returns 读取结果;文件不存在或名字非法返回 null
6639
+ */
6640
+ async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
6641
+ if (!isDebugLogName(name)) return null;
6642
+ const target = join(pluginLogRoot(home), name);
6643
+ const info = await stat(target).catch(() => null);
6644
+ if (!info?.isFile()) return null;
6645
+ const text = await readFile(target, "utf8").catch(() => null);
6646
+ if (text === null) return null;
6647
+ const cut = Math.max(0, text.length - maxBytes);
6648
+ return {
6649
+ name,
6650
+ path: target,
6651
+ content: cut > 0 ? text.slice(cut) : text,
6652
+ size: info.size,
6653
+ truncated: cut > 0
6654
+ };
6655
+ }
6656
+ /**
6657
+ * 清空一份诊断日志(truncate 到 0,保留文件本身)。
6658
+ * @author ddj 2026年09月17号
6659
+ * @param name 日志文件名(isDebugLogName 白名单校验)
6660
+ * @param home DSH home(测试可注入)
6661
+ * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
6662
+ */
6663
+ async function clearDebugLog(name, home = dshHome()) {
6664
+ if (!isDebugLogName(name)) return false;
6665
+ return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
6520
6666
  }
6521
6667
  //#endregion
6522
6668
  //#region src/perf.ts
@@ -7152,10 +7298,11 @@ async function integrationBaseUrlOf(ctx) {
7152
7298
  return (typeof value?.integrationBaseUrl === "string" ? value.integrationBaseUrl.trim() : "") || "http://127.0.0.1:3080";
7153
7299
  }
7154
7300
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
7155
- function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions) {
7301
+ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers) {
7156
7302
  return {
7157
7303
  ...lspHandlers ?? {},
7158
7304
  ...aiHandlers ?? {},
7305
+ ...svnHandlers ?? {},
7159
7306
  "edrv.list": async (args) => {
7160
7307
  const sc = await requireSession(ctx, args.sessionId);
7161
7308
  if ("err" in sc) return {
@@ -7574,9 +7721,68 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7574
7721
  ok: false,
7575
7722
  error: sc.err
7576
7723
  };
7577
- debugRecord(ctx, sc.cwd, String(args.text ?? ""));
7724
+ debugRecord(ctx, sc.cwd, String(args.text ?? ""), args.level);
7578
7725
  return { ok: true };
7579
7726
  },
7727
+ "edrv.dlog.list": async (args) => {
7728
+ const sc = await requireSession(ctx, args.sessionId);
7729
+ if ("err" in sc) return {
7730
+ ok: false,
7731
+ error: sc.err
7732
+ };
7733
+ const files = await listDebugLogs();
7734
+ const current = basename(debugLogFile(sc.cwd));
7735
+ return {
7736
+ ok: true,
7737
+ root: pluginLogRoot(),
7738
+ files,
7739
+ current
7740
+ };
7741
+ },
7742
+ "edrv.dlog.read": async (args) => {
7743
+ const sc = await requireSession(ctx, args.sessionId);
7744
+ if ("err" in sc) return {
7745
+ ok: false,
7746
+ error: sc.err
7747
+ };
7748
+ const name = isDebugLogName(args.file) ? args.file : basename(debugLogFile(sc.cwd));
7749
+ const read = await readDebugLog(name, Math.min(Math.max(args.maxBytes ?? 262144, 1024), 524288));
7750
+ if (!read) return {
7751
+ ok: false,
7752
+ error: "日志不存在:" + name + "(开启诊断日志并触发一次操作后生成)"
7753
+ };
7754
+ return {
7755
+ ok: true,
7756
+ ...read
7757
+ };
7758
+ },
7759
+ "edrv.dlog.clear": async (args) => {
7760
+ const sc = await requireSession(ctx, args.sessionId);
7761
+ if ("err" in sc) return {
7762
+ ok: false,
7763
+ error: sc.err
7764
+ };
7765
+ const name = isDebugLogName(args.file) ? args.file : basename(debugLogFile(sc.cwd));
7766
+ if (!await clearDebugLog(name)) return {
7767
+ ok: false,
7768
+ error: "清空失败:日志不存在或名字非法"
7769
+ };
7770
+ return {
7771
+ ok: true,
7772
+ name
7773
+ };
7774
+ },
7775
+ "edrv.dlog.reveal": async () => {
7776
+ const revealed = await revealInExplorer(ctx, pluginLogRoot(), true);
7777
+ if (!revealed.ok) return {
7778
+ ok: false,
7779
+ error: revealed.error
7780
+ };
7781
+ return {
7782
+ ok: true,
7783
+ opened: true
7784
+ };
7785
+ },
7580
7786
  "edrv.searchFiles": async (args) => {
7581
7787
  const sc = await requireSession(ctx, args.sessionId);
7582
7788
  if ("err" in sc) return {
@@ -8335,8 +8541,8 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8335
8541
  * 统一入口:按方法分发到 handler 表。
8336
8542
  * @author ddj 2026年08月20号 / 2026年08月26号
8337
8543
  */
8338
- async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions) {
8339
- const handler = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions)[method];
8544
+ async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers, aiHandlers, fileVersions, svnHandlers) {
8545
+ const handler = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers)[method];
8340
8546
  if (!handler) return {
8341
8547
  ok: false,
8342
8548
  error: "未知方法: " + String(method)
@@ -11882,58 +12088,1636 @@ function createFileVersions(ctx) {
11882
12088
  };
11883
12089
  }
11884
12090
  //#endregion
11885
- //#region src/index.ts
12091
+ //#region src/svnXml.ts
11886
12092
  /**
11887
- * @dsh-external 生态 → dsh-vscode-mode:DSH 上的类 VSCode 编码体验(Host 半入口)。
11888
- * 职责:装配 capture(tools/result 捕获 edit/write 差异)、RPC 分发(edrv.* / mcp.* / vscode.* / compat)、
11889
- * webServer 路由(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)、工作区旁车持久化、
11890
- * 兼容层装配(依赖守卫 + 重复装配自诊断 + 启动日志兼容性报告)。
11891
- * 结构:shared/(双面契约) + model/store/capture/workspace/revert/registry/rpc/routes/compat(host 模块)
11892
- * + log/debugLog(统一日志:bindHostLog 绑定 ctx.logger,edrv.debug 诊断文件通道)。
11893
- * 作者 ddj 2026-08-20
12093
+ * dsh-vscode-mode host SVN XML 解析基元(零第三方依赖,可单测)。
12094
+ *
12095
+ * 为什么单独成模块:svn 的 `--xml` 输出结构简单且高度同形(`<entry attr="...">` 族 +
12096
+ * 文本节点),status/log/info 三处解析共用同一套「实体反转义 + 取属性 + 单趟标签扫描」,
12097
+ * 各自实现会重复踩同一批坑(实测两次:闭标签被当成新条目、路径含 `&` 需反转义)。
12098
+ *
12099
+ * 设计约束:不引 XML 库——输出有上限保护,且只需识别少数字段,正则单趟扫描足够且可确定测试。
12100
+ * 作者 ddj 2026年09月16号
12101
+ */
12102
+ /** XML 实体表(`&amp;` 等命名实体)。 */
12103
+ const NAMED_ENTITY = {
12104
+ amp: "&",
12105
+ lt: "<",
12106
+ gt: ">",
12107
+ quot: "\"",
12108
+ apos: "'"
12109
+ };
12110
+ /**
12111
+ * XML 实体反转义(命名实体 + 十进制/十六进制数字引用)。
12112
+ * 实测场景:工作副本路径含 `&` 时 svn 输出 `a&amp;b.txt`,不反转义会把实体写进 UI。
12113
+ * 无法识别的引用原样保留(不猜)。
12114
+ * @author ddj 2026年09月16号
12115
+ * @param text 原始文本
12116
+ * @returns 反转义后的文本
12117
+ */
12118
+ function unescapeXml(text) {
12119
+ if (typeof text !== "string" || !text.includes("&")) return text ?? "";
12120
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
12121
+ if (body.startsWith("#x") || body.startsWith("#X")) {
12122
+ const code = Number.parseInt(body.slice(2), 16);
12123
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
12124
+ }
12125
+ if (body.startsWith("#")) {
12126
+ const code = Number.parseInt(body.slice(1), 10);
12127
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
12128
+ }
12129
+ return NAMED_ENTITY[body] ?? whole;
12130
+ });
12131
+ }
12132
+ /**
12133
+ * 取标签属性值(`path="..."`;未命中返回空串)。属性值自动反转义。
12134
+ * @author ddj 2026年09月16号
12135
+ * @param tag 标签原文(含属性部分)
12136
+ * @param name 属性名
12137
+ * @returns 属性值;缺失返回 ''
11894
12138
  */
11895
- const name = "dsh-vscode-mode";
11896
- const inject = [
11897
- "sessions",
11898
- "fs",
11899
- "webServer",
11900
- "loader",
11901
- "tools",
11902
- "workspaceRegistry",
11903
- "agents"
11904
- ];
12139
+ function attrOf(tag, name) {
12140
+ const hit = new RegExp(name + "\\s*=\\s*\"([^\"]*)\"").exec(tag);
12141
+ return hit ? unescapeXml(hit[1]) : "";
12142
+ }
11905
12143
  /**
11906
- * 装配插件:挂事件监听、注册路由、安装兼容层。
11907
- * @author ddj 2026年08月20号
11908
- * @param ctx DSH 上下文(sessions/fs/webServer 由 inject 提供;sandboxPolicy/subprocess 惰性获取)
11909
- * @param config 插件配置(可选 imageDir 覆盖图标目录)
12144
+ * 取标签的文本内容(`<msg>...</msg>` 形式,支持换行)。
12145
+ * attrOf 互补:SVN 的提交信息/作者/日期是文本节点而非属性。
12146
+ * @author ddj 2026年09月16号
12147
+ * @param xml 源文本
12148
+ * @param tag 标签名
12149
+ * @returns 反转义后的内容;缺失返回 ''
11910
12150
  */
11911
- function apply(ctx, config) {
11912
- bindHostLog(ctx);
11913
- const registry = /* @__PURE__ */ new Map();
11914
- const searcher = newSearcher(ctx);
11915
- const contentSearcher = newContentSearcher(ctx);
11916
- /** LSP 服务器管理器(语言智能:跳转/引用/大纲;诊断行带模块内前缀如 [lsp-manager])。 */
11917
- const lspManager = createLspManager((line) => log.debug(line));
11918
- /** 兼容性警告收集(route 护栏等写入,启动日志一并输出)。 */
11919
- const warnings = [];
11920
- /** 设置状态(fileOpenTool + AI 补全配置读写;settings 不可用时内存态降级)。 */
11921
- const openSettings = setupOpenSettings(ctx, config, () => {});
11922
- /** LSP 配置为配置值模式:插件组合配置 + 会话内运行时覆盖(原 settings section 命名空间不合法,见 lsp/config.ts)。 */
11923
- /** 规则注入 section(~/.dsh/rules 与 <工作区>/.dsh/rules;旧版 DSH 无 systemPrompt 时静默降级)。 */
11924
- const rulesInstalled = installRulesSection(ctx);
11925
- if (!rulesInstalled) log.warn("未检测到 systemPrompt 服务,规则仅可管理不注入");
11926
- /** 插件自带技能组(<包根>/skills,惰性获取 skills 服务;返回值仅表示"已调度",实际结果见 skillGroupState)。 */
11927
- const skillsDispatched = installSkillGroup(ctx);
11928
- if (!skillsDispatched) log.warn("技能组未调度,插件技能组不可用");
11929
- /** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
11930
- const lspRpc = createLspRpc({
11931
- ctx,
11932
- pluginConfig: config,
11933
- manager: lspManager
12151
+ function textOf(xml, tag) {
12152
+ const hit = new RegExp("<" + tag + ">([\\s\\S]*?)</" + tag + ">").exec(xml);
12153
+ return hit ? unescapeXml(hit[1]) : "";
12154
+ }
12155
+ /**
12156
+ * 单趟扫描指定标签族(只识别 name 集合内的标签,其余忽略)。
12157
+ *
12158
+ * 调用方须自行处理「闭标签不等于新条目」——`<wc-status>`/`<logentry>` 的闭标签无属性,
12159
+ * 若当成新条目会产出多余的空记录(P2 实测踩过)。
12160
+ *
12161
+ * @author ddj 2026年09月16号
12162
+ * @param xml 源文本
12163
+ * @param names 关注的标签名集合
12164
+ * @returns 命中序列
12165
+ */
12166
+ function scanXmlTags(xml, names) {
12167
+ if (typeof xml !== "string" || !xml || !names.length) return [];
12168
+ const alternation = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
12169
+ const re = new RegExp("<(/?)(" + alternation + ")\\b([^>]*?)(/?)>", "g");
12170
+ const hits = [];
12171
+ let match;
12172
+ while ((match = re.exec(xml)) !== null) hits.push({
12173
+ closing: match[1] === "/",
12174
+ name: match[2],
12175
+ attrs: match[3],
12176
+ selfClosing: match[4] === "/"
11934
12177
  });
11935
- const lspHandlers = lspRpc.handlers;
11936
- const aiHandlers = createAiRpc({
12178
+ return hits;
12179
+ }
12180
+ /**
12181
+ * 解析数字属性(`revision="123"`);非法/缺失返回 undefined。
12182
+ * @author ddj 2026年09月16号
12183
+ * @param tag 标签原文
12184
+ * @param name 属性名
12185
+ * @returns 数字或 undefined
12186
+ */
12187
+ function numAttrOf(tag, name) {
12188
+ const raw = attrOf(tag, name);
12189
+ if (raw === "") return void 0;
12190
+ const value = Number.parseInt(raw, 10);
12191
+ return Number.isFinite(value) ? value : void 0;
12192
+ }
12193
+ //#endregion
12194
+ //#region src/svnText.ts
12195
+ /** 输出载荷上限(1MB,与 runSvn stdio 限额一致)。 */
12196
+ const TEXT_OUTPUT_CAP = 1 << 20;
12197
+ /**
12198
+ * 命令输出是否成功。
12199
+ * @author ddj 2026年09月16号
12200
+ * @param code 退出码
12201
+ * @returns 是否成功
12202
+ */
12203
+ function isOkCode(code) {
12204
+ return code === 0;
12205
+ }
12206
+ /**
12207
+ * 合并 stdout/stderr 为单一诊断文本(去空、截断到上限)。
12208
+ * @author ddj 2026年09月16号
12209
+ * @param stdout 标准输出
12210
+ * @param stderr 标准错误
12211
+ * @param cap 上限
12212
+ * @returns 合并文本
12213
+ */
12214
+ function mergeOutput(stdout, stderr, cap = TEXT_OUTPUT_CAP) {
12215
+ return [stdout, stderr].filter((item) => item && item.trim()).join("\n").trim().slice(0, cap);
12216
+ }
12217
+ /**
12218
+ * 取文本尾部(错误提示只带末尾关键信息)。
12219
+ * @author ddj 2026年09月16号
12220
+ * @param text 原始文本
12221
+ * @param max 保留长度
12222
+ * @returns 末尾片段
12223
+ */
12224
+ function tailOfText(text, max = 400) {
12225
+ const trim = String(text ?? "").trim();
12226
+ if (trim.length <= max) return trim || "无输出";
12227
+ return "…" + trim.slice(-max);
12228
+ }
12229
+ /**
12230
+ * svn update 的逐条结果行状态字母表(`svn help update` 的「A Added / U Updated …」)。
12231
+ * 保留中文字面量以覆盖本地化输出;未识别字母不生成条目(宁缺勿错)。
12232
+ */
12233
+ const UPDATE_ACTION_LABEL = {
12234
+ A: "已添加",
12235
+ U: "已更新",
12236
+ D: "已删除",
12237
+ G: "已合并",
12238
+ C: "冲突",
12239
+ E: "已存在",
12240
+ R: "已替换",
12241
+ " ": "无变化"
12242
+ };
12243
+ /** update 结果行:行首若干空白 + 单字母 + 空白 + 路径(路径可含空格,取整段)。 */
12244
+ const UPDATE_LINE_RE = /^([ADGCEUR]|\s{3})\s{2,}(\S.*)$/;
12245
+ /**
12246
+ * 解析 `svn update` 文本输出为**条目级**结构化结果(附冲突清单与一行摘要)。
12247
+ *
12248
+ * 为什么不能只给摘要:update 是「逐文件状态」语义,用户需要看到哪些文件被更新、
12249
+ * 哪些冲突(实测冲突行 `C conf.txt` + 末尾 `Summary of conflicts:` 段)。
12250
+ * update 不接受 `--xml`(实测:`Subcommand 'update' doesn't accept option '--xml'`),
12251
+ * 故只能文本解析;解析不出条目时保留 revision/冲突摘要等既有字段,保证不退化。
12252
+ *
12253
+ * @author ddj 2026年09月16号
12254
+ * @param stdout 标准输出
12255
+ * @param stderr 标准错误
12256
+ * @param code 退出码
12257
+ * @returns 结构化结果(entries 为条目级明细;conflicts 为冲突路径清单)
12258
+ */
12259
+ function updateResultOf(stdout, stderr, code) {
12260
+ const output = mergeOutput(stdout, stderr);
12261
+ const matches = [...output.matchAll(/(?:revision|版本)\s*\.?\s*(\d+)/gi)];
12262
+ const revision = matches.length ? Number(matches[matches.length - 1][1]) : void 0;
12263
+ const entries = [];
12264
+ const conflicts = [];
12265
+ for (const line of output.split(/\r?\n/)) {
12266
+ const hit = UPDATE_LINE_RE.exec(line);
12267
+ if (hit) {
12268
+ const action = hit[1].trim();
12269
+ const path = hit[2].trim();
12270
+ if (path && entries.length < 4e3) entries.push({
12271
+ action,
12272
+ path,
12273
+ label: UPDATE_ACTION_LABEL[action] ?? action
12274
+ });
12275
+ if (action === "C" && conflicts.length < 64) conflicts.push(path);
12276
+ continue;
12277
+ }
12278
+ const loose = /^(?:\s?[A-Z_ ]?C)\s+(\S.*)$/.exec(line);
12279
+ if (loose && conflicts.length < 64) {
12280
+ const path = loose[1].trim();
12281
+ if (!conflicts.includes(path)) conflicts.push(path);
12282
+ }
12283
+ }
12284
+ const conflictText = conflicts.length ? ",冲突 " + conflicts.length + " 项" : "";
12285
+ return {
12286
+ summary: revision !== void 0 ? "已更新到修订版 r" + revision + conflictText : "更新完成" + conflictText,
12287
+ revision,
12288
+ conflicts,
12289
+ entries,
12290
+ output
12291
+ };
12292
+ }
12293
+ /**
12294
+ * 解析 revert/add/commit 等批量命令输出为「计数 + 摘要」。
12295
+ * @author ddj 2026年09月16号
12296
+ * @param stdout 标准输出
12297
+ * @param stderr 标准错误
12298
+ * @param code 退出码
12299
+ * @param verb 动作动词(中文摘要用)
12300
+ * @returns 动作结果载荷
12301
+ */
12302
+ function batchResultOf(stdout, stderr, code, verb) {
12303
+ const output = mergeOutput(stdout, stderr);
12304
+ const count = (stdout.match(/^(?:Reverted|A|Adding|Skipped|Deleting|D)\b/gm) ?? []).length;
12305
+ return {
12306
+ count,
12307
+ summary: !isOkCode(code) ? verb + "失败" + (output ? ":" + tailOfText(output) : "") : verb + "完成" + (count ? "(" + count + " 项)" : ""),
12308
+ output
12309
+ };
12310
+ }
12311
+ /**
12312
+ * Show All(P1-7)专用条目上限:比默认 `LOG_CAP` 高一个量级,仍留护栏。
12313
+ *
12314
+ * 取舍依据(蓝图 §5 P1-7):真全量(省略 `-l`)在超大仓库会拉取上百 MB XML,
12315
+ * 且 DSH 输出收集器超限**保留尾部**(见 `svn.ts` `LOG_OUTPUT_CAP` 教训)→ 宁可有界。
12316
+ * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 5000` → 5000 条 /
12317
+ * 18.86MB / 30.1s;根路径 `-g` 300 条全历史 > 600s 未返回(真全量 + `-g` 不可行)。
12318
+ */
12319
+ const SVN_LOG_SHOW_ALL_CAP = 5e3;
12320
+ /** 合法日志动作字母。 */
12321
+ const LOG_ACTIONS = /* @__PURE__ */ new Set([
12322
+ "A",
12323
+ "D",
12324
+ "R",
12325
+ "M"
12326
+ ]);
12327
+ /**
12328
+ * merged 条目的最大解析深度(P1-5):实测最深 1 层,更深递归未观察到,
12329
+ * 故按「可能更深」实现递归但有界,防病态输入无限下钻。
12330
+ */
12331
+ const LOG_MERGE_DEPTH = 8;
12332
+ /**
12333
+ * 把仓库绝对路径映射为工作区相对路径。
12334
+ *
12335
+ * `relativeUrl` 形如 `^/trunk`(来自 `svn info --xml`);仓库路径 `/trunk/sub/x.txt`
12336
+ * 剥前缀后得 `sub/x.txt`。注意 relative-url 是 **URL 编码形态**(实测:空格 = `%20`、
12337
+ * 中文 = `%E9..`),而 log --xml 的 path 是明文,比对前必须先解码。
12338
+ * 前缀不完全匹配(跨分支/外部定义)返回 null——宁可在 UI 标注「仓库外」也不要硬拼出
12339
+ * 错误路径导致打开失败。
12340
+ *
12341
+ * @author ddj 2026年09月16号 / 2026年09月17号
12342
+ * @param repoPath 仓库绝对路径(`/trunk/...`)
12343
+ * @param relativeUrl 工作副本根的 relative-url(`^/trunk`,URL 编码形态)
12344
+ * @returns 工作区相对路径;不在根下返回 null
12345
+ */
12346
+ function mapRepoPath(repoPath, relativeUrl) {
12347
+ const repo = String(repoPath ?? "").replace(/\/+$/, "");
12348
+ const encoded = String(relativeUrl ?? "").replace(/^\^/, "").replace(/\/+$/, "");
12349
+ let prefix = encoded;
12350
+ try {
12351
+ prefix = decodeURIComponent(encoded);
12352
+ } catch (e) {}
12353
+ if (!repo) return null;
12354
+ if (!prefix) return null;
12355
+ if (repo === prefix) return "";
12356
+ if (!repo.startsWith(prefix + "/")) return null;
12357
+ return repo.slice(prefix.length + 1);
12358
+ }
12359
+ /**
12360
+ * 解析 `svn log --xml -v` 输出为日志条目列表。
12361
+ *
12362
+ * 实现:顺序扫描(非按块切分)——`parseLogEntry` 会连**嵌套内容**一起消费,
12363
+ * 并返回本条结束位置,故顶层循环天然只收顶层条目,嵌套归各自的父条目。
12364
+ * 每条内取 author/date/msg 与 `<paths>` 段;`<path attrs>文本</path>` 的属性在标签上、
12365
+ * 路径在标签之间。闭标签不得当作新条目(P2 在 status 解析上踩过同一个坑)。
12366
+ *
12367
+ * P1-5(`-g`):被合并的修订是**嵌套 `<logentry>`**(位于父条目 `</msg>` 之后,可并列多个),
12368
+ * 收进父条目的 `merged`;`cap` 只约束顶层条目(实测 `-l` 亦只约束顶层,总条数可超 `-l`)。
12369
+ *
12370
+ * @author ddj 2026年09月16号 / 2026年09月18号
12371
+ * @param xml log --xml 的 stdout
12372
+ * @param relativeUrl 工作副本根的 relative-url(用于路径映射;缺省则不映射)
12373
+ * @param cap 顶层条目上限
12374
+ * @returns 日志条目与是否截断
12375
+ */
12376
+ function parseLogXml(xml, relativeUrl = "", cap = 500) {
12377
+ const entries = [];
12378
+ let truncated = false;
12379
+ if (typeof xml !== "string" || !xml) return {
12380
+ entries,
12381
+ truncated
12382
+ };
12383
+ let from = xml.indexOf("<logentry");
12384
+ while (from >= 0) {
12385
+ if (entries.length >= cap) {
12386
+ truncated = true;
12387
+ break;
12388
+ }
12389
+ const scanned = parseLogEntry(xml, from, relativeUrl, 0);
12390
+ if (!scanned) break;
12391
+ entries.push(scanned.entry);
12392
+ from = xml.indexOf("<logentry", scanned.end);
12393
+ }
12394
+ return {
12395
+ entries,
12396
+ truncated
12397
+ };
12398
+ }
12399
+ /**
12400
+ * 解析 `at` 处的一条 logentry(连嵌套内容一起消费;P1-5 递归 merged)。
12401
+ *
12402
+ * @author ddj 2026年09月18号
12403
+ * @param xml 日志 XML 全文
12404
+ * @param at `<logentry` 的起始下标
12405
+ * @param relativeUrl 工作副本根的 relative-url
12406
+ * @param depth 当前嵌套深度(顶层 0)
12407
+ * @returns 条目与「本条结束下标」;起始处不是 logentry 时返回 null
12408
+ */
12409
+ function parseLogEntry(xml, at, relativeUrl, depth) {
12410
+ const attrs = tagTailOf(xml, at, "logentry");
12411
+ if (attrs === null) return null;
12412
+ const revision = numAttrOf(attrs, "revision");
12413
+ if (revision === void 0) return null;
12414
+ const bodyFrom = at + 9 + attrs.length + 1;
12415
+ let end = entryEndOf(xml, bodyFrom, depth);
12416
+ const body = xml.slice(bodyFrom, end);
12417
+ const entry = {
12418
+ revision,
12419
+ author: textOf(body, "author"),
12420
+ date: textOf(body, "date"),
12421
+ message: textOf(body, "msg"),
12422
+ paths: parseLogPaths(body, relativeUrl)
12423
+ };
12424
+ const reverse = attrOf(attrs, "reverse-merge");
12425
+ if (reverse) entry.reverseMerge = reverse === "true";
12426
+ if (depth < LOG_MERGE_DEPTH) {
12427
+ const nested = parseMergedOf(xml, bodyFrom, end, relativeUrl, depth);
12428
+ if (nested.list.length) entry.merged = nested.list;
12429
+ end = nested.end;
12430
+ }
12431
+ return {
12432
+ entry,
12433
+ end
12434
+ };
12435
+ }
12436
+ /**
12437
+ * 取 `at` 处标签的属性串(`<name` 之后到首个 `>` 之间)。
12438
+ * @author ddj 2026年09月18号
12439
+ * @param xml XML 全文
12440
+ * @param at `<name` 的起始下标
12441
+ * @param name 标签名
12442
+ * @returns 属性串(已 trim);该处不是该标签的开标签时返回 null
12443
+ */
12444
+ function tagTailOf(xml, at, name) {
12445
+ if (!xml.startsWith("<" + name, at)) return null;
12446
+ const gt = xml.indexOf(">", at);
12447
+ if (gt < 0) return null;
12448
+ const tag = xml.slice(at + 1 + name.length, gt);
12449
+ return tag.trim().endsWith("/") ? null : tag;
12450
+ }
12451
+ /**
12452
+ * 求条目结束下标:从正文起点扫描,跳过全部嵌套条目所用的闭标签。
12453
+ *
12454
+ * 实现按「先看下一个 `<logentry` 起点、再看当前深度的 `</logentry>`」取舍,
12455
+ * 因而支持更深层级(深层闭标签由深层那次递归消费,不会提前终止本层)。
12456
+ * @author ddj 2026年09月18号
12457
+ * @param xml XML 全文
12458
+ * @param from 正文起点(开标签 `>` 之后)
12459
+ * @param depth 本条目深度
12460
+ * @returns 本条 `</logentry>` 之后的绝对下标
12461
+ */
12462
+ function entryEndOf(xml, from, depth) {
12463
+ let scan = from;
12464
+ let level = depth;
12465
+ while (scan < xml.length) {
12466
+ const openAt = xml.indexOf("<logentry", scan);
12467
+ const closeAt = xml.indexOf("</logentry>", scan);
12468
+ if (closeAt < 0) return xml.length;
12469
+ if (openAt >= 0 && openAt < closeAt) {
12470
+ const attrs = tagTailOf(xml, openAt, "logentry");
12471
+ if (attrs === null) {
12472
+ scan = openAt + 9;
12473
+ continue;
12474
+ }
12475
+ scan = entryEndOf(xml, openAt + 9 + attrs.length + 1, level + 1);
12476
+ continue;
12477
+ }
12478
+ if (level <= depth) return closeAt + 11;
12479
+ level -= 1;
12480
+ scan = closeAt + 11;
12481
+ }
12482
+ return xml.length;
12483
+ }
12484
+ /**
12485
+ * 解析块内全部并列嵌套 logentry(P1-5);`end` 随之推进到本条目结束处。
12486
+ *
12487
+ * @author ddj 2026年09月18号
12488
+ * @param xml XML 全文
12489
+ * @param from 正文起点(开标签 `>` 之后)
12490
+ * @param end 本条目结束下标(深度扫描的结果)
12491
+ * @param relativeUrl 工作副本根的 relative-url
12492
+ * @param depth 父条目深度
12493
+ * @returns 嵌套条目列表与推进后的结束下标
12494
+ */
12495
+ function parseMergedOf(xml, from, end, relativeUrl, depth) {
12496
+ const list = [];
12497
+ let scan = from;
12498
+ while (scan < end) {
12499
+ const openAt = xml.indexOf("<logentry", scan);
12500
+ if (openAt < 0 || openAt >= end) break;
12501
+ const nested = parseLogEntry(xml, openAt, relativeUrl, depth + 1);
12502
+ if (!nested) {
12503
+ scan = openAt + 9;
12504
+ continue;
12505
+ }
12506
+ list.push(nested.entry);
12507
+ scan = nested.end;
12508
+ end = Math.max(end, nested.end);
12509
+ }
12510
+ return {
12511
+ list,
12512
+ end
12513
+ };
12514
+ }
12515
+ /**
12516
+ * 解析单条日志的 `<paths>` 段为变更路径列表。
12517
+ * @author ddj 2026年09月16号
12518
+ * @param block 单条 logentry 原文
12519
+ * @param relativeUrl 工作副本根的 relative-url
12520
+ * @returns 变更路径列表
12521
+ */
12522
+ function parseLogPaths(block, relativeUrl) {
12523
+ const section = /<paths>([\s\S]*?)<\/paths>/.exec(block);
12524
+ if (!section) return [];
12525
+ const out = [];
12526
+ const re = /<path\b([^>]*?)\/?>([\s\S]*?)<\/path>/g;
12527
+ let m;
12528
+ while ((m = re.exec(section[1])) !== null) {
12529
+ const attrs = m[1];
12530
+ const rawPath = unescapeXml(m[2]).trim();
12531
+ if (!rawPath) continue;
12532
+ const action = attrOf(attrs, "action");
12533
+ out.push({
12534
+ path: rawPath,
12535
+ relPath: mapRepoPath(rawPath, relativeUrl),
12536
+ action: LOG_ACTIONS.has(action) ? action : "M",
12537
+ kind: attrOf(attrs, "kind") || void 0,
12538
+ copyFrom: attrOf(attrs, "copyfrom-path") || void 0,
12539
+ copyFromRev: numAttrOf(attrs, "copyfrom-rev"),
12540
+ propMods: attrOf(attrs, "prop-mods") === "true",
12541
+ textMods: attrOf(attrs, "text-mods") === "true"
12542
+ });
12543
+ }
12544
+ return out;
12545
+ }
12546
+ /**
12547
+ * 从 `svn info --xml` 输出解析工作副本版号(P1-9;目录目标返回 null——版号加粗仅对文件目标)。
12548
+ * @author ddj 2026年09月17号
12549
+ * @param xml svn info --xml 输出
12550
+ * @returns 工作副本版号(无 commit 节点或目录 = null)
12551
+ */
12552
+ function parseSvnInfoRevision(xml) {
12553
+ const text = String(xml ?? "");
12554
+ if (!text.includes("<entry")) return null;
12555
+ if ((/<entry[^>]*\bkind="([^"]*)"/.exec(text)?.[1] ?? "") === "dir") return null;
12556
+ const at = /<commit[^>]*\brevision="(\d+)"/.exec(text);
12557
+ return at ? Number(at[1]) : null;
12558
+ }
12559
+ /** CLI 探测超时。 */
12560
+ const CLI_GRACE_MS = 6e3;
12561
+ /** svn update 超时(大工作副本留足预算)。 */
12562
+ const UPDATE_GRACE_MS = 18e4;
12563
+ /** TortoiseProc 发射超时预算(不 await done,仅约束服务侧资源)。 */
12564
+ const TORTOISE_GRACE_MS = 6e5;
12565
+ /** 前台置顶助手超时预算(轮询 ≤8s + PowerShell 启动余量)。 */
12566
+ const HELPER_GRACE_MS = 2e4;
12567
+ /** 输出载荷上限(1MB,对齐 runSvn stdio 限额;文本解析上限见 svnText.TEXT_OUTPUT_CAP)。 */
12568
+ const OUTPUT_CAP = TEXT_OUTPUT_CAP;
12569
+ /**
12570
+ * 日志拉取的 stdout 上限:默认窗口 LOG_CAP=500 条、Show All 上限 SVN_LOG_SHOW_ALL_CAP=5000 条。
12571
+ *
12572
+ * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 500` → 2.52MB / 2.0s,
12573
+ * `-l 5000` → 18.86MB / 30.1s(约 3.8~5KB/条,含中文路径与提交信息)。故取 64MB 留约 3× 余量,
12574
+ * `-g`(merged)单条更大也吃这份余量。
12575
+ * 实测教训(勿复踩):默认 1MB 上限时收集器保留**尾部**,Assets 全量 100 条(1.19MB)
12576
+ * 恰好把最新的 52 条挤出(用户报「显示的数据不对」)。
12577
+ * @author ddj 2026年09月17号 / 2026年09月18号
12578
+ */
12579
+ const LOG_OUTPUT_CAP = 67108864;
12580
+ /** 变更条目上限(防超大工作副本撑爆载荷)。 */
12581
+ const CHANGES_CAP = 4e3;
12582
+ /** `svn status --xml` 超时(大工作副本留足预算)。 */
12583
+ const STATUS_GRACE_MS = 12e4;
12584
+ /** 其他只读子命令(cat/info)超时。 */
12585
+ const READ_GRACE_MS = 3e4;
12586
+ /** revert/add 超时。 */
12587
+ const MUTATE_GRACE_MS = 12e4;
12588
+ /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
12589
+ const READ_CAP = 8388608;
12590
+ /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
12591
+ const SVN_ROOT_MAX_DEPTH = 10;
12592
+ /** 默认存在性探测(node fs;异常一律按不存在)。 */
12593
+ const defaultExists = async (path) => {
12594
+ try {
12595
+ await stat(path);
12596
+ return true;
12597
+ } catch {
12598
+ return false;
12599
+ }
12600
+ };
12601
+ /**
12602
+ * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
12603
+ * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
12604
+ * @author ddj 2026年09月16号
12605
+ * @param cwd 起始绝对路径
12606
+ * @param exists 存在性探测(测试注入)
12607
+ * @returns 工作副本根绝对路径;未命中返回 null
12608
+ */
12609
+ async function findSvnRoot(cwd, exists = defaultExists) {
12610
+ let dir = cwd;
12611
+ for (let depth = 0; dir && depth < SVN_ROOT_MAX_DEPTH; depth += 1) {
12612
+ if (await exists(join(dir, ".svn"))) return dir;
12613
+ const parent = dirname(dir);
12614
+ if (parent === dir) return null;
12615
+ dir = parent;
12616
+ }
12617
+ return null;
12618
+ }
12619
+ /**
12620
+ * 解析 TortoiseProc.exe(候选序:设置目录 → bin 子目录 → 默认安装目录 → PATH 逐项)。
12621
+ * @author ddj 2026年09月16号
12622
+ * @param dirSetting 设置里的 TortoiseSVN 目录(空 = 默认)
12623
+ * @param exists 存在性探测(测试注入)
12624
+ * @param platform 目标平台(非 win32 恒 null)
12625
+ * @param pathEnv PATH 环境变量(测试注入)
12626
+ * @returns TortoiseProc.exe 绝对路径;不可用返回 null
12627
+ */
12628
+ async function findTortoiseProc(dirSetting, exists = defaultExists, platform = process.platform, pathEnv = process.env?.PATH) {
12629
+ if (platform !== "win32") return null;
12630
+ const dir = dirSetting && dirSetting.trim() ? dirSetting.trim() : "";
12631
+ const candidates = [dir && join(dir, "bin", "TortoiseProc.exe"), dir && join(dir, "TortoiseProc.exe")].filter((item) => Boolean(item));
12632
+ for (const entry of (pathEnv ?? "").split(";")) {
12633
+ const trim = entry.trim();
12634
+ if (trim) candidates.push(join(trim, TORTOISE_EXE));
12635
+ }
12636
+ for (const candidate of candidates) if (await exists(candidate)) return candidate;
12637
+ return null;
12638
+ }
12639
+ /**
12640
+ * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
12641
+ *
12642
+ * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
12643
+ * 避免自研项与官方同名项并列、用户误点到官方弹窗。
12644
+ * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
12645
+ * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
12646
+ */
12647
+ const SVN_FEATURES = [
12648
+ "diff",
12649
+ "revert",
12650
+ "log"
12651
+ ];
12652
+ /** `svn status --xml` 的 `<wc-status item="...">` 合法取值(未知值一律按 modified 兜底)。 */
12653
+ const KNOWN_ITEM_STATUS = /* @__PURE__ */ new Set([
12654
+ "normal",
12655
+ "added",
12656
+ "deleted",
12657
+ "replaced",
12658
+ "conflicted",
12659
+ "missing",
12660
+ "unversioned",
12661
+ "obstructed",
12662
+ "ignored",
12663
+ "modified",
12664
+ "external",
12665
+ "incomplete"
12666
+ ]);
12667
+ /** 受版本控制判据:unversioned/ignored 之外均为版本化条目。 */
12668
+ const UNVERSIONED_STATUS = /* @__PURE__ */ new Set(["unversioned", "ignored"]);
12669
+ /**
12670
+ * 归一化 XML 中的条目路径为工作区相对路径(`/` 分隔、无前导 `./`)。
12671
+ * XML 路径相对「执行 status 时的 cwd」(实测 Windows 下用 `\`),故 host 必须以
12672
+ * 工作副本根为 cwd 执行,这里只做分隔符与形态归一;`''`/`.` 表示根自身。
12673
+ * @author ddj 2026年09月16号
12674
+ * @param raw XML path 属性原文(已反转义)
12675
+ * @returns 归一化路径;根自身返回 ''
12676
+ */
12677
+ function changePathOf(raw) {
12678
+ const stripped = String(raw ?? "").replace(/\\/g, "/").trim().replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
12679
+ return stripped === "." ? "" : stripped;
12680
+ }
12681
+ /**
12682
+ * 解析 `svn status --xml` 输出为变更清单(纯函数,无第三方 XML 依赖)。
12683
+ *
12684
+ * 结构要点(实测 svn 1.14.5):
12685
+ * - `<target path="..."><entry path="..."><wc-status item="..." props="..." revision="..."/></entry>...`
12686
+ * - 登记过 changelist 的条目被抽到 `<changelist name="...">` 块内(与 target 平级),
12687
+ * 故解析必须按块回填 changelist 名,不能只看 `<entry>`。
12688
+ * - 根自身条目(`path="."`)在属性被改时出现,`item="normal"` 不代表变更;路径为空的
12689
+ * 条目一律跳过,避免把根当成变更项展示。
12690
+ *
12691
+ * @author ddj 2026年09月16号
12692
+ * @param xml status --xml 的 stdout
12693
+ * @param cap 条目上限(超出标记 truncated)
12694
+ * @returns 变更清单与是否截断
12695
+ */
12696
+ function parseStatusXml(xml, cap = CHANGES_CAP) {
12697
+ const entries = [];
12698
+ let truncated = false;
12699
+ if (typeof xml !== "string" || !xml) return {
12700
+ entries,
12701
+ truncated
12702
+ };
12703
+ let changelist = "";
12704
+ let pendingPath = null;
12705
+ for (const hit of scanXmlTags(xml, [
12706
+ "changelist",
12707
+ "entry",
12708
+ "wc-status"
12709
+ ])) {
12710
+ if (hit.name === "changelist") {
12711
+ changelist = hit.closing ? "" : attrOf(hit.attrs, "name");
12712
+ continue;
12713
+ }
12714
+ if (hit.name === "entry") {
12715
+ pendingPath = hit.closing ? null : attrOf(hit.attrs, "path");
12716
+ continue;
12717
+ }
12718
+ if (hit.closing) {
12719
+ pendingPath = null;
12720
+ continue;
12721
+ }
12722
+ if (!pendingPath) continue;
12723
+ const item = attrOf(hit.attrs, "item") || "modified";
12724
+ const status = KNOWN_ITEM_STATUS.has(item) ? item : "modified";
12725
+ const path = changePathOf(pendingPath);
12726
+ if (!path) {
12727
+ pendingPath = null;
12728
+ continue;
12729
+ }
12730
+ if (entries.length >= cap) truncated = true;
12731
+ else {
12732
+ const props = attrOf(hit.attrs, "props");
12733
+ const revision = numAttrOf(hit.attrs, "revision");
12734
+ entries.push({
12735
+ path,
12736
+ status,
12737
+ props: props === "modified" || props === "conflicted" ? props : "none",
12738
+ versioned: !UNVERSIONED_STATUS.has(status),
12739
+ ...changelist ? { changelist } : {},
12740
+ ...revision !== void 0 && revision >= 0 ? { revision } : {}
12741
+ });
12742
+ }
12743
+ if (hit.selfClosing) pendingPath = null;
12744
+ }
12745
+ return {
12746
+ entries,
12747
+ truncated
12748
+ };
12749
+ }
12750
+ /** 进程名(去路径去 .exe;前台助手用)。 */
12751
+ function processNameOf(exe) {
12752
+ return (exe.split(/[\\/]/).pop() ?? exe).replace(/\.exe$/i, "");
12753
+ }
12754
+ /**
12755
+ * 前台置顶助手 argv(纯函数)。
12756
+ * 背景:Windows 前台锁——后台进程(DSH host)启动的窗口默认抢不到前台(任务栏闪烁),
12757
+ * 前台进程的子进程才继承置顶权(SvnHelper 依赖 VSCode 在前台故无此问题)。
12758
+ * 方案:独立 PowerShell 助手轮询新进程主窗口,SwitchToThisWindow 强制切换
12759
+ * (任务栏同源 API,无前台权限也可切窗);脚本内不含任何用户数据(按进程名发现),无注入面。
12760
+ * @author ddj 2026年09月16号
12761
+ * @param processName 目标进程名(不带 .exe)
12762
+ * @returns powershell 助手 argv
12763
+ */
12764
+ function foregroundHelperArgv(processName) {
12765
+ return [
12766
+ "powershell",
12767
+ "-NoProfile",
12768
+ "-NonInteractive",
12769
+ "-WindowStyle",
12770
+ "Hidden",
12771
+ "-Command",
12772
+ [
12773
+ "$cut = (Get-Date).AddSeconds(-2)",
12774
+ "$deadline = $cut.AddSeconds(8)",
12775
+ "$h = [IntPtr]::Zero",
12776
+ "while ((Get-Date) -lt $deadline) {",
12777
+ " Start-Sleep -Milliseconds 150",
12778
+ " $p = Get-Process -Name \"" + processName + "\" -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -ge $cut -and $_.MainWindowHandle -ne 0 } | Sort-Object StartTime -Descending | Select-Object -First 1",
12779
+ " if ($p) { $h = $p.MainWindowHandle; break }",
12780
+ "}",
12781
+ "if ($h -ne [IntPtr]::Zero) {",
12782
+ " Add-Type -Namespace W -Name N -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void SwitchToThisWindow(IntPtr h, bool a);'",
12783
+ " [W.N]::SwitchToThisWindow($h, $true)",
12784
+ "}"
12785
+ ].join("\n")
12786
+ ];
12787
+ }
12788
+ /** host 会话/工作区前置(对齐 rpc.ts requireSession 的错误文案)。 */
12789
+ async function requireSvnSession(ctx, sessionId) {
12790
+ const session = sessionOf(ctx, sessionId);
12791
+ if (!session) return { err: "会话不存在" };
12792
+ const cwd = cwdOf(session);
12793
+ if (!cwd) return { err: "会话无工作区" };
12794
+ return { cwd };
12795
+ }
12796
+ /** 工作区相对路径归一('' 合法 = 工作区根;非法含越界/绝对路径返回 null)。 */
12797
+ function safeRel(raw) {
12798
+ if (raw === void 0 || raw === null || raw === "") return "";
12799
+ if (typeof raw !== "string") return null;
12800
+ const rel = raw.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
12801
+ if (rel === "") return "";
12802
+ if (rel === ".." || rel.startsWith("../") || rel.includes("/../")) return null;
12803
+ if (/^[A-Za-z]:/.test(rel) || rel.startsWith("/")) return null;
12804
+ return rel;
12805
+ }
12806
+ /**
12807
+ * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
12808
+ * @author ddj 2026年09月16号
12809
+ * @param deps ctx + 设置读取面 + 可注入时钟
12810
+ * @returns handlers(Partial<RpcHandlerMap> 形状)
12811
+ */
12812
+ function createSvnRpc(deps) {
12813
+ const { ctx, settings } = deps;
12814
+ const now = deps.now ?? (() => Date.now());
12815
+ const findRoot = deps.findRoot ?? ((cwd) => findSvnRoot(cwd));
12816
+ const findTortoise = deps.findTortoise ?? ((dir) => findTortoiseProc(dir));
12817
+ const statusCache = /* @__PURE__ */ new Map();
12818
+ /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
12819
+ const changesCache = /* @__PURE__ */ new Map();
12820
+ /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
12821
+ const urlCache = /* @__PURE__ */ new Map();
12822
+ let cliProbe = null;
12823
+ const subprocess = () => {
12824
+ const sub = ctx.get("subprocess");
12825
+ if (!sub || typeof sub.spawn !== "function") return null;
12826
+ return sub.spawn.bind(sub);
12827
+ };
12828
+ /** svn CLI 探测(inherit stdio 只取退出码;TTL 缓存按 exe 键)。 */
12829
+ const probeCli = async (exe) => {
12830
+ const at = now();
12831
+ if (cliProbe && cliProbe.exe === exe && at - cliProbe.at < 3e5) return cliProbe.ok;
12832
+ let ok = false;
12833
+ const spawn = subprocess();
12834
+ if (spawn) try {
12835
+ const outcome = await spawn({
12836
+ argv: [
12837
+ exe,
12838
+ "--version",
12839
+ "--quiet"
12840
+ ],
12841
+ cwd: tmpdir(),
12842
+ stdio: {
12843
+ stdout: "inherit",
12844
+ stderr: "inherit",
12845
+ stdin: "ignore"
12846
+ },
12847
+ graceMs: CLI_GRACE_MS
12848
+ }).done;
12849
+ ok = (outcome?.exitCode ?? outcome?.code) === 0;
12850
+ } catch {
12851
+ ok = false;
12852
+ }
12853
+ cliProbe = {
12854
+ at,
12855
+ exe,
12856
+ ok
12857
+ };
12858
+ return ok;
12859
+ };
12860
+ /** 合成状态(managed/svnCli/tortoise),带 TTL 缓存。 */
12861
+ const statusOf = async (cwd, force = false) => {
12862
+ const at = now();
12863
+ const hit = statusCache.get(cwd);
12864
+ if (!force && hit && at - hit.at < 6e4) return hit.payload;
12865
+ const configured = settings.svn();
12866
+ const cliExe = configured.svnPath.trim() || "svn";
12867
+ const wcRoot = await findRoot(cwd);
12868
+ const svnCli = await probeCli(cliExe);
12869
+ const tortoiseExe = await findTortoise(configured.tortoisePath);
12870
+ const payload = {
12871
+ managed: wcRoot !== null,
12872
+ wcRoot,
12873
+ svnCli,
12874
+ tortoise: tortoiseExe !== null,
12875
+ svnFeatures: SVN_FEATURES,
12876
+ svnPath: cliExe,
12877
+ tortoiseExe: tortoiseExe ?? ""
12878
+ };
12879
+ statusCache.set(cwd, {
12880
+ at,
12881
+ payload
12882
+ });
12883
+ return payload;
12884
+ };
12885
+ /**
12886
+ * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
12887
+ * @author ddj 2026年08月26号 / 2026年09月17号
12888
+ * @param cwd 运行目录
12889
+ * @param argv 参数(不含可执行文件名;自动加 --non-interactive)
12890
+ * @param graceMs 进程宽限
12891
+ * @param stdoutCap stdout 收集上限(缺省 1MB;大载荷场景如日志拉取需显式放大)
12892
+ * @returns 退出码与输出
12893
+ */
12894
+ const runSvn = async (cwd, argv, graceMs, stdoutCap = OUTPUT_CAP) => {
12895
+ const spawn = subprocess();
12896
+ if (!spawn) throw new Error("缺少 subprocess 服务");
12897
+ const fs = ctx.get("fs");
12898
+ let realCwd = cwd;
12899
+ if (fs?.resolve && fs?.processPath) realCwd = fs.processPath(await fs.resolve(cwd, {}));
12900
+ const exe = settings.svn().svnPath.trim() || "svn";
12901
+ const attempt = (mode) => spawn({
12902
+ argv: [
12903
+ exe,
12904
+ "--non-interactive",
12905
+ ...argv
12906
+ ],
12907
+ cwd: realCwd,
12908
+ stdio: mode === "pipe" ? {
12909
+ stdout: { maxBytes: stdoutCap },
12910
+ stderr: { maxBytes: OUTPUT_CAP },
12911
+ stdin: "ignore"
12912
+ } : {
12913
+ stdout: "inherit",
12914
+ stderr: "inherit",
12915
+ stdin: "ignore"
12916
+ },
12917
+ graceMs
12918
+ });
12919
+ try {
12920
+ const handle = attempt("pipe");
12921
+ const outcome = await handle.done;
12922
+ const stdout = handle.collected?.stdout?.readFrom(0).text ?? "";
12923
+ const stderr = handle.collected?.stderr?.readFrom(0).text ?? "";
12924
+ return {
12925
+ code: outcome?.exitCode ?? outcome?.code ?? null,
12926
+ stdout,
12927
+ stderr
12928
+ };
12929
+ } catch {
12930
+ const outcome = await attempt("inherit").done;
12931
+ return {
12932
+ code: outcome?.exitCode ?? outcome?.code ?? null,
12933
+ stdout: "",
12934
+ stderr: ""
12935
+ };
12936
+ }
12937
+ };
12938
+ /** 发射 TortoiseProc(GUI 长驻:不 await done,no-op catch 防未处理 rejection)。 */
12939
+ const launchTortoise = (exe, action, absPath) => {
12940
+ const spawn = subprocess();
12941
+ if (!spawn) throw new Error("缺少 subprocess 服务");
12942
+ spawn({
12943
+ argv: tortoiseLaunchArgv(exe, action, absPath),
12944
+ cwd: dirname(absPath),
12945
+ stdio: {
12946
+ stdout: "inherit",
12947
+ stderr: "inherit",
12948
+ stdin: "ignore"
12949
+ },
12950
+ graceMs: TORTOISE_GRACE_MS
12951
+ }).done.catch(() => {});
12952
+ if ((deps.platform ?? process.platform) !== "win32") return;
12953
+ try {
12954
+ spawn({
12955
+ argv: foregroundHelperArgv(processNameOf(exe)),
12956
+ cwd: tmpdir(),
12957
+ stdio: {
12958
+ stdout: "inherit",
12959
+ stderr: "inherit",
12960
+ stdin: "ignore"
12961
+ },
12962
+ graceMs: HELPER_GRACE_MS
12963
+ }).done.catch(() => {});
12964
+ } catch {}
12965
+ };
12966
+ /**
12967
+ * 读取并缓存工作副本变更(`svn status --xml --no-ignore`,以工作副本根为 cwd 执行)。
12968
+ *
12969
+ * 为什么固定以 wcRoot 为 cwd:XML 里的 path 是相对「执行时的 cwd」的(实测 Windows 用
12970
+ * `\`),固定根目录后路径即为「工作副本根相对」,与 client 的 scope/树路径口径一致;
12971
+ * 恒带 `--no-ignore` 取全集(默认不返回 ignored),显示开关交给客户端,避免为开关重跑。
12972
+ * @author ddj 2026年09月16号
12973
+ * @param wcRoot 工作副本根绝对路径
12974
+ * @param force 跳过 TTL 强制重查
12975
+ * @returns 变更清单与截断标记
12976
+ */
12977
+ const changesOf = async (wcRoot, force = false) => {
12978
+ const at = now();
12979
+ const hit = changesCache.get(wcRoot);
12980
+ if (!force && hit && at - hit.at < 5e3) return hit.value;
12981
+ const outcome = await runSvn(wcRoot, [
12982
+ "status",
12983
+ "--xml",
12984
+ "--no-ignore"
12985
+ ], STATUS_GRACE_MS);
12986
+ if (outcome.code !== 0) throw new Error(tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n")));
12987
+ const value = parseStatusXml(outcome.stdout);
12988
+ changesCache.set(wcRoot, {
12989
+ at,
12990
+ value
12991
+ });
12992
+ return value;
12993
+ };
12994
+ /**
12995
+ * 批量路径动作公共流程(revert/add 共用):会话 → 路径白名单与上限 → 受管理校验 → 执行。
12996
+ * 路径一律以工作副本根为 cwd 传入(`--` 之后),避免以 cwd 为基准产生歧义。
12997
+ * @author ddj 2026年09月16号
12998
+ * @param verb 动作动词(摘要用)
12999
+ * @param verbArgs 子命令与选项(如 ['revert','-R'])
13000
+ * @param sessionId 会话 id
13001
+ * @param paths 工作区相对路径列表
13002
+ * @returns 动作结果或错误
13003
+ */
13004
+ const mutate = async (verb, verbArgs, sessionId, paths) => {
13005
+ const sc = await requireSvnSession(ctx, sessionId);
13006
+ if ("err" in sc) return {
13007
+ ok: false,
13008
+ error: sc.err
13009
+ };
13010
+ if (!Array.isArray(paths) || !paths.length) return {
13011
+ ok: false,
13012
+ error: "未选择任何路径"
13013
+ };
13014
+ if (paths.length > 64) return {
13015
+ ok: false,
13016
+ error: "一次最多处理 64 个路径"
13017
+ };
13018
+ const rels = [];
13019
+ for (const item of paths) {
13020
+ const rel = safeRel(item);
13021
+ if (rel === null || rel === "") return {
13022
+ ok: false,
13023
+ error: "路径不合法"
13024
+ };
13025
+ rels.push(rel);
13026
+ }
13027
+ const status = await statusOf(sc.cwd);
13028
+ if (!status.managed || !status.wcRoot) return {
13029
+ ok: false,
13030
+ error: "当前工作区不受 SVN 管理"
13031
+ };
13032
+ if (!status.svnCli) return {
13033
+ ok: false,
13034
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13035
+ };
13036
+ try {
13037
+ const outcome = await runSvn(status.wcRoot, [
13038
+ ...verbArgs,
13039
+ "--",
13040
+ ...rels
13041
+ ], MUTATE_GRACE_MS);
13042
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, verb);
13043
+ changesCache.delete(status.wcRoot);
13044
+ if (outcome.code !== 0) return {
13045
+ ok: false,
13046
+ error: verb + "失败:" + (result.output ? tailOfText(result.output) : "未知错误")
13047
+ };
13048
+ return {
13049
+ ok: true,
13050
+ count: result.count,
13051
+ summary: result.summary,
13052
+ output: result.output
13053
+ };
13054
+ } catch (error) {
13055
+ return {
13056
+ ok: false,
13057
+ error: verb + "失败:" + String(error)
13058
+ };
13059
+ }
13060
+ };
13061
+ /**
13062
+ * 读取并缓存工作副本根的 `relative-url`(`^/trunk`),供日志路径映射用。
13063
+ * 与状态探测同 TTL 量级:仓库切换(switch/relocate)是低频操作,缓存 60s 足够且省一次 spawn。
13064
+ * @author ddj 2026年09月16号
13065
+ * @param wcRoot 工作副本根
13066
+ * @returns relative-url(探测失败返回 '')
13067
+ */
13068
+ const relativeUrlOf = async (wcRoot) => {
13069
+ const at = now();
13070
+ const hit = urlCache.get(wcRoot);
13071
+ if (hit && at - hit.at < 6e4) return hit.value;
13072
+ let value = "";
13073
+ try {
13074
+ const outcome = await runSvn(wcRoot, ["info", "--xml"], READ_GRACE_MS);
13075
+ if (outcome.code === 0) value = textOf(outcome.stdout, "relative-url");
13076
+ } catch {
13077
+ value = "";
13078
+ }
13079
+ urlCache.set(wcRoot, {
13080
+ at,
13081
+ value
13082
+ });
13083
+ return value;
13084
+ };
13085
+ /**
13086
+ * 拉取提交日志(`svn log --xml -v`)。
13087
+ * 必须显式 `-r HEAD:1`:实测默认范围是 `BASE:1`,工作副本落后时几乎拿不到日志。
13088
+ * @author ddj 2026年09月16号 / 2026年09月18号
13089
+ * @param wcRoot 工作副本根
13090
+ * @param target 目标相对路径('' = 根)
13091
+ * @param limit 条数上限
13092
+ * @param opts stopOnCopy(P1-4)/ range(P1-6)/ showMerged(P1-5,argv `-g`)
13093
+ * @param cap 条数硬上限(P1-7 Show All 传 SVN_LOG_SHOW_ALL_CAP,默认 LOG_CAP)
13094
+ * @returns 日志条目与截断标记
13095
+ */
13096
+ const logOf = async (wcRoot, target, limit, opts = {}, cap = 500) => {
13097
+ const capped = Math.max(1, Math.min(cap, Math.floor(limit) || 100));
13098
+ const relativeUrl = await relativeUrlOf(wcRoot);
13099
+ const argv = [
13100
+ "log",
13101
+ "-r",
13102
+ opts.range ? String(opts.range.start) + ":" + String(opts.range.end) : "HEAD:1",
13103
+ "-l",
13104
+ String(capped),
13105
+ "--xml",
13106
+ "-v"
13107
+ ];
13108
+ if (opts.stopOnCopy) argv.push("--stop-on-copy");
13109
+ if (opts.showMerged) argv.push("-g");
13110
+ if (target) argv.push("--", target);
13111
+ const outcome = await runSvn(wcRoot, argv, READ_GRACE_MS, LOG_OUTPUT_CAP);
13112
+ if (outcome.code !== 0) throw new Error(tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n")));
13113
+ const parsed = parseLogXml(outcome.stdout, relativeUrl);
13114
+ return {
13115
+ entries: parsed.entries,
13116
+ truncated: parsed.entries.length >= capped,
13117
+ limit: capped
13118
+ };
13119
+ };
13120
+ /**
13121
+ * 读取单文件某版本的左右两侧内容(`cat -r REV` vs `cat -r REV-1`)。
13122
+ *
13123
+ * 左侧在「该路径在该版本不存在」时(实测新增文件报 E195012)返回 null 而非报错:
13124
+ * 这正是「新增」的正常语义,UI 应把左侧渲染为空而不是弹错误。
13125
+ * @author ddj 2026年09月16号
13126
+ * @param wcRoot 工作副本根
13127
+ * @param rel 工作区相对路径
13128
+ * @param revision 目标版本
13129
+ * @returns 双侧内容
13130
+ */
13131
+ const diffRevSides = async (wcRoot, rel, revision) => {
13132
+ const rightOutcome = await runSvn(wcRoot, [
13133
+ "cat",
13134
+ "-r",
13135
+ String(revision),
13136
+ "--",
13137
+ rel
13138
+ ], READ_GRACE_MS);
13139
+ if (rightOutcome.code !== 0) return {
13140
+ left: null,
13141
+ right: null,
13142
+ reason: "not-exist",
13143
+ error: tailOfText(rightOutcome.stderr || rightOutcome.stdout)
13144
+ };
13145
+ const prev = revision - 1;
13146
+ if (prev < 1) return {
13147
+ left: null,
13148
+ right: rightOutcome.stdout,
13149
+ reason: "not-exist"
13150
+ };
13151
+ const leftOutcome = await runSvn(wcRoot, [
13152
+ "cat",
13153
+ "-r",
13154
+ String(prev),
13155
+ "--",
13156
+ rel
13157
+ ], READ_GRACE_MS);
13158
+ if (leftOutcome.code !== 0) return {
13159
+ left: null,
13160
+ right: rightOutcome.stdout,
13161
+ reason: "not-exist"
13162
+ };
13163
+ return {
13164
+ left: leftOutcome.stdout,
13165
+ right: rightOutcome.stdout
13166
+ };
13167
+ };
13168
+ /**
13169
+ * 读取单文件两个指定版本的左右两侧内容(P1-2 比较两个修订;与 diffRevSides 同口径)。
13170
+ *
13171
+ * 任一侧 cat 失败返回 null + reason(新增/删除侧为正常业务分支),UI 渲染为空而非报错。
13172
+ * @author ddj 2026年09月17号
13173
+ * @param wcRoot 工作副本根
13174
+ * @param rel 工作区相对路径
13175
+ * @param revA 左侧版本(选中顺序在前)
13176
+ * @param revB 右侧版本(选中顺序在后)
13177
+ * @returns 双侧内容
13178
+ */
13179
+ const pairSides = async (wcRoot, rel, revA, revB) => {
13180
+ const sides = [];
13181
+ for (const rev of [revA, revB]) {
13182
+ const outcome = await runSvn(wcRoot, [
13183
+ "cat",
13184
+ "-r",
13185
+ String(rev),
13186
+ "--",
13187
+ rel
13188
+ ], READ_GRACE_MS);
13189
+ if (outcome.code !== 0) sides.push({
13190
+ rev,
13191
+ text: null,
13192
+ reason: "not-exist",
13193
+ error: tailOfText(outcome.stderr || outcome.stdout)
13194
+ });
13195
+ else sides.push({
13196
+ rev,
13197
+ text: outcome.stdout
13198
+ });
13199
+ }
13200
+ return {
13201
+ left: sides[0].text,
13202
+ right: sides[1].text,
13203
+ reason: sides[0].reason ?? sides[1].reason,
13204
+ error: sides[0].error ?? sides[1].error
13205
+ };
13206
+ };
13207
+ return { handlers: {
13208
+ "svn.status": async (args) => {
13209
+ const sc = await requireSvnSession(ctx, args.sessionId);
13210
+ if ("err" in sc) return {
13211
+ ok: false,
13212
+ error: sc.err
13213
+ };
13214
+ return {
13215
+ ok: true,
13216
+ ...await statusOf(sc.cwd, args.force === true)
13217
+ };
13218
+ },
13219
+ "svn.changes": async (args) => {
13220
+ const sc = await requireSvnSession(ctx, args.sessionId);
13221
+ if ("err" in sc) return {
13222
+ ok: false,
13223
+ error: sc.err
13224
+ };
13225
+ const status = await statusOf(sc.cwd);
13226
+ if (!status.managed || !status.wcRoot) return {
13227
+ ok: false,
13228
+ error: "当前工作区不受 SVN 管理"
13229
+ };
13230
+ if (!status.svnCli) return {
13231
+ ok: false,
13232
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13233
+ };
13234
+ try {
13235
+ const result = await changesOf(status.wcRoot, args.force === true);
13236
+ return {
13237
+ ok: true,
13238
+ wcRoot: status.wcRoot,
13239
+ ...result
13240
+ };
13241
+ } catch (error) {
13242
+ return {
13243
+ ok: false,
13244
+ error: "读取变更失败:" + String(error)
13245
+ };
13246
+ }
13247
+ },
13248
+ "svn.diffBase": async (args) => {
13249
+ const sc = await requireSvnSession(ctx, args.sessionId);
13250
+ if ("err" in sc) return {
13251
+ ok: false,
13252
+ error: sc.err
13253
+ };
13254
+ const rel = safeRel(args.path);
13255
+ if (rel === null || rel === "") return {
13256
+ ok: false,
13257
+ error: "路径不合法"
13258
+ };
13259
+ const status = await statusOf(sc.cwd);
13260
+ if (!status.managed || !status.wcRoot) return {
13261
+ ok: false,
13262
+ error: "当前工作区不受 SVN 管理"
13263
+ };
13264
+ if (!status.svnCli) return {
13265
+ ok: false,
13266
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13267
+ };
13268
+ const fs = ctx.get("fs");
13269
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
13270
+ ok: false,
13271
+ error: "缺少 fs"
13272
+ };
13273
+ try {
13274
+ const target = await fs.resolve(rel, { cwd: sc.cwd });
13275
+ const info = await fs.stat(target);
13276
+ if (!info || info.type !== "file") return {
13277
+ ok: false,
13278
+ error: "文件不存在"
13279
+ };
13280
+ if ((info.size ?? 0) > READ_CAP) return {
13281
+ ok: false,
13282
+ error: "文件过大(>8MB),不支持基线差异"
13283
+ };
13284
+ const working = await fs.readText(target);
13285
+ const outcome = await runSvn(status.wcRoot, [
13286
+ "cat",
13287
+ "-r",
13288
+ "BASE",
13289
+ "--",
13290
+ rel
13291
+ ], READ_GRACE_MS);
13292
+ if (outcome.code !== 0) {
13293
+ const text = (outcome.stderr || outcome.stdout).trim();
13294
+ return {
13295
+ ok: true,
13296
+ base: null,
13297
+ working,
13298
+ reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
13299
+ error: tailOfText(text)
13300
+ };
13301
+ }
13302
+ return {
13303
+ ok: true,
13304
+ base: outcome.stdout,
13305
+ working
13306
+ };
13307
+ } catch (error) {
13308
+ return {
13309
+ ok: false,
13310
+ error: "读取基线失败:" + String(error)
13311
+ };
13312
+ }
13313
+ },
13314
+ "svn.revert": async (args) => {
13315
+ return mutate("还原", ["revert", "-R"], args.sessionId, args.paths);
13316
+ },
13317
+ "svn.add": async (args) => {
13318
+ return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
13319
+ },
13320
+ "svn.log": async (args) => {
13321
+ const sc = await requireSvnSession(ctx, args.sessionId);
13322
+ if ("err" in sc) return {
13323
+ ok: false,
13324
+ error: sc.err
13325
+ };
13326
+ const rel = safeRel(args.path);
13327
+ if (rel === null) return {
13328
+ ok: false,
13329
+ error: "路径不合法"
13330
+ };
13331
+ const status = await statusOf(sc.cwd);
13332
+ if (!status.managed || !status.wcRoot) return {
13333
+ ok: false,
13334
+ error: "当前工作区不受 SVN 管理"
13335
+ };
13336
+ if (!status.svnCli) return {
13337
+ ok: false,
13338
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13339
+ };
13340
+ try {
13341
+ return {
13342
+ ok: true,
13343
+ ...await logOf(status.wcRoot, rel, args.limit ?? 100, {
13344
+ stopOnCopy: args.stopOnCopy === true,
13345
+ showMerged: args.showMerged === true,
13346
+ range: args.startRev && args.endRev ? {
13347
+ start: Math.floor(args.startRev),
13348
+ end: Math.floor(args.endRev)
13349
+ } : null
13350
+ }, SVN_LOG_SHOW_ALL_CAP),
13351
+ target: rel
13352
+ };
13353
+ } catch (error) {
13354
+ return {
13355
+ ok: false,
13356
+ error: "读取日志失败:" + String(error)
13357
+ };
13358
+ }
13359
+ },
13360
+ "svn.diffRev": async (args) => {
13361
+ const sc = await requireSvnSession(ctx, args.sessionId);
13362
+ if ("err" in sc) return {
13363
+ ok: false,
13364
+ error: sc.err
13365
+ };
13366
+ const rel = safeRel(args.path);
13367
+ if (rel === null || rel === "") return {
13368
+ ok: false,
13369
+ error: "路径不合法"
13370
+ };
13371
+ const revision = Number(args.revision);
13372
+ if (!Number.isFinite(revision) || revision < 1) return {
13373
+ ok: false,
13374
+ error: "版本号不合法"
13375
+ };
13376
+ const status = await statusOf(sc.cwd);
13377
+ if (!status.managed || !status.wcRoot) return {
13378
+ ok: false,
13379
+ error: "当前工作区不受 SVN 管理"
13380
+ };
13381
+ if (!status.svnCli) return {
13382
+ ok: false,
13383
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13384
+ };
13385
+ try {
13386
+ return {
13387
+ ok: true,
13388
+ ...await diffRevSides(status.wcRoot, rel, Math.floor(revision))
13389
+ };
13390
+ } catch (error) {
13391
+ return {
13392
+ ok: false,
13393
+ error: "读取版本差异失败:" + String(error)
13394
+ };
13395
+ }
13396
+ },
13397
+ "svn.diffPair": async (args) => {
13398
+ const sc = await requireSvnSession(ctx, args.sessionId);
13399
+ if ("err" in sc) return {
13400
+ ok: false,
13401
+ error: sc.err
13402
+ };
13403
+ const rel = safeRel(args.path);
13404
+ if (rel === null || rel === "") return {
13405
+ ok: false,
13406
+ error: "路径不合法"
13407
+ };
13408
+ const revA = Number(args.revA);
13409
+ const revB = Number(args.revB);
13410
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
13411
+ ok: false,
13412
+ error: "版本号不合法"
13413
+ };
13414
+ const status = await statusOf(sc.cwd);
13415
+ if (!status.managed || !status.wcRoot) return {
13416
+ ok: false,
13417
+ error: "当前工作区不受 SVN 管理"
13418
+ };
13419
+ if (!status.svnCli) return {
13420
+ ok: false,
13421
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13422
+ };
13423
+ try {
13424
+ return {
13425
+ ok: true,
13426
+ ...await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB))
13427
+ };
13428
+ } catch (error) {
13429
+ return {
13430
+ ok: false,
13431
+ error: "读取版本差异失败:" + String(error)
13432
+ };
13433
+ }
13434
+ },
13435
+ "svn.diffWorking": async (args) => {
13436
+ const sc = await requireSvnSession(ctx, args.sessionId);
13437
+ if ("err" in sc) return {
13438
+ ok: false,
13439
+ error: sc.err
13440
+ };
13441
+ const rel = safeRel(args.path);
13442
+ if (rel === null || rel === "") return {
13443
+ ok: false,
13444
+ error: "路径不合法"
13445
+ };
13446
+ const revision = Number(args.revision);
13447
+ if (!Number.isFinite(revision) || revision < 1) return {
13448
+ ok: false,
13449
+ error: "版本号不合法"
13450
+ };
13451
+ const status = await statusOf(sc.cwd);
13452
+ if (!status.managed || !status.wcRoot) return {
13453
+ ok: false,
13454
+ error: "当前工作区不受 SVN 管理"
13455
+ };
13456
+ if (!status.svnCli) return {
13457
+ ok: false,
13458
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13459
+ };
13460
+ const fs = ctx.get("fs");
13461
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
13462
+ ok: false,
13463
+ error: "缺少 fs"
13464
+ };
13465
+ try {
13466
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
13467
+ const info = await fs.stat(resolved);
13468
+ if (!info || info.type !== "file") return {
13469
+ ok: false,
13470
+ error: "文件不存在"
13471
+ };
13472
+ if ((info.size ?? 0) > READ_CAP) return {
13473
+ ok: false,
13474
+ error: "文件过大(>8MB),不支持与工作副本比较"
13475
+ };
13476
+ const working = await fs.readText(resolved);
13477
+ const leftOutcome = await runSvn(status.wcRoot, [
13478
+ "cat",
13479
+ "-r",
13480
+ String(Math.floor(revision)),
13481
+ "--",
13482
+ rel
13483
+ ], READ_GRACE_MS);
13484
+ if (leftOutcome.code !== 0) return {
13485
+ ok: true,
13486
+ left: null,
13487
+ right: working,
13488
+ reason: "not-exist",
13489
+ error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim())
13490
+ };
13491
+ return {
13492
+ ok: true,
13493
+ left: leftOutcome.stdout,
13494
+ right: working
13495
+ };
13496
+ } catch (error) {
13497
+ return {
13498
+ ok: false,
13499
+ error: "读取工作副本差异失败:" + String(error)
13500
+ };
13501
+ }
13502
+ },
13503
+ "svn.wcRev": async (args) => {
13504
+ const sc = await requireSvnSession(ctx, args.sessionId);
13505
+ if ("err" in sc) return {
13506
+ ok: false,
13507
+ error: sc.err
13508
+ };
13509
+ const rel = safeRel(args.path);
13510
+ if (rel === null || rel === "") return {
13511
+ ok: true,
13512
+ revision: null
13513
+ };
13514
+ const status = await statusOf(sc.cwd);
13515
+ if (!status.managed || !status.wcRoot) return {
13516
+ ok: false,
13517
+ error: "当前工作区不受 SVN 管理"
13518
+ };
13519
+ if (!status.svnCli) return {
13520
+ ok: false,
13521
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13522
+ };
13523
+ const outcome = await runSvn(status.wcRoot, [
13524
+ "info",
13525
+ "--xml",
13526
+ "--",
13527
+ rel
13528
+ ], READ_GRACE_MS);
13529
+ if (outcome.code !== 0) return {
13530
+ ok: true,
13531
+ revision: null
13532
+ };
13533
+ return {
13534
+ ok: true,
13535
+ revision: parseSvnInfoRevision(outcome.stdout)
13536
+ };
13537
+ },
13538
+ "svn.cleanup": async (args) => {
13539
+ const sc = await requireSvnSession(ctx, args.sessionId);
13540
+ if ("err" in sc) return {
13541
+ ok: false,
13542
+ error: sc.err
13543
+ };
13544
+ const status = await statusOf(sc.cwd);
13545
+ if (!status.managed || !status.wcRoot) return {
13546
+ ok: false,
13547
+ error: "当前工作区不受 SVN 管理"
13548
+ };
13549
+ if (!status.svnCli) return {
13550
+ ok: false,
13551
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13552
+ };
13553
+ const argv = ["cleanup"];
13554
+ if (args.removeUnversioned === true) argv.push("--remove-unversioned");
13555
+ if (args.removeIgnored === true) argv.push("--remove-ignored");
13556
+ try {
13557
+ const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
13558
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
13559
+ if (outcome.code !== 0) return {
13560
+ ok: false,
13561
+ error: "清理失败:" + tailOfText(result.output)
13562
+ };
13563
+ changesCache.delete(status.wcRoot);
13564
+ return {
13565
+ ok: true,
13566
+ ...result
13567
+ };
13568
+ } catch (error) {
13569
+ return {
13570
+ ok: false,
13571
+ error: "清理失败:" + String(error)
13572
+ };
13573
+ }
13574
+ },
13575
+ "svn.update": async (args) => {
13576
+ const sc = await requireSvnSession(ctx, args.sessionId);
13577
+ if ("err" in sc) return {
13578
+ ok: false,
13579
+ error: sc.err
13580
+ };
13581
+ const rel = safeRel(args.path);
13582
+ if (rel === null) return {
13583
+ ok: false,
13584
+ error: "路径不合法"
13585
+ };
13586
+ const status = await statusOf(sc.cwd);
13587
+ if (!status.managed) return {
13588
+ ok: false,
13589
+ error: "当前工作区不受 SVN 管理"
13590
+ };
13591
+ if (!status.svnCli) return {
13592
+ ok: false,
13593
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
13594
+ };
13595
+ try {
13596
+ const outcome = await runSvn(sc.cwd, ["update", rel || "."], UPDATE_GRACE_MS);
13597
+ const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
13598
+ if (outcome.code !== 0) return {
13599
+ ok: false,
13600
+ error: "更新失败:" + tailOfText(result.output)
13601
+ };
13602
+ return {
13603
+ ok: true,
13604
+ ...result
13605
+ };
13606
+ } catch (error) {
13607
+ return {
13608
+ ok: false,
13609
+ error: "更新失败:" + String(error)
13610
+ };
13611
+ }
13612
+ },
13613
+ "svn.tortoise": async (args) => {
13614
+ const sc = await requireSvnSession(ctx, args.sessionId);
13615
+ if ("err" in sc) return {
13616
+ ok: false,
13617
+ error: sc.err
13618
+ };
13619
+ const action = args.action;
13620
+ if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
13621
+ ok: false,
13622
+ error: "未知动作: " + String(action)
13623
+ };
13624
+ const rel = safeRel(args.path);
13625
+ if (rel === null) return {
13626
+ ok: false,
13627
+ error: "路径不合法"
13628
+ };
13629
+ const status = await statusOf(sc.cwd);
13630
+ if (!status.managed) return {
13631
+ ok: false,
13632
+ error: "当前工作区不受 SVN 管理"
13633
+ };
13634
+ if (!status.tortoise) return {
13635
+ ok: false,
13636
+ error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
13637
+ };
13638
+ const fs = ctx.get("fs");
13639
+ if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
13640
+ ok: false,
13641
+ error: "缺少 fs"
13642
+ };
13643
+ try {
13644
+ const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
13645
+ if (!await fs.stat(target)) return {
13646
+ ok: false,
13647
+ error: "路径不存在"
13648
+ };
13649
+ const abs = fs.processPath(target);
13650
+ launchTortoise(status.tortoiseExe, action, abs);
13651
+ return {
13652
+ ok: true,
13653
+ launched: abs
13654
+ };
13655
+ } catch (error) {
13656
+ return {
13657
+ ok: false,
13658
+ error: "启动 TortoiseSVN 失败:" + String(error)
13659
+ };
13660
+ }
13661
+ }
13662
+ } };
13663
+ }
13664
+ //#endregion
13665
+ //#region src/index.ts
13666
+ /**
13667
+ * @dsh-external 生态 → dsh-vscode-mode:DSH 上的类 VSCode 编码体验(Host 半入口)。
13668
+ * 职责:装配 capture(tools/result 捕获 edit/write 差异)、RPC 分发(edrv.* / mcp.* / vscode.* / compat)、
13669
+ * webServer 路由(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)、工作区旁车持久化、
13670
+ * 兼容层装配(依赖守卫 + 重复装配自诊断 + 启动日志兼容性报告)。
13671
+ * 结构:shared/(双面契约) + model/store/capture/workspace/revert/registry/rpc/routes/compat(host 模块)
13672
+ * + log/debugLog(统一日志:bindHostLog 绑定 ctx.logger,edrv.debug 诊断文件通道)。
13673
+ * 作者 ddj 2026-08-20
13674
+ */
13675
+ const name = "dsh-vscode-mode";
13676
+ const inject = [
13677
+ "sessions",
13678
+ "fs",
13679
+ "webServer",
13680
+ "loader",
13681
+ "tools",
13682
+ "workspaceRegistry",
13683
+ "agents"
13684
+ ];
13685
+ /**
13686
+ * 装配插件:挂事件监听、注册路由、安装兼容层。
13687
+ * @author ddj 2026年08月20号
13688
+ * @param ctx DSH 上下文(sessions/fs/webServer 由 inject 提供;sandboxPolicy/subprocess 惰性获取)
13689
+ * @param config 插件配置(可选 imageDir 覆盖图标目录)
13690
+ */
13691
+ function apply(ctx, config) {
13692
+ bindHostLog(ctx);
13693
+ const registry = /* @__PURE__ */ new Map();
13694
+ const searcher = newSearcher(ctx);
13695
+ const contentSearcher = newContentSearcher(ctx);
13696
+ /** LSP 服务器管理器(语言智能:跳转/引用/大纲;诊断行带模块内前缀如 [lsp-manager])。 */
13697
+ const lspManager = createLspManager((line) => log.debug(line));
13698
+ /** 兼容性警告收集(route 护栏等写入,启动日志一并输出)。 */
13699
+ const warnings = [];
13700
+ /** 设置状态(fileOpenTool + AI 补全配置读写;settings 不可用时内存态降级)。 */
13701
+ const openSettings = setupOpenSettings(ctx, config, () => {});
13702
+ /** LSP 配置为配置值模式:插件组合配置 + 会话内运行时覆盖(原 settings section 命名空间不合法,见 lsp/config.ts)。 */
13703
+ /** 规则注入 section(~/.dsh/rules 与 <工作区>/.dsh/rules;旧版 DSH 无 systemPrompt 时静默降级)。 */
13704
+ const rulesInstalled = installRulesSection(ctx);
13705
+ if (!rulesInstalled) log.warn("未检测到 systemPrompt 服务,规则仅可管理不注入");
13706
+ /** 插件自带技能组(<包根>/skills,惰性获取 skills 服务;返回值仅表示"已调度",实际结果见 skillGroupState)。 */
13707
+ const skillsDispatched = installSkillGroup(ctx);
13708
+ if (!skillsDispatched) log.warn("技能组未调度,插件技能组不可用");
13709
+ /** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
13710
+ const lspRpc = createLspRpc({
13711
+ ctx,
13712
+ pluginConfig: config,
13713
+ manager: lspManager
13714
+ });
13715
+ const lspHandlers = lspRpc.handlers;
13716
+ const aiHandlers = createAiRpc({
13717
+ ctx,
13718
+ settings: openSettings
13719
+ }).handlers;
13720
+ const svnHandlers = createSvnRpc({
11937
13721
  ctx,
11938
13722
  settings: openSettings
11939
13723
  }).handlers;
@@ -11954,7 +13738,7 @@ function apply(ctx, config) {
11954
13738
  const sid = session?.id;
11955
13739
  if (typeof sid === "string") lspRpc.disposeSession(sid);
11956
13740
  });
11957
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions), (warning) => warnings.push(warning));
13741
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers), (warning) => warnings.push(warning));
11958
13742
  installIsolation(ctx);
11959
13743
  ctx.effect(() => shellMenuLifecycle(ctx));
11960
13744
  sweepTreeCache();