dsh-vscode-mode 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +62 -11
  2. package/lib/client.js +2879 -1030
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2227 -1056
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/activePathStore.ts +28 -0
  8. package/src/client/commandBridge.ts +9 -41
  9. package/src/client/dap/hover.ts +2 -7
  10. package/src/client/index.ts +116 -14
  11. package/src/client/keybindings.ts +19 -73
  12. package/src/client/luaReservedWords.ts +10 -0
  13. package/src/client/monaco/lsp/keywords.ts +121 -0
  14. package/src/client/monaco/lsp/lspClient.ts +68 -6
  15. package/src/client/monaco/lsp/memberScope.ts +90 -0
  16. package/src/client/monaco/lsp/providers.ts +14 -4
  17. package/src/client/settingsDocRouter.ts +52 -0
  18. package/src/client/shortcutsOfficial.ts +451 -0
  19. package/src/client/sidebar/menuItems.ts +1 -0
  20. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  21. package/src/client/sidebar/types.ts +2 -0
  22. package/src/client/styles/editor.css +143 -37
  23. package/src/client/svnActions.ts +4 -4
  24. package/src/client/ui/ContextMenu.ts +4 -2
  25. package/src/client/ui/EditorView.ts +142 -135
  26. package/src/client/ui/McpSettings.ts +4 -5
  27. package/src/client/ui/QuickOpen.ts +10 -11
  28. package/src/client/ui/ShortcutSettings.ts +294 -0
  29. package/src/client/ui/SvnLogDialog.ts +200 -69
  30. package/src/client/ui/SvnUpdateDialog.ts +472 -0
  31. package/src/client/ui/commandCatalog.ts +36 -41
  32. package/src/client/ui/svnUpdateModel.ts +135 -0
  33. package/src/fileOpenSettings.ts +3 -0
  34. package/src/index.ts +2 -0
  35. package/src/lsp/completionCap.ts +80 -0
  36. package/src/lsp/manager.ts +42 -10
  37. package/src/lsp/rpc.ts +8 -5
  38. package/src/lsp/server.ts +18 -4
  39. package/src/rpc.ts +74 -2
  40. package/src/rules.ts +100 -44
  41. package/src/settingsDocPath.ts +30 -0
  42. package/src/shared/keybindings.ts +253 -0
  43. package/src/shared/rpc.ts +12 -2
  44. package/src/shared/rules.ts +9 -1
  45. package/src/shared/svn.ts +38 -0
  46. package/src/svn.ts +102 -7
  47. package/src/svnText.ts +22 -13
  48. package/src/svnUpdateJob.ts +588 -0
  49. package/src/client/ui/KeybindingsPanel.ts +0 -190
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { chmod, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, truncate, writeFile } from "node:fs/promises";
3
- import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { homedir, tmpdir } from "node:os";
@@ -118,6 +118,8 @@ async function refreshMcp(ctx, id) {
118
118
  * `|` 连接多个候选(任一命中即触发),如 `Alt+ArrowLeft|Ctrl+Alt+-`。
119
119
  * 约束:本模块被 host 与 client 两半共用,**禁止 import client 侧模块**(React/浏览器 API)。
120
120
  * 与 client/ui/commandCatalog 的一致性由 tests/commands.test.ts 断言兜底。
121
+ * 官方机制(2026-10):SHORTCUT_PROFILES 描述各命令在官方 ctx.shortcuts 的 profile 默认键位
122
+ * (由 client 注册官方服务时使用);chordToBinding/bindingToChord 负责插件弦与官方绑定互转。
121
123
  * 作者 ddj 2026年08月26号 / 2026年09月10号
122
124
  */
123
125
  /** 命令 id → 默认键位(可为多候选)。命令目录以此为准,新增命令只需加一项。 */
@@ -900,6 +902,9 @@ async function updateSection(provider, ns, patch, expectedRevision) {
900
902
  * 构建设置 section schema(install 路径与插件 Config 声明共用,保证两代形状同源)。
901
903
  * 字段集 = fileOpenTool/keybindings/sidebarMinWidth/maxOpenEditors/integrationBaseUrl/
902
904
  * aiInline/aiProvider/aiModel/aiEffort/svnPath/tortoisePath/nativeOpenExts,全部带默认值
905
+ * (keybindings 自 2026-10 起废弃停写:键位持久化归官方 shortcuts 存储
906
+ * userData/keybindings.json / dsh.keybindings.v1;字段保留仅为旧档迁移读取与降级回滚,
907
+ * 见 client/shortcutsOfficial.ts 的 migrateLegacyKeybindings。)
903
908
  * (Config 启动校验在 undefined/空配置下自动填充,rc/alpha 两代 cordis 均通过)。
904
909
  * options.volatile:DSH 0.1.7 线的 Config 导出专用——SettingsForms.describe 只下发
905
910
  * 含 volatile 字段的 entry(volatileForm 门槛),不标记则 ns 永不出现在 describe、
@@ -5429,7 +5434,7 @@ async function restoreFile(ctx, session, rec) {
5429
5434
  //#region src/rules.ts
5430
5435
  /**
5431
5436
  * dsh-vscode-mode host — 规则管理(Codebuddy/Cursor 式 .mdc 规则)。
5432
- * - 存储:用户规则 ~/.dsh/rules/*.mdc;项目规则 <工作区>/.dsh/rules/*.mdc(随仓库共享)。
5437
+ * - 存储:用户规则 DSH_HOME/rules + ~/.dsh/rules(当前目录优先);项目规则 <工作区>/.dsh/rules。
5433
5438
  * - 格式:frontmatter(description / alwaysApply / globs / enabled)+ markdown 正文;
5434
5439
  * 类型映射 总是=always / 自动=auto(globs) / 手动=manual(仅索引);enabled 为本插件扩展字段,缺省 true。
5435
5440
  * - 生效:host 注册 systemPrompt.section(order 400),每次装配同步读取(mtime 缓存),
@@ -5452,7 +5457,7 @@ const SECTION_NAME = "dsh-vscode-mode:rules";
5452
5457
  /** section 顺序:persona(0) 与 PLAN_POLICY(500) 之间,规则先于计划策略被读到。 */
5453
5458
  const SECTION_ORDER = 400;
5454
5459
  /**
5455
- * 用户规则目录(~/.dsh/rules)。
5460
+ * 当前用户规则目录(DSH_HOME/rules)。
5456
5461
  * @author ddj 2026年09月03号
5457
5462
  * @param home DSH home(缺省自动解析)
5458
5463
  * @returns 绝对路径
@@ -5460,6 +5465,27 @@ const SECTION_ORDER = 400;
5460
5465
  function userRulesDir(home = dshHome()) {
5461
5466
  return join(home, "rules");
5462
5467
  }
5468
+ /** DSH_HOME 改变时仍读取系统用户目录的旧规则;相同路径不重复加载。 */
5469
+ function userRuleDirs(activeHome = dshHome(), systemHome = homedir()) {
5470
+ const current = resolve(userRulesDir(activeHome));
5471
+ const legacy = resolve(userRulesDir(join(systemHome, ".dsh")));
5472
+ const key = (path) => process.platform === "win32" ? path.toLowerCase() : path;
5473
+ return key(current) === key(legacy) ? { current } : {
5474
+ current,
5475
+ legacy
5476
+ };
5477
+ }
5478
+ function userDirOf(origin, dirs) {
5479
+ if (origin === void 0 || origin === "current") return dirs.current;
5480
+ if (origin === "legacy" && dirs.legacy) return dirs.legacy;
5481
+ throw new Error("无效的用户规则来源");
5482
+ }
5483
+ /** 同名时当前目录优先;禁用的当前规则也应遮蔽旧目录规则。 */
5484
+ function mergeUserRules(current, legacy, fileOf) {
5485
+ const key = (rule) => process.platform === "win32" ? fileOf(rule).toLowerCase() : fileOf(rule);
5486
+ const seen = new Set(current.map(key));
5487
+ return [...current, ...legacy.filter((rule) => !seen.has(key(rule)))].sort((a, b) => fileOf(a).localeCompare(fileOf(b)));
5488
+ }
5463
5489
  /**
5464
5490
  * 项目规则目录(<工作区>/.dsh/rules)。
5465
5491
  * @author ddj 2026年09月03号
@@ -5663,18 +5689,28 @@ function renderRulesSection(user, project, workspacePath) {
5663
5689
  return [renderScope("用户规则(用户配置,必须遵守)", user), workspacePath ? renderScope("项目规则(工作区 " + workspacePath + ",必须遵守)", project) : ""].filter(Boolean).join("\n");
5664
5690
  }
5665
5691
  /** 单目录规则元信息列表(仅 *.mdc,最多 RULE_DIR_CAP 个;目录缺失返回空)。 */
5666
- async function listRulesDir(dir, scope) {
5667
- const mdcs = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".mdc")).sort().slice(0, RULE_DIR_CAP);
5692
+ async function listRulesDir(dir, scope, origin) {
5693
+ const mdcs = (await readdir(dir).catch((error) => {
5694
+ if (error.code === "ENOENT") return [];
5695
+ throw error;
5696
+ })).filter((name) => name.endsWith(".mdc")).sort().slice(0, RULE_DIR_CAP);
5668
5697
  const out = [];
5669
5698
  for (const file of mdcs) {
5670
5699
  const absPath = join(dir, file);
5671
- const info = await stat(absPath).catch(() => null);
5700
+ const info = await stat(absPath).catch((error) => {
5701
+ if (error.code === "ENOENT") return null;
5702
+ throw error;
5703
+ });
5672
5704
  if (!info || !info.isFile()) continue;
5673
- const parsed = parseRuleMdc(await readFile(absPath, "utf8").catch(() => ""));
5674
- out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs));
5705
+ const parsed = parseRuleMdc(await readFile(absPath, "utf8"));
5706
+ out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs, origin));
5675
5707
  }
5676
5708
  return out;
5677
5709
  }
5710
+ /** 列表与注入共用当前目录优先的合并约定。 */
5711
+ async function listUserRules(dirs = userRuleDirs()) {
5712
+ return mergeUserRules(await listRulesDir(dirs.current, "user", "current"), dirs.legacy ? await listRulesDir(dirs.legacy, "user", "legacy") : [], (rule) => rule.file);
5713
+ }
5678
5714
  /** 已注册 workspace 列表(项目 Tab 与写入校验共用)。 */
5679
5715
  function workspaceList$1(ctx) {
5680
5716
  return (ctx.get("workspaceRegistry")?.list?.() ?? []).filter((item) => Boolean(item) && typeof item.path === "string" && item.path !== "");
@@ -5691,10 +5727,16 @@ function requireWorkspace$2(ctx, workspacePath) {
5691
5727
  if (!workspace) throw new Error("项目未注册为 DSH workspace,不能管理项目规则");
5692
5728
  return workspace;
5693
5729
  }
5694
- /** 按作用域解析规则目录(project 先过 requireWorkspace)。 */
5695
- function dirOf$1(ctx, scope, workspacePath) {
5696
- if (scope === "project") return projectRulesDir(requireWorkspace$2(ctx, workspacePath).path);
5697
- return userRulesDir();
5730
+ /** 按作用域解析规则目录;客户端路径和未识别的来源均不作为目标使用。 */
5731
+ function dirOf$1(ctx, ref, dirs) {
5732
+ const invalid = validateRuleFile(ref.file);
5733
+ if (invalid) throw new Error(invalid);
5734
+ if (ref.scope === "project") {
5735
+ if (ref.origin !== void 0) throw new Error("项目规则不能指定用户规则来源");
5736
+ return projectRulesDir(requireWorkspace$2(ctx, ref.workspacePath).path);
5737
+ }
5738
+ if (ref.scope !== "user") throw new Error("无效的规则作用域");
5739
+ return userDirOf(ref.origin, dirs);
5698
5740
  }
5699
5741
  /**
5700
5742
  * 规则总列表:用户规则 + 各已注册工作区的项目规则。
@@ -5702,8 +5744,8 @@ function dirOf$1(ctx, scope, workspacePath) {
5702
5744
  * @param ctx DSH 上下文
5703
5745
  * @returns rules.list 载荷
5704
5746
  */
5705
- async function rulesList(ctx) {
5706
- const user = await listRulesDir(userRulesDir(), "user");
5747
+ async function rulesList(ctx, dirs = userRuleDirs()) {
5748
+ const user = await listUserRules(dirs);
5707
5749
  const projects = [];
5708
5750
  for (const workspace of workspaceList$1(ctx)) {
5709
5751
  const dir = projectRulesDir(workspace.path);
@@ -5728,8 +5770,8 @@ async function rulesList(ctx) {
5728
5770
  * @param ref 作用域 + 文件名(+ 项目工作区)
5729
5771
  * @returns 文件全文
5730
5772
  */
5731
- async function rulesRead(ctx, ref) {
5732
- const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
5773
+ async function rulesRead(ctx, ref, dirs = userRuleDirs()) {
5774
+ const dir = dirOf$1(ctx, ref, dirs);
5733
5775
  return readFile(join(dir, ref.file), "utf8");
5734
5776
  }
5735
5777
  /**
@@ -5743,12 +5785,13 @@ async function rulesRead(ctx, ref) {
5743
5785
  * @param mtime 修改时间毫秒
5744
5786
  * @returns 规则元信息
5745
5787
  */
5746
- function toRuleInfo(scope, file, absPath, parsed, size, mtime) {
5788
+ function toRuleInfo(scope, file, absPath, parsed, size, mtime, origin) {
5747
5789
  return {
5748
5790
  scope,
5749
5791
  file,
5750
5792
  absPath,
5751
- relHint: scope === "project" ? ".dsh/rules/" : "rules/",
5793
+ ...scope === "user" ? { origin: origin ?? "current" } : {},
5794
+ relHint: scope === "project" ? ".dsh/rules/" : origin === "legacy" ? "~/.dsh/rules/" : "rules/",
5752
5795
  description: parsed.description,
5753
5796
  type: ruleTypeOf(parsed),
5754
5797
  globs: parsed.globs,
@@ -5765,10 +5808,8 @@ function toRuleInfo(scope, file, absPath, parsed, size, mtime) {
5765
5808
  * @param input 保存入参
5766
5809
  * @returns 保存后的规则元信息
5767
5810
  */
5768
- async function rulesSave(ctx, input) {
5769
- const invalid = validateRuleFile(input.file);
5770
- if (invalid) throw new Error(invalid);
5771
- const dir = dirOf$1(ctx, input.scope, input.workspacePath);
5811
+ async function rulesSave(ctx, input, dirs = userRuleDirs()) {
5812
+ const dir = dirOf$1(ctx, input, dirs);
5772
5813
  await mkdir(dir, { recursive: true });
5773
5814
  const absPath = join(dir, input.file);
5774
5815
  const content = String(input.content ?? "");
@@ -5779,15 +5820,15 @@ async function rulesSave(ctx, input) {
5779
5820
  await fs.writeText(target, content, void 0, void 0, fullPolicy$2(ctx));
5780
5821
  } else await writeFile(absPath, content, "utf8");
5781
5822
  const info = await stat(absPath).catch(() => null);
5782
- return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now());
5823
+ return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now(), input.origin);
5783
5824
  }
5784
5825
  /**
5785
5826
  * 删除一条规则文件(project 作用域同样先过 workspace 注册校验)。
5786
5827
  * @author ddj 2026年09月03号
5787
5828
  * @param ref 作用域 + 文件名(+ 项目工作区)
5788
5829
  */
5789
- async function rulesRemove(ctx, ref) {
5790
- const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
5830
+ async function rulesRemove(ctx, ref, dirs = userRuleDirs()) {
5831
+ const dir = dirOf$1(ctx, ref, dirs);
5791
5832
  await rm(join(dir, ref.file), { force: true });
5792
5833
  }
5793
5834
  /**
@@ -5797,8 +5838,8 @@ async function rulesRemove(ctx, ref) {
5797
5838
  * @param enabled 目标状态
5798
5839
  * @returns 更新后的规则元信息
5799
5840
  */
5800
- async function rulesToggle(ctx, ref, enabled) {
5801
- const dir = dirOf$1(ctx, ref.scope, ref.workspacePath);
5841
+ async function rulesToggle(ctx, ref, enabled, dirs = userRuleDirs()) {
5842
+ const dir = dirOf$1(ctx, ref, dirs);
5802
5843
  const absPath = join(dir, ref.file);
5803
5844
  const text = await readRuleText(ctx, ref, absPath);
5804
5845
  if (text === null) throw new Error("规则文件不存在:" + ref.file);
@@ -5809,7 +5850,10 @@ async function rulesToggle(ctx, ref, enabled) {
5809
5850
  }
5810
5851
  /** 按作用域读规则全文:project 与写盘同通道走 ctx fs,user 走 node fs;缺失返回 null。 */
5811
5852
  async function readRuleText(ctx, ref, absPath) {
5812
- if (ref.scope !== "project") return readFile(absPath, "utf8").catch(() => null);
5853
+ if (ref.scope !== "project") return readFile(absPath, "utf8").catch((error) => {
5854
+ if (error.code === "ENOENT") return null;
5855
+ throw error;
5856
+ });
5813
5857
  const fs = ctx.get("fs");
5814
5858
  if (!fs) throw new Error("缺少 fs 服务");
5815
5859
  const target = await fs.resolve(".dsh/rules/" + ref.file, { cwd: requireWorkspace$2(ctx, ref.workspacePath).path });
@@ -5833,24 +5877,24 @@ async function rulesSaveContent(ctx, ref, absPath, content) {
5833
5877
  /** 重建单条规则元信息(toggle 后回传 UI;stat 失败时用内容长度 + 当前时间兜底)。 */
5834
5878
  async function ruleInfoOf(ref, absPath, content) {
5835
5879
  const info = await stat(absPath).catch(() => null);
5836
- return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now());
5880
+ return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer$1.byteLength(content), info?.mtimeMs ?? Date.now(), ref.origin);
5837
5881
  }
5838
5882
  const injCache = /* @__PURE__ */ new Map();
5839
5883
  /**
5840
- * 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存,异常静默为空。
5884
+ * 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存;缺失视为空。
5841
5885
  * @author ddj 2026年09月03号
5842
5886
  * @param dir 规则目录
5843
5887
  * @param scope 作用域
5844
- * @param relHint 相对提示
5888
+ * @param origin 用户规则来源
5845
5889
  * @returns 已加载规则列表
5846
5890
  */
5847
- function readRulesSync(dir, scope) {
5848
- if (!existsSync(dir)) return [];
5891
+ function readRulesSync(dir, scope, origin) {
5849
5892
  let names;
5850
5893
  try {
5851
5894
  names = readdirSync(dir);
5852
- } catch {
5853
- return [];
5895
+ } catch (error) {
5896
+ if (error.code === "ENOENT") return [];
5897
+ throw error;
5854
5898
  }
5855
5899
  const out = [];
5856
5900
  for (const file of names.filter((name) => name.endsWith(".mdc")).sort().slice(0, RULE_DIR_CAP)) {
@@ -5858,8 +5902,9 @@ function readRulesSync(dir, scope) {
5858
5902
  let info;
5859
5903
  try {
5860
5904
  info = statSync(absPath);
5861
- } catch {
5862
- continue;
5905
+ } catch (error) {
5906
+ if (error.code === "ENOENT") continue;
5907
+ throw error;
5863
5908
  }
5864
5909
  if (!info.isFile()) continue;
5865
5910
  const cached = injCache.get(absPath);
@@ -5868,8 +5913,9 @@ function readRulesSync(dir, scope) {
5868
5913
  else {
5869
5914
  try {
5870
5915
  parsed = parseRuleMdc(readFileSync(absPath, "utf8"));
5871
- } catch {
5872
- continue;
5916
+ } catch (error) {
5917
+ if (error.code === "ENOENT") continue;
5918
+ throw error;
5873
5919
  }
5874
5920
  injCache.set(absPath, {
5875
5921
  mtimeMs: info.mtimeMs,
@@ -5878,12 +5924,16 @@ function readRulesSync(dir, scope) {
5878
5924
  });
5879
5925
  }
5880
5926
  out.push({
5881
- info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs),
5927
+ info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs, origin),
5882
5928
  body: parsed.body
5883
5929
  });
5884
5930
  }
5885
5931
  return out;
5886
5932
  }
5933
+ /** 同步装配与列表同名遮蔽策略一致。 */
5934
+ function readUserRulesSync(dirs = userRuleDirs()) {
5935
+ return mergeUserRules(readRulesSync(dirs.current, "user", "current"), dirs.legacy ? readRulesSync(dirs.legacy, "user", "legacy") : [], (rule) => rule.info.file);
5936
+ }
5887
5937
  /** 从装配上下文解析会话工作区 cwd(agent.id → sessions → header.cwd;缺链返回 null)。 */
5888
5938
  function cwdFromAssemble(ctx, asm) {
5889
5939
  const id = asm?.agent?.id;
@@ -5897,12 +5947,12 @@ function cwdFromAssemble(ctx, asm) {
5897
5947
  * @param ctx DSH host 上下文
5898
5948
  * @returns 是否成功注册
5899
5949
  */
5900
- function installRulesSection(ctx) {
5950
+ function installRulesSection(ctx, dirs = userRuleDirs()) {
5901
5951
  const sp = ctx.get("systemPrompt");
5902
5952
  if (!sp || typeof sp.section !== "function") return false;
5903
5953
  const provider = (asm) => {
5904
5954
  try {
5905
- const user = readRulesSync(userRulesDir(), "user");
5955
+ const user = readUserRulesSync(dirs);
5906
5956
  const cwd = cwdFromAssemble(ctx, asm);
5907
5957
  return renderRulesSection(user, cwd ? readRulesSync(projectRulesDir(cwd), "project") : [], cwd ?? void 0);
5908
5958
  } catch {
@@ -8335,6 +8385,32 @@ function patchInsertPerfConfig(text) {
8335
8385
  return patchRemovePerfConfig(text).replace(/\n+$/, "") + "\n" + perfConfigBlock();
8336
8386
  }
8337
8387
  //#endregion
8388
+ //#region src/settingsDocPath.ts
8389
+ /**
8390
+ * Resolve the settings document only when the request names its exact configured path.
8391
+ *
8392
+ * Both sides are resolved with the path rules of `platform` (win32 → `C:`-style paths and
8393
+ * case-insensitive comparison; otherwise POSIX rules and case-sensitive comparison), so the
8394
+ * function keeps its meaning when the host platform is passed explicitly instead of implied.
8395
+ * @author ddj 2026年10月02号
8396
+ * @param configuredPath Path exposed by the active settings provider
8397
+ * @param requestedPath Path requested by the editor RPC
8398
+ * @param platform Platform whose path rules resolve both sides and drive case comparison
8399
+ * @returns The configured absolute path when authorized; otherwise null
8400
+ */
8401
+ function settingsDocPath(configuredPath, requestedPath, platform = process.platform) {
8402
+ if (typeof configuredPath !== "string" || !configuredPath.trim() || !requestedPath) return null;
8403
+ try {
8404
+ const api = platform === "win32" ? win32 : posix;
8405
+ const configured = api.resolve(configuredPath);
8406
+ const requested = api.resolve(requestedPath);
8407
+ const fold = (value) => platform === "win32" ? value.toLowerCase() : value;
8408
+ return fold(configured) === fold(requested) ? configured : null;
8409
+ } catch {
8410
+ return null;
8411
+ }
8412
+ }
8413
+ //#endregion
8338
8414
  //#region src/rpc.ts
8339
8415
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
8340
8416
  const staleCheckedAt = /* @__PURE__ */ new Map();
@@ -8384,6 +8460,20 @@ function snippetTargetOf(path) {
8384
8460
  return isSnippetFilePath(path) ? path.replace(/\\/g, "/") : null;
8385
8461
  }
8386
8462
  /**
8463
+ * 返回与当前 settings provider 文档路径精确匹配的授权目标。
8464
+ * @author ddj 2026年10月02号
8465
+ * @param ctx DSH host 上下文
8466
+ * @param path 编辑器请求路径
8467
+ * @returns provider 当前配置文档的绝对路径;未匹配返回 null
8468
+ */
8469
+ function settingsTargetOf(ctx, path) {
8470
+ try {
8471
+ return settingsDocPath(ctx.get("settings")?.documentPath, path);
8472
+ } catch {
8473
+ return null;
8474
+ }
8475
+ }
8476
+ /**
8387
8477
  * 二进制/文本读取共用前置(edrv.read 与 edrv.readBinary):解析路径 + debugRecord +
8388
8478
  * stat + 文件类型校验 + 32MB 二进制上限,保证两条读通道的错误口径完全一致。
8389
8479
  * @author ddj 2026年09月22号
@@ -8748,6 +8838,31 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8748
8838
  };
8749
8839
  },
8750
8840
  "edrv.read": async (args) => {
8841
+ const settingsTarget = settingsTargetOf(ctx, args.path);
8842
+ if (settingsTarget && args.encoding !== "base64") try {
8843
+ const info = await stat(settingsTarget);
8844
+ if (!info.isFile()) return {
8845
+ ok: false,
8846
+ error: "设置配置文件不存在"
8847
+ };
8848
+ if (info.size > 8388608) return {
8849
+ ok: false,
8850
+ error: "设置配置文件过大(>8MB)"
8851
+ };
8852
+ const content = await readFile(settingsTarget, "utf8");
8853
+ return {
8854
+ ok: true,
8855
+ content,
8856
+ size: content.length,
8857
+ version: fileVersionOf(info)
8858
+ };
8859
+ } catch (error) {
8860
+ return {
8861
+ ok: false,
8862
+ error: "读取设置配置文件失败:" + String(error),
8863
+ resolvedPath: settingsTarget
8864
+ };
8865
+ }
8751
8866
  const snippetTarget = snippetTargetOf(args.path);
8752
8867
  if (snippetTarget && args.encoding !== "base64") try {
8753
8868
  const content = await readFile(snippetTarget, "utf8");
@@ -8829,11 +8944,47 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8829
8944
  }
8830
8945
  },
8831
8946
  "edrv.versions": async (args) => {
8947
+ const items = [];
8948
+ const regularPaths = [];
8949
+ for (const path of args.paths) {
8950
+ const target = settingsTargetOf(ctx, path);
8951
+ if (!target) {
8952
+ regularPaths.push(path);
8953
+ continue;
8954
+ }
8955
+ try {
8956
+ const info = await stat(target);
8957
+ items.push({
8958
+ path,
8959
+ version: fileVersionOf(info),
8960
+ size: info.size,
8961
+ type: "file"
8962
+ });
8963
+ } catch (error) {
8964
+ items.push({
8965
+ path,
8966
+ version: "",
8967
+ type: "missing",
8968
+ error: String(error)
8969
+ });
8970
+ }
8971
+ }
8972
+ if (regularPaths.length === 0) return {
8973
+ ok: true,
8974
+ items
8975
+ };
8832
8976
  if (!fileVersions) return {
8833
8977
  ok: false,
8834
8978
  error: "文件版本观察器未装配"
8835
8979
  };
8836
- return fileVersions.versions(args);
8980
+ const regular = await fileVersions.versions({
8981
+ ...args,
8982
+ paths: regularPaths
8983
+ });
8984
+ return {
8985
+ ...regular,
8986
+ items: [...items, ...regular.items]
8987
+ };
8837
8988
  },
8838
8989
  "edrv.original": async (args) => {
8839
8990
  const sc = await requireSession(ctx, args.sessionId);
@@ -8890,6 +9041,29 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8890
9041
  }
8891
9042
  },
8892
9043
  "edrv.save": async (args) => {
9044
+ const settingsTarget = settingsTargetOf(ctx, args.path);
9045
+ if (settingsTarget) try {
9046
+ const currentRev = fileVersionOf(await stat(settingsTarget));
9047
+ if (args.rev && args.rev !== currentRev) return {
9048
+ ok: false,
9049
+ conflict: true,
9050
+ error: "设置配置文件已被外部修改,请先重新加载"
9051
+ };
9052
+ if (settingsTargetOf(ctx, args.path) !== settingsTarget) return {
9053
+ ok: false,
9054
+ error: "设置配置文件路径已变化,拒绝保存"
9055
+ };
9056
+ await writeFile(settingsTarget, args.content, "utf8");
9057
+ return {
9058
+ ok: true,
9059
+ rev: fileVersionOf(await stat(settingsTarget).catch(() => null)) || currentRev
9060
+ };
9061
+ } catch (error) {
9062
+ return {
9063
+ ok: false,
9064
+ error: "保存设置配置文件失败:" + String(error)
9065
+ };
9066
+ }
8893
9067
  const snippetTarget = snippetTargetOf(args.path);
8894
9068
  if (snippetTarget) try {
8895
9069
  await writeFile(snippetTarget, args.content, "utf8");
@@ -9580,6 +9754,29 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
9580
9754
  revision: section?.revision
9581
9755
  };
9582
9756
  },
9757
+ "vscode.settingsDocumentPath": async () => {
9758
+ try {
9759
+ const settings = ctx.get("settings");
9760
+ if (typeof settings?.prepareDocument !== "function") return {
9761
+ ok: false,
9762
+ error: "设置配置文件不可用"
9763
+ };
9764
+ const path = await settings.prepareDocument();
9765
+ if (typeof path !== "string" || !path.trim()) return {
9766
+ ok: false,
9767
+ error: "设置配置文件路径为空"
9768
+ };
9769
+ return {
9770
+ ok: true,
9771
+ path
9772
+ };
9773
+ } catch (error) {
9774
+ return {
9775
+ ok: false,
9776
+ error: "准备设置配置文件失败:" + String(error)
9777
+ };
9778
+ }
9779
+ },
9583
9780
  "vscode.fileOpenSettingsUpdate": async (args) => {
9584
9781
  const settings = ctx.get("settings");
9585
9782
  const patch = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) };
@@ -10892,6 +11089,18 @@ function deriveDefinitionFromLocations(queryPos, locations, docText, docAbsPath)
10892
11089
  * 请求封装(definition/references/documentSymbol/workspaceSymbol/hover)、状态上报。
10893
11090
  * 作者 ddj 2026-08-27
10894
11091
  */
11092
+ const MAX_DOC_VERSION = 2147483647;
11093
+ /**
11094
+ * 使用合法、递增的 Int32 文档版本;旧客户端的毫秒时间戳不能直发给 Roslyn。
11095
+ * @author ddj 2026年09月24号
11096
+ * @param incoming 客户端传来的版本
11097
+ * @param previous 当前服务器记录的版本
11098
+ * @returns 可发送给语言服务器的版本
11099
+ */
11100
+ function lspDocVersion(incoming, previous) {
11101
+ const next = Math.min(MAX_DOC_VERSION, (previous ?? 0) + 1);
11102
+ return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next;
11103
+ }
10895
11104
  /**
10896
11105
  * 创建语言服务器会话。
10897
11106
  * @author ddj 2026年08月27号
@@ -11079,13 +11288,14 @@ function createLspServer(spec, root, languageId, logger) {
11079
11288
  sync(path, text, version) {
11080
11289
  const existing = docs.get(path);
11081
11290
  const uri = existing?.uri ?? docUriOf(path);
11291
+ const safeVersion = lspDocVersion(version, existing?.version);
11082
11292
  if (existing) {
11083
- existing.version = version;
11293
+ existing.version = safeVersion;
11084
11294
  existing.text = text;
11085
11295
  if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didChange", {
11086
11296
  textDocument: {
11087
11297
  uri,
11088
- version
11298
+ version: safeVersion
11089
11299
  },
11090
11300
  contentChanges: [{ text }]
11091
11301
  });
@@ -11094,13 +11304,13 @@ function createLspServer(spec, root, languageId, logger) {
11094
11304
  docs.set(path, {
11095
11305
  path,
11096
11306
  uri,
11097
- version,
11307
+ version: safeVersion,
11098
11308
  text
11099
11309
  });
11100
11310
  if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didOpen", { textDocument: {
11101
11311
  uri,
11102
11312
  languageId,
11103
- version,
11313
+ version: safeVersion,
11104
11314
  text
11105
11315
  } });
11106
11316
  },
@@ -11606,6 +11816,13 @@ function normalizeSemanticData(data, types, modifiers) {
11606
11816
  * 实例注册表(key=root|languageId,refcount 共享),崩溃退避重启,session/disposed 清理。
11607
11817
  * 作者 ddj 2026-08-27
11608
11818
  */
11819
+ const BACKOFF_MS = [
11820
+ 1e3,
11821
+ 5e3,
11822
+ 3e4
11823
+ ];
11824
+ const MAX_FAILURES = 3;
11825
+ const STABLE_MS = 6e4;
11609
11826
  /**
11610
11827
  * 创建 LSP 管理器。
11611
11828
  * @author ddj 2026年08月27号
@@ -11616,6 +11833,20 @@ function createLspManager(logger) {
11616
11833
  const entries = /* @__PURE__ */ new Map();
11617
11834
  const keyOf = (root, languageId) => root + "|" + languageId;
11618
11835
  const log = (line) => logger?.("[lsp-manager] " + line);
11836
+ /**
11837
+ * 重试耗尽后保留退出根因,并让 UI 不再误报“未启动”。
11838
+ * @author ddj 2026年09月24号
11839
+ * @param entry 服务器条目
11840
+ * @returns 当前可见状态
11841
+ */
11842
+ const statusOf = (entry) => {
11843
+ const status = entry.server.status();
11844
+ return entry.failures >= MAX_FAILURES ? {
11845
+ ...status,
11846
+ phase: "unavailable",
11847
+ reason: (status.reason ?? "服务器已退出") + "(已停止自动重试)"
11848
+ } : status;
11849
+ };
11619
11850
  /** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
11620
11851
  const dropEntry = (entry) => {
11621
11852
  if (entry.restartTimer) {
@@ -11625,6 +11856,25 @@ function createLspManager(logger) {
11625
11856
  entries.delete(entry.key);
11626
11857
  entry.server.dispose().catch(() => {});
11627
11858
  };
11859
+ const scheduleRestart = (entry) => {
11860
+ if (entry.restartTimer || entry.refs === 0 || entries.get(entry.key) !== entry) return;
11861
+ if (entry.failures >= MAX_FAILURES) {
11862
+ log("unavailable after " + entry.failures + " failures: " + entry.key);
11863
+ manager.onStatusChange?.(statusOf(entry));
11864
+ return;
11865
+ }
11866
+ const delay = BACKOFF_MS[Math.min(Math.max(entry.failures - 1, 0), BACKOFF_MS.length - 1)];
11867
+ entry.restartTimer = setTimeout(() => {
11868
+ entry.restartTimer = null;
11869
+ if (entries.get(entry.key) !== entry || entry.refs === 0) return;
11870
+ (async () => {
11871
+ if (!await entry.server.start() && entries.get(entry.key) === entry && !entry.restartTimer) {
11872
+ entry.failures++;
11873
+ scheduleRestart(entry);
11874
+ }
11875
+ })();
11876
+ }, delay);
11877
+ };
11628
11878
  const manager = {
11629
11879
  acquire(root, languageId, resolveSpec) {
11630
11880
  const key = keyOf(root, languageId);
@@ -11632,17 +11882,33 @@ function createLspManager(logger) {
11632
11882
  if (!entry) {
11633
11883
  const spec = resolveSpec(languageId);
11634
11884
  const server = createLspServer(spec, root, languageId, log);
11635
- server.onStateChange = (status) => manager.onStatusChange?.(status);
11636
11885
  entry = {
11637
11886
  key,
11638
11887
  server,
11639
11888
  refs: 0,
11640
11889
  failures: 0,
11890
+ readyAt: 0,
11641
11891
  restartTimer: null
11642
11892
  };
11643
11893
  entries.set(key, entry);
11894
+ server.onStateChange = (status) => {
11895
+ const live = entries.get(key);
11896
+ if (live?.server === server) {
11897
+ if (status.phase === "ready" && live.readyAt === 0) live.readyAt = Date.now();
11898
+ if (status.phase === "idle" && status.reason?.startsWith("服务器已退出")) {
11899
+ if (live.readyAt && Date.now() - live.readyAt >= STABLE_MS) live.failures = 0;
11900
+ live.failures++;
11901
+ live.readyAt = 0;
11902
+ scheduleRestart(live);
11903
+ }
11904
+ }
11905
+ manager.onStatusChange?.(live?.server === server ? statusOf(live) : status);
11906
+ };
11644
11907
  if (spec.ready) server.start().then((ok) => {
11645
- if (!ok && entries.has(key)) entry.failures++;
11908
+ if (!ok && entries.get(key) === entry && server.phase === "idle" && !entry.restartTimer) {
11909
+ entry.failures++;
11910
+ scheduleRestart(entry);
11911
+ }
11646
11912
  });
11647
11913
  }
11648
11914
  entry.refs++;
@@ -11665,7 +11931,7 @@ function createLspManager(logger) {
11665
11931
  for (const entry of [...entries.values()]) if (entry.server.languageId === languageId) dropEntry(entry);
11666
11932
  },
11667
11933
  statusAll() {
11668
- return [...entries.values()].map((entry) => entry.server.status());
11934
+ return [...entries.values()].map(statusOf);
11669
11935
  },
11670
11936
  peek(root, languageId) {
11671
11937
  return entries.get(keyOf(root, languageId))?.server;
@@ -12920,6 +13186,74 @@ function langOfPath(path) {
12920
13186
  })) if (exts.includes(ext)) return lang;
12921
13187
  return null;
12922
13188
  }
13189
+ /**
13190
+ * 判断 needle 是否为 haystack 的子序列(两侧均已小写)。
13191
+ *
13192
+ * 与 Monaco 候选列表的模糊匹配同口径,保证「分桶判为命中」的条目确实是
13193
+ * 用户输入能筛出来的那些,避免分桶顺序与前端过滤结果互相矛盾。
13194
+ * @author ddj 2026年09月24号
13195
+ * @param haystack 候选文本(已小写)
13196
+ * @param needle 输入前缀(已小写、非空)
13197
+ * @returns 是否为子序列
13198
+ */
13199
+ function isSubsequence(haystack, needle) {
13200
+ let matched = 0;
13201
+ for (let i = 0; i < haystack.length && matched < needle.length; i++) if (haystack[i] === needle[matched]) matched++;
13202
+ return matched === needle.length;
13203
+ }
13204
+ /**
13205
+ * 取补全项的匹配文本(Monaco 口径:filterText 优先,回落 label;统一小写)。
13206
+ * @author ddj 2026年09月24号
13207
+ * @param item 补全项
13208
+ * @returns 小写匹配文本;无可用文本时为空串
13209
+ */
13210
+ function textOfItem(item) {
13211
+ const raw = typeof item?.filterText === "string" && item.filterText ? item.filterText : item?.label;
13212
+ return typeof raw === "string" ? raw.toLowerCase() : "";
13213
+ }
13214
+ /**
13215
+ * 按输入前缀分桶后截断补全列表。
13216
+ *
13217
+ * 分桶优先级:startsWith > includes > 子序列 > 其余;桶内保持服务器原始顺序
13218
+ * (服务器已按相关性排序,不重新打分)。无前缀或未超限时与旧行为完全一致。
13219
+ * @author ddj 2026年09月24号
13220
+ * @param items 服务器归一化后的补全条目
13221
+ * @param prefix 当前输入前缀(Monaco getWordUntilPosition 的词)
13222
+ * @param cap 条目上限(缺省 COMPLETION_ITEM_CAP;测试可注入小值)
13223
+ * @returns 有界条目与截断标记
13224
+ */
13225
+ function capCompletions(items, prefix, cap = 500) {
13226
+ if (!Array.isArray(items)) return {
13227
+ items: [],
13228
+ truncated: false
13229
+ };
13230
+ if (items.length <= cap) return {
13231
+ items,
13232
+ truncated: false
13233
+ };
13234
+ const needle = String(prefix ?? "").toLowerCase();
13235
+ if (!needle) return {
13236
+ items: items.slice(0, cap),
13237
+ truncated: true
13238
+ };
13239
+ const buckets = [
13240
+ [],
13241
+ [],
13242
+ [],
13243
+ []
13244
+ ];
13245
+ for (const item of items) {
13246
+ const text = textOfItem(item);
13247
+ if (text.startsWith(needle)) buckets[0].push(item);
13248
+ else if (text.includes(needle)) buckets[1].push(item);
13249
+ else if (isSubsequence(text, needle)) buckets[2].push(item);
13250
+ else buckets[3].push(item);
13251
+ }
13252
+ return {
13253
+ items: buckets[0].concat(buckets[1], buckets[2], buckets[3]).slice(0, cap),
13254
+ truncated: true
13255
+ };
13256
+ }
12923
13257
  //#endregion
12924
13258
  //#region src/lsp/envRequirements.ts
12925
13259
  /**
@@ -13484,13 +13818,16 @@ function createLspRpc(deps) {
13484
13818
  ok: true,
13485
13819
  completions: void 0
13486
13820
  };
13821
+ const capped = capCompletions(completions.items, args.prefix);
13822
+ const payload = {
13823
+ ...completions,
13824
+ items: capped.items
13825
+ };
13826
+ if (capped.truncated) payload.truncated = true;
13827
+ else delete payload.truncated;
13487
13828
  return {
13488
13829
  ok: true,
13489
- completions: completions.items.length > 500 ? {
13490
- ...completions,
13491
- items: completions.items.slice(0, 500),
13492
- truncated: true
13493
- } : completions
13830
+ completions: payload
13494
13831
  };
13495
13832
  } catch (error) {
13496
13833
  return {
@@ -14382,7 +14719,27 @@ const UPDATE_ACTION_LABEL = {
14382
14719
  " ": "无变化"
14383
14720
  };
14384
14721
  /** update 结果行:行首若干空白 + 单字母 + 空白 + 路径(路径可含空格,取整段)。 */
14385
- const UPDATE_LINE_RE = /^([ADGCEUR]|\s{3})\s{2,}(\S.*)$/;
14722
+ const UPDATE_LINE_RE = /^([A-Z_ ]{1,4})\s{2,}(\S.*)$/;
14723
+ /**
14724
+ * 解析 update 的单行状态(正文/摘要不匹配)。属性列 U/C 也按该条目状态处理。
14725
+ * @author ddj 2026年09月24号
14726
+ * @param line 原始输出行
14727
+ * @returns 可识别条目或 null
14728
+ */
14729
+ function updateLineOf(line) {
14730
+ const hit = UPDATE_LINE_RE.exec(line);
14731
+ if (!hit) return null;
14732
+ const status = hit[1].replace(/[ _]/g, "");
14733
+ if (!status || /[^ADGCEUR]/.test(status)) return null;
14734
+ const action = status.includes("C") ? "C" : status[0];
14735
+ const path = hit[2].trim();
14736
+ if (!path) return null;
14737
+ return {
14738
+ action,
14739
+ path,
14740
+ label: UPDATE_ACTION_LABEL[action] ?? action
14741
+ };
14742
+ }
14386
14743
  /**
14387
14744
  * 解析 `svn update` 文本输出为**条目级**结构化结果(附冲突清单与一行摘要)。
14388
14745
  *
@@ -14404,16 +14761,10 @@ function updateResultOf(stdout, stderr, code) {
14404
14761
  const entries = [];
14405
14762
  const conflicts = [];
14406
14763
  for (const line of output.split(/\r?\n/)) {
14407
- const hit = UPDATE_LINE_RE.exec(line);
14408
- if (hit) {
14409
- const action = hit[1].trim();
14410
- const path = hit[2].trim();
14411
- if (path && entries.length < 4e3) entries.push({
14412
- action,
14413
- path,
14414
- label: UPDATE_ACTION_LABEL[action] ?? action
14415
- });
14416
- if (action === "C" && conflicts.length < 64) conflicts.push(path);
14764
+ const entry = updateLineOf(line);
14765
+ if (entry) {
14766
+ if (entries.length < 4e3) entries.push(entry);
14767
+ if (entry.action === "C" && conflicts.length < 64) conflicts.push(entry.path);
14417
14768
  continue;
14418
14769
  }
14419
14770
  const loose = /^(?:\s?[A-Z_ ]?C)\s+(\S.*)$/.exec(line);
@@ -14697,126 +15048,771 @@ function parseSvnInfoRevision(xml) {
14697
15048
  const at = /<commit[^>]*\brevision="(\d+)"/.exec(text);
14698
15049
  return at ? Number(at[1]) : null;
14699
15050
  }
14700
- /** CLI 探测超时。 */
14701
- const CLI_GRACE_MS = 6e3;
14702
- /** svn update 超时(大工作副本留足预算)。 */
14703
- const UPDATE_GRACE_MS = 18e4;
14704
- /** TortoiseProc 发射超时预算(不 await done,仅约束服务侧资源)。 */
14705
- const TORTOISE_GRACE_MS = 6e5;
14706
- /** 前台置顶助手超时预算(轮询 ≤8s + PowerShell 启动余量)。 */
14707
- const HELPER_GRACE_MS = 2e4;
14708
- /** 输出载荷上限(1MB,对齐 runSvn stdio 限额;文本解析上限见 svnText.TEXT_OUTPUT_CAP)。 */
14709
- const OUTPUT_CAP = TEXT_OUTPUT_CAP;
14710
- /**
14711
- * 日志拉取的 stdout 上限:默认窗口 LOG_CAP=500 条、Show All 上限 SVN_LOG_SHOW_ALL_CAP=5000 条。
14712
- *
14713
- * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 500` → 2.52MB / 2.0s,
14714
- * `-l 5000` → 18.86MB / 30.1s(约 3.8~5KB/条,含中文路径与提交信息)。故取 64MB 留约 3× 余量,
14715
- * `-g`(merged)单条更大也吃这份余量。
14716
- * 实测教训(勿复踩):默认 1MB 上限时收集器保留**尾部**,Assets 全量 100 条(1.19MB)
14717
- * 恰好把最新的 52 条挤出(用户报「显示的数据不对」)。
14718
- * @author ddj 2026年09月17号 / 2026年09月18号
14719
- */
14720
- const LOG_OUTPUT_CAP = 67108864;
14721
- /** 变更条目上限(防超大工作副本撑爆载荷)。 */
14722
- const CHANGES_CAP = 4e3;
14723
- /** `svn status --xml` 超时(大工作副本留足预算)。 */
14724
- const STATUS_GRACE_MS = 12e4;
14725
- /** 其他只读子命令(cat/info)超时。 */
14726
- const READ_GRACE_MS = 3e4;
14727
- /** revert/add 超时。 */
14728
- const MUTATE_GRACE_MS = 12e4;
14729
- /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
14730
- const READ_CAP = 8388608;
14731
- /**
14732
- * W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
14733
- * 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
14734
- * 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
14735
- */
14736
- const PATCH_OUTPUT_CAP = 16777216;
14737
- /** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
14738
- const SUM_OUTPUT_CAP = 8388608;
14739
- /**
14740
- * W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
14741
- * (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
14742
- */
14743
- const REMOTE_GRACE_MS = 15e3;
14744
- /** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
14745
- const SUM_GRACE_MS = 6e4;
14746
- /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
14747
- const SVN_ROOT_MAX_DEPTH = 10;
14748
- /** 默认存在性探测(node fs;异常一律按不存在)。 */
14749
- const defaultExists = async (path) => {
14750
- try {
14751
- await stat(path);
14752
- return true;
14753
- } catch {
14754
- return false;
14755
- }
14756
- };
15051
+ //#endregion
15052
+ //#region src/svnUpdateJob.ts
14757
15053
  /**
14758
- * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
14759
- * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
14760
- * @author ddj 2026年09月16号
14761
- * @param cwd 起始绝对路径
14762
- * @param exists 存在性探测(测试注入)
14763
- * @returns 工作副本根绝对路径;未命中返回 null
15054
+ * dsh-vscode-mode host — SVN update job with incremental output and bounded replay.
15055
+ * @author ddj 2026年09月24号
14764
15056
  */
14765
- async function findSvnRoot(cwd, exists = defaultExists) {
14766
- let dir = cwd;
14767
- for (let depth = 0; dir && depth < SVN_ROOT_MAX_DEPTH; depth += 1) {
14768
- if (await exists(join(dir, ".svn"))) return dir;
14769
- const parent = dirname(dir);
14770
- if (parent === dir) return null;
14771
- dir = parent;
14772
- }
14773
- return null;
14774
- }
15057
+ const ROW_CAP = 2e4;
15058
+ const PAGE_SIZE = 250;
15059
+ const LINE_CAP = 16e3;
15060
+ const JOB_CAP = 16;
15061
+ const RESULT_TTL_MS = 6e5;
15062
+ const UPDATE_MAX_MS = 36e5;
15063
+ const COLLECT_MS = 100;
14775
15064
  /**
14776
- * 解析 TortoiseProc.exe(候选序:设置目录 → bin 子目录 → 默认安装目录 → PATH 逐项)。
14777
- * @author ddj 2026年09月16号
14778
- * @param dirSetting 设置里的 TortoiseSVN 目录(空 = 默认)
14779
- * @param exists 存在性探测(测试注入)
14780
- * @param platform 目标平台(非 win32 恒 null)
14781
- * @param pathEnv PATH 环境变量(测试注入)
14782
- * @returns TortoiseProc.exe 绝对路径;不可用返回 null
15065
+ * 创建每个任务独立的 SVN 状态计数。
15066
+ * @author ddj 2026年09月24号
15067
+ * @returns 初始计数
14783
15068
  */
14784
- async function findTortoiseProc(dirSetting, exists = defaultExists, platform = process.platform, pathEnv = process.env?.PATH) {
14785
- if (platform !== "win32") return null;
14786
- const dir = dirSetting && dirSetting.trim() ? dirSetting.trim() : "";
14787
- const candidates = [dir && join(dir, "bin", "TortoiseProc.exe"), dir && join(dir, "TortoiseProc.exe")].filter((item) => Boolean(item));
14788
- for (const entry of (pathEnv ?? "").split(";")) {
14789
- const trim = entry.trim();
14790
- if (trim) candidates.push(join(trim, TORTOISE_EXE));
14791
- }
14792
- for (const candidate of candidates) if (await exists(candidate)) return candidate;
14793
- return null;
15069
+ function emptyCounts() {
15070
+ return {
15071
+ A: 0,
15072
+ U: 0,
15073
+ D: 0,
15074
+ G: 0,
15075
+ C: 0,
15076
+ E: 0,
15077
+ R: 0
15078
+ };
14794
15079
  }
14795
15080
  /**
14796
- * svn:ignore 名称合法性校验(host 权威;防换行注入 propset 值与路径穿越)。
14797
- * 规则:去空白后非空、无换行、无路径分隔符、不以 `-` 开头(防被 svn 当选项解析)。
14798
- * @author ddj 2026年09月23号
14799
- * @param name 待写入的忽略名(basename / 通配模式)
14800
- * @returns 错误文案;合法为 null
15081
+ * 工作副本根作为并发键;Windows 路径大小写不敏感。
15082
+ * @author ddj 2026年09月24号
15083
+ * @param root 工作副本根
15084
+ * @param platform 目标平台
15085
+ * @returns 并发键
14801
15086
  */
14802
- function ignoreNameErrorOf(name) {
14803
- if (typeof name !== "string") return "忽略名必须是字符串";
14804
- const trimmed = name.trim();
14805
- if (!trimmed) return "忽略名不能为空";
14806
- if (/[\r\n]/.test(trimmed)) return "忽略名不能包含换行";
14807
- if (trimmed.includes("/") || trimmed.includes("\\")) return "忽略名不能包含路径分隔符";
14808
- if (trimmed.startsWith("-")) return "忽略名不能以 - 开头";
14809
- return null;
15087
+ function rootKey(root, platform) {
15088
+ return platform === "win32" ? root.toLowerCase() : root;
14810
15089
  }
14811
15090
  /**
14812
- * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
14813
- *
14814
- * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
14815
- * 避免自研项与官方同名项并列、用户误点到官方弹窗。
14816
- * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
14817
- * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
15091
+ * 有状态更新任务:原 RPC 与新后台任务独立,测试可注入进程服务/时钟。
15092
+ * @author ddj 2026年09月24号
14818
15093
  */
14819
- const SVN_FEATURES = [
15094
+ var SvnUpdateJobs = class {
15095
+ service;
15096
+ jobs = /* @__PURE__ */ new Map();
15097
+ platform;
15098
+ now;
15099
+ /**
15100
+ * 建立任务管理器。
15101
+ * @author ddj 2026年09月24号
15102
+ * @param service DSH subprocess 服务获取器
15103
+ * @param platform 平台
15104
+ * @param now 时钟
15105
+ */
15106
+ constructor(service, platform = process.platform, now = Date.now) {
15107
+ this.service = service;
15108
+ this.platform = platform;
15109
+ this.now = now;
15110
+ }
15111
+ /**
15112
+ * 同根重复点击只返回当前任务;旧结果可由 active 取回,新点击启动新任务。
15113
+ * @author ddj 2026年09月24号
15114
+ * @param target 已验证的工作副本目标
15115
+ * @returns 任务初始状态
15116
+ */
15117
+ start(target) {
15118
+ this.prune();
15119
+ const key = rootKey(target.root, this.platform);
15120
+ const old = this.jobs.get(key);
15121
+ if (old && !this.isFinal(old.phase)) return this.stateOf(old);
15122
+ if (!old && this.jobs.size >= JOB_CAP) throw new Error("同时保留的 SVN 更新任务过多,请稍后重试");
15123
+ const job = {
15124
+ id: randomUUID(),
15125
+ root: target.root,
15126
+ cwd: target.cwd,
15127
+ target: target.path,
15128
+ phase: "starting",
15129
+ counts: emptyCounts(),
15130
+ revision: null,
15131
+ error: "",
15132
+ rawTail: "",
15133
+ truncated: false,
15134
+ startedAt: this.now(),
15135
+ endedAt: null,
15136
+ rows: [],
15137
+ seq: 0,
15138
+ pending: "",
15139
+ cancelled: false,
15140
+ exited: false
15141
+ };
15142
+ this.jobs.set(key, job);
15143
+ this.launch(job, target.exe);
15144
+ return this.stateOf(job);
15145
+ }
15146
+ /**
15147
+ * 游标分页取状态;即使前端掉线,有限窗口仍可重新读取。
15148
+ * @author ddj 2026年09月24号
15149
+ * @param root 已验证会话的工作副本根
15150
+ * @param jobId 任务 id
15151
+ * @param since 最后一条已读序号
15152
+ * @returns 一页增量及状态
15153
+ */
15154
+ poll(root, jobId, since) {
15155
+ const job = this.getJob(root, jobId);
15156
+ const offset = job.rows[0]?.seq ?? job.seq + 1;
15157
+ const cursor = Math.max(offset - 1, Number.isFinite(since) ? Math.floor(since) : 0);
15158
+ const start = Math.max(0, cursor - offset + 1);
15159
+ const rows = job.rows.slice(start, start + PAGE_SIZE);
15160
+ return {
15161
+ ...this.stateOf(job),
15162
+ rows,
15163
+ offset,
15164
+ totalSeq: job.seq,
15165
+ nextSeq: rows.at(-1)?.seq ?? cursor
15166
+ };
15167
+ }
15168
+ /**
15169
+ * 请求终止整个受管进程范围;真实退出前保持 cancelling 状态。
15170
+ * @author ddj 2026年09月24号
15171
+ * @param root 已验证会话的工作副本根
15172
+ * @param jobId 任务 id
15173
+ * @returns 当前状态
15174
+ */
15175
+ cancel(root, jobId) {
15176
+ const job = this.getJob(root, jobId);
15177
+ if (!this.isFinal(job.phase) && !job.exited) {
15178
+ job.cancelled = true;
15179
+ job.phase = "cancelling";
15180
+ job.probeAbort?.abort(/* @__PURE__ */ new Error("用户已取消 SVN 更新"));
15181
+ try {
15182
+ job.stop?.();
15183
+ } catch (error) {
15184
+ job.error = String(error);
15185
+ }
15186
+ }
15187
+ return this.stateOf(job);
15188
+ }
15189
+ /**
15190
+ * 按当前会话工作副本恢复进行中或最近一次结果。
15191
+ * @author ddj 2026年09月24号
15192
+ * @param root 已验证的工作副本根
15193
+ * @returns 状态或 null
15194
+ */
15195
+ active(root) {
15196
+ this.prune();
15197
+ const job = this.jobs.get(rootKey(root, this.platform));
15198
+ return job ? this.stateOf(job) : null;
15199
+ }
15200
+ /**
15201
+ * 插件卸载回收任务与定时器。
15202
+ * @author ddj 2026年09月24号
15203
+ */
15204
+ dispose() {
15205
+ for (const job of this.jobs.values()) {
15206
+ if (job.timer) clearInterval(job.timer);
15207
+ if (job.deadline) clearTimeout(job.deadline);
15208
+ if (!this.isFinal(job.phase)) {
15209
+ job.cancelled = true;
15210
+ job.probeAbort?.abort(/* @__PURE__ */ new Error("SVN 插件已卸载"));
15211
+ try {
15212
+ job.stop?.();
15213
+ } catch {}
15214
+ }
15215
+ }
15216
+ this.jobs.clear();
15217
+ }
15218
+ /**
15219
+ * 先用只读 version 探测捕获通道,再允许启动会修改工作副本的 update。
15220
+ * @author ddj 2026年09月24号
15221
+ * @param job 待启动任务
15222
+ * @param exe 已配置 SVN CLI
15223
+ */
15224
+ async launch(job, exe) {
15225
+ const controller = new AbortController();
15226
+ job.probeAbort = controller;
15227
+ const probeDeadline = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("SVN 输出探测超时(8 秒)")), 8e3);
15228
+ probeDeadline.unref?.();
15229
+ try {
15230
+ const service = this.service();
15231
+ if (!service) throw new Error("subprocess 服务不可用");
15232
+ const modes = this.platform === "win32" ? ["terminal", "collect"] : ["collect", "terminal"];
15233
+ let mode = null;
15234
+ let reason = "";
15235
+ for (const candidate of modes) {
15236
+ controller.signal.throwIfAborted();
15237
+ try {
15238
+ await this.probe(service, exe, job.cwd, candidate, controller.signal);
15239
+ mode = candidate;
15240
+ break;
15241
+ } catch (error) {
15242
+ if (controller.signal.aborted) throw error;
15243
+ reason = String(error);
15244
+ }
15245
+ }
15246
+ if (job.cancelled) {
15247
+ this.finish(job, null);
15248
+ return;
15249
+ }
15250
+ if (!mode) throw new Error("SVN 输出通道不可用:" + reason);
15251
+ clearTimeout(probeDeadline);
15252
+ await this.startUpdate(job, service, mode, exe, controller);
15253
+ } catch (error) {
15254
+ job.error = String(error);
15255
+ this.finish(job, null);
15256
+ } finally {
15257
+ clearTimeout(probeDeadline);
15258
+ job.probeAbort = void 0;
15259
+ }
15260
+ }
15261
+ /**
15262
+ * 分配实际更新句柄时继续允许取消;未完成的分配不盲目重试更新。
15263
+ * @author ddj 2026年09月24号
15264
+ * @param job 当前任务
15265
+ * @param service DSH 子进程服务
15266
+ * @param mode 已探测可用的流式通道
15267
+ * @param exe SVN 可执行文件
15268
+ * @param controller 启动阶段的取消信号
15269
+ */
15270
+ async startUpdate(job, service, mode, exe, controller) {
15271
+ const argv = [
15272
+ exe,
15273
+ "--non-interactive",
15274
+ "update",
15275
+ "--",
15276
+ job.target || "."
15277
+ ];
15278
+ job.phase = "running";
15279
+ job.stop = () => controller.abort(/* @__PURE__ */ new Error("更新启动期间被取消"));
15280
+ job.deadline = setTimeout(() => {
15281
+ job.error = "SVN 更新超过 60 分钟,已请求停止;工作副本可能已部分更新";
15282
+ try {
15283
+ job.stop?.();
15284
+ } catch {}
15285
+ }, UPDATE_MAX_MS);
15286
+ job.deadline.unref?.();
15287
+ if (mode === "terminal" && service.spawnTerminal) {
15288
+ const pending = service.spawnTerminal({
15289
+ ...this.terminalSpec(argv, job.cwd),
15290
+ signal: controller.signal
15291
+ });
15292
+ const handle = await this.awaitTerminal(pending, controller.signal);
15293
+ job.stop = () => {
15294
+ handle.terminate().catch((error) => {
15295
+ job.error = String(error);
15296
+ });
15297
+ };
15298
+ if (job.cancelled) job.stop();
15299
+ this.watchTerminal(job, handle);
15300
+ } else {
15301
+ const handle = service.spawn({
15302
+ ...this.collectSpec(argv, job.cwd),
15303
+ signal: controller.signal
15304
+ });
15305
+ job.stop = () => handle.terminate();
15306
+ if (job.cancelled) job.stop();
15307
+ this.watchCollect(job, handle);
15308
+ }
15309
+ }
15310
+ /**
15311
+ * 终端句柄分配与 Abort 之间的微任务间隙也必须回收晚到句柄。
15312
+ * @author ddj 2026年09月24号
15313
+ * @param pending 分配中的终端
15314
+ * @param signal 用户取消或截止信号
15315
+ * @returns 可受管的终端句柄
15316
+ */
15317
+ async awaitTerminal(pending, signal) {
15318
+ let handle = null;
15319
+ const stop = () => {
15320
+ if (handle) handle.terminate().catch(() => {});
15321
+ };
15322
+ signal.addEventListener("abort", stop, { once: true });
15323
+ try {
15324
+ const guarded = pending.then((ready) => {
15325
+ handle = ready;
15326
+ if (signal.aborted) stop();
15327
+ return ready;
15328
+ });
15329
+ return await this.waitProbe(guarded, signal, () => {});
15330
+ } finally {
15331
+ signal.removeEventListener("abort", stop);
15332
+ }
15333
+ }
15334
+ /**
15335
+ * 探针/启动 Promise 可由超时和用户取消立即打断,并终止已分配的句柄。
15336
+ * @author ddj 2026年09月24号
15337
+ * @param pending 待完成操作
15338
+ * @param signal 任务取消信号
15339
+ * @param stop 停止已经分配的子进程
15340
+ * @returns 操作结果
15341
+ */
15342
+ waitProbe(pending, signal, stop) {
15343
+ return new Promise((resolve, reject) => {
15344
+ let finished = false;
15345
+ const abort = () => {
15346
+ if (finished) return;
15347
+ finished = true;
15348
+ try {
15349
+ stop();
15350
+ } catch {}
15351
+ reject(signal.reason ?? /* @__PURE__ */ new Error("SVN 探测已取消"));
15352
+ };
15353
+ if (signal.aborted) {
15354
+ abort();
15355
+ pending.catch(() => {});
15356
+ return;
15357
+ }
15358
+ signal.addEventListener("abort", abort, { once: true });
15359
+ pending.then((value) => {
15360
+ signal.removeEventListener("abort", abort);
15361
+ if (!finished) {
15362
+ finished = true;
15363
+ resolve(value);
15364
+ }
15365
+ }, (error) => {
15366
+ signal.removeEventListener("abort", abort);
15367
+ if (!finished) {
15368
+ finished = true;
15369
+ reject(error);
15370
+ }
15371
+ });
15372
+ });
15373
+ }
15374
+ /**
15375
+ * 探针失败只换读法,不会重试一次已启动的实际 update。
15376
+ * @author ddj 2026年09月24号
15377
+ * @param service DSH 服务
15378
+ * @param exe SVN 命令
15379
+ * @param cwd 进程目录
15380
+ * @param mode 输出通道
15381
+ * @param signal 用户取消或探针超时
15382
+ */
15383
+ async probe(service, exe, cwd, mode, signal) {
15384
+ signal.throwIfAborted();
15385
+ const argv = [
15386
+ exe,
15387
+ "--version",
15388
+ "--quiet"
15389
+ ];
15390
+ if (mode === "terminal") {
15391
+ if (!service.spawnTerminal) throw new Error("终端输出通道不可用");
15392
+ const pending = service.spawnTerminal({
15393
+ ...this.terminalSpec(argv, cwd),
15394
+ signal
15395
+ });
15396
+ const handle = await this.awaitTerminal(pending, signal);
15397
+ let text = "";
15398
+ let failure = "";
15399
+ const drained = new Promise((resolve) => {
15400
+ handle.output.on("data", (chunk) => {
15401
+ text += String(chunk);
15402
+ });
15403
+ handle.output.once("end", resolve);
15404
+ handle.output.once("error", (error) => {
15405
+ failure = String(error);
15406
+ resolve();
15407
+ });
15408
+ });
15409
+ const outcome = await this.waitProbe(handle.done, signal, () => {
15410
+ handle.terminate().catch(() => {});
15411
+ });
15412
+ await this.waitProbe(Promise.race([drained, new Promise((resolve) => {
15413
+ setTimeout(resolve, 2e3).unref?.();
15414
+ })]), signal, () => {
15415
+ handle.terminate().catch(() => {});
15416
+ });
15417
+ if (outcome.exitCode !== 0 || !text.trim() || failure) throw new Error("终端输出探测失败:" + failure);
15418
+ return;
15419
+ }
15420
+ const handle = service.spawn({
15421
+ ...this.collectSpec(argv, cwd),
15422
+ signal
15423
+ });
15424
+ const outcome = await this.waitProbe(handle.done, signal, () => handle.terminate());
15425
+ const text = handle.collected?.stdout?.readFrom(0).text ?? "";
15426
+ if (outcome.exitCode !== 0 || !text.trim()) throw new Error("收集输出探测失败");
15427
+ }
15428
+ /**
15429
+ * DSH PTY 读取:TextDecoder 保证跨数据块的 UTF-8 字符不被截断。
15430
+ * @author ddj 2026年09月24号
15431
+ * @param job 活动任务
15432
+ * @param handle 终端句柄
15433
+ */
15434
+ watchTerminal(job, handle) {
15435
+ const decoder = new TextDecoder();
15436
+ let drained = false;
15437
+ let finishOutput = () => {};
15438
+ const outputDone = new Promise((resolve) => {
15439
+ finishOutput = resolve;
15440
+ });
15441
+ handle.output.on("data", (chunk) => {
15442
+ const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
15443
+ this.ingest(job, text);
15444
+ });
15445
+ handle.output.on("end", () => {
15446
+ drained = true;
15447
+ this.ingest(job, decoder.decode());
15448
+ finishOutput();
15449
+ });
15450
+ handle.output.on("error", (error) => {
15451
+ job.error = "读取输出失败:" + String(error);
15452
+ finishOutput();
15453
+ });
15454
+ handle.done.then(async (outcome) => {
15455
+ job.exited = true;
15456
+ if (!drained) await Promise.race([outputDone, new Promise((resolve) => {
15457
+ setTimeout(resolve, 2e3).unref?.();
15458
+ })]);
15459
+ this.finish(job, outcome.exitCode);
15460
+ }, (error) => {
15461
+ job.exited = true;
15462
+ job.error = String(error);
15463
+ this.finish(job, null);
15464
+ });
15465
+ }
15466
+ /**
15467
+ * 管道收集的读取器独立管理 stdout/stderr 字节偏移;只消费完整行,避免截断中文。
15468
+ * @author ddj 2026年09月24号
15469
+ * @param job 活动任务
15470
+ * @param handle 子进程句柄
15471
+ */
15472
+ watchCollect(job, handle) {
15473
+ const offsets = {
15474
+ stdout: 0,
15475
+ stderr: 0
15476
+ };
15477
+ job.timer = setInterval(() => this.readCollect(job, handle, offsets, false), COLLECT_MS);
15478
+ job.timer.unref?.();
15479
+ handle.done.then((outcome) => {
15480
+ job.exited = true;
15481
+ if (job.timer) clearInterval(job.timer);
15482
+ this.readCollect(job, handle, offsets, true);
15483
+ this.finish(job, outcome.exitCode ?? outcome.code ?? null);
15484
+ }, (error) => {
15485
+ job.exited = true;
15486
+ job.error = String(error);
15487
+ this.finish(job, null);
15488
+ });
15489
+ }
15490
+ /**
15491
+ * 从两个 bounded collector 取完整行;lossy 时标记不完整且不伪造丢失计数。
15492
+ * @author ddj 2026年09月24号
15493
+ * @param job 活动任务
15494
+ * @param handle 子进程句柄
15495
+ * @param offsets 各路已消费字节位置
15496
+ * @param final 进程已结束,可以消费末尾无换行内容
15497
+ */
15498
+ readCollect(job, handle, offsets, final) {
15499
+ for (const key of ["stdout", "stderr"]) {
15500
+ const reader = handle.collected?.[key];
15501
+ if (!reader) {
15502
+ job.truncated = true;
15503
+ continue;
15504
+ }
15505
+ const read = reader.readFrom(offsets[key]);
15506
+ if (read.lossy) {
15507
+ job.truncated = true;
15508
+ offsets[key] = read.nextOffset;
15509
+ job.pending = "";
15510
+ this.recordLine(job, "输出前段已截断,以下是可恢复的尾部记录", null);
15511
+ const firstEnd = read.text.indexOf("\n");
15512
+ if (firstEnd >= 0) this.ingest(job, read.text.slice(firstEnd + 1));
15513
+ continue;
15514
+ }
15515
+ const last = final ? read.text.length : read.text.lastIndexOf("\n") + 1;
15516
+ if (!last) continue;
15517
+ const chunk = read.text.slice(0, last);
15518
+ offsets[key] = final ? read.nextOffset : offsets[key] + Buffer.byteLength(chunk, "utf8");
15519
+ this.ingest(job, chunk);
15520
+ }
15521
+ }
15522
+ /**
15523
+ * 增量行缓冲:ANSI 和 CR/LF 归一;过长单行主动截断并告警。
15524
+ * @author ddj 2026年09月24号
15525
+ * @param job 活动任务
15526
+ * @param text 新输出
15527
+ */
15528
+ ingest(job, text) {
15529
+ if (!text) return;
15530
+ job.pending += text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
15531
+ const lines = job.pending.split(/\r\n|\n|\r/);
15532
+ job.pending = lines.pop() ?? "";
15533
+ for (const line of lines) this.recordLine(job, line, updateLineOf(line));
15534
+ if (job.pending.length > LINE_CAP) {
15535
+ job.truncated = true;
15536
+ this.recordLine(job, job.pending.slice(0, LINE_CAP) + "…(单行过长)", null);
15537
+ job.pending = "";
15538
+ }
15539
+ }
15540
+ /**
15541
+ * 记录有界行与原文;修订号只读 svn 自己的完成/无变化行。
15542
+ * @author ddj 2026年09月24号
15543
+ * @param job 活动任务
15544
+ * @param line 完整输出行
15545
+ * @param entry 可识别的逐文件状态
15546
+ */
15547
+ recordLine(job, line, entry) {
15548
+ const raw = line.trimEnd();
15549
+ if (!raw) return;
15550
+ job.rawTail = (job.rawTail + raw + "\n").slice(-32e3);
15551
+ const rev = /(?:Updated to revision|At revision|已更新到版本|版本)\s*\.?\s*(\d+)/i.exec(raw);
15552
+ if (rev) job.revision = Number(rev[1]);
15553
+ if (entry) job.counts[entry.action] += 1;
15554
+ const path = entry?.path ?? "";
15555
+ const row = {
15556
+ seq: ++job.seq,
15557
+ action: entry?.action ?? null,
15558
+ path,
15559
+ relPath: entry ? this.relPathOf(job, path) : null,
15560
+ label: entry?.label ?? raw.trim(),
15561
+ raw
15562
+ };
15563
+ job.rows.push(row);
15564
+ if (job.rows.length > ROW_CAP) {
15565
+ job.rows.splice(0, 1e3);
15566
+ job.truncated = true;
15567
+ }
15568
+ }
15569
+ /**
15570
+ * 只给工作区内的输出路径开放文件动作(不信任 CLI 文本直接当作目标)。
15571
+ * @author ddj 2026年09月24号
15572
+ * @param job 活动任务
15573
+ * @param path SVN 输出路径
15574
+ * @returns 工作区相对路径或 null
15575
+ */
15576
+ relPathOf(job, path) {
15577
+ const abs = resolve(job.cwd, path);
15578
+ const wcRel = relative(job.root, abs);
15579
+ const rel = relative(job.cwd, abs);
15580
+ if (isAbsolute(wcRel) || wcRel === ".." || wcRel.startsWith("..\\") || wcRel.startsWith("../")) return null;
15581
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith("..\\") || rel.startsWith("../")) return null;
15582
+ return rel.replace(/\\/g, "/") || null;
15583
+ }
15584
+ /**
15585
+ * 保留终态行供用户检查,取消不能误报成功。
15586
+ * @author ddj 2026年09月24号
15587
+ * @param job 活动任务
15588
+ * @param code 真实退出码
15589
+ */
15590
+ finish(job, code) {
15591
+ if (this.isFinal(job.phase)) return;
15592
+ if (job.pending.trim()) this.recordLine(job, job.pending, updateLineOf(job.pending));
15593
+ job.pending = "";
15594
+ if (job.timer) {
15595
+ clearInterval(job.timer);
15596
+ job.timer = void 0;
15597
+ }
15598
+ if (job.deadline) {
15599
+ clearTimeout(job.deadline);
15600
+ job.deadline = void 0;
15601
+ }
15602
+ job.endedAt = this.now();
15603
+ if (job.cancelled) job.phase = "cancelled";
15604
+ else if (code === 0 && !job.error) job.phase = "completed";
15605
+ else {
15606
+ job.phase = "failed";
15607
+ job.error ||= "SVN 更新失败(退出码 " + String(code) + "):" + job.rawTail.slice(-500);
15608
+ }
15609
+ }
15610
+ /**
15611
+ * 快照不暴露可变数组;耗时使用现场时钟或终态时间。
15612
+ * @author ddj 2026年09月24号
15613
+ * @param job 目标任务
15614
+ * @returns 纯数据快照
15615
+ */
15616
+ stateOf(job) {
15617
+ return {
15618
+ jobId: job.id,
15619
+ target: job.target,
15620
+ phase: job.phase,
15621
+ counts: { ...job.counts },
15622
+ revision: job.revision,
15623
+ elapsedMs: Math.max(0, (job.endedAt ?? this.now()) - job.startedAt),
15624
+ error: job.error,
15625
+ rawTail: this.isFinal(job.phase) || job.truncated ? job.rawTail : "",
15626
+ truncated: job.truncated,
15627
+ nextSeq: job.seq
15628
+ };
15629
+ }
15630
+ /**
15631
+ * 任务 ID 必须属于调用会话的工作副本根。
15632
+ * @author ddj 2026年09月24号
15633
+ * @param root 已验证工作副本根
15634
+ * @param jobId 客户端任务 id
15635
+ * @returns job
15636
+ */
15637
+ getJob(root, jobId) {
15638
+ this.prune();
15639
+ const job = this.jobs.get(rootKey(root, this.platform));
15640
+ if (!job || job.id !== jobId) throw new Error("SVN 更新任务不存在或工作区不匹配");
15641
+ return job;
15642
+ }
15643
+ /**
15644
+ * 清理过期终态,不丢活动中的更新。
15645
+ * @author ddj 2026年09月24号
15646
+ */
15647
+ prune() {
15648
+ for (const [key, job] of this.jobs) if (job.endedAt !== null && this.now() - job.endedAt > RESULT_TTL_MS) this.jobs.delete(key);
15649
+ }
15650
+ /**
15651
+ * 终态判断集中定义,避免取消请求被误视作结束。
15652
+ * @author ddj 2026年09月24号
15653
+ * @param phase 当前阶段
15654
+ * @returns 是否结束
15655
+ */
15656
+ isFinal(phase) {
15657
+ return phase === "completed" || phase === "failed" || phase === "cancelled";
15658
+ }
15659
+ /**
15660
+ * 不启 shell:每个 argv 直接传入,标准输入忽略且提供进程清理预算。
15661
+ * @author ddj 2026年09月24号
15662
+ * @param argv 可执行文件及参数
15663
+ * @param cwd 目标工作目录
15664
+ * @returns DSH terminal spec
15665
+ */
15666
+ terminalSpec(argv, cwd) {
15667
+ return {
15668
+ argv,
15669
+ cwd,
15670
+ rows: 30,
15671
+ cols: 180,
15672
+ terminalType: "xterm-256color",
15673
+ graceMs: 1e4
15674
+ };
15675
+ }
15676
+ /**
15677
+ * collected reader 大于单次 RPC:后台按 100ms 消费,避免 1MB 尾部静默丢头。
15678
+ * @author ddj 2026年09月24号
15679
+ * @param argv 可执行文件及参数
15680
+ * @param cwd 目标工作目录
15681
+ * @returns DSH process spec
15682
+ */
15683
+ collectSpec(argv, cwd) {
15684
+ return {
15685
+ argv,
15686
+ cwd,
15687
+ graceMs: 1e4,
15688
+ stdio: {
15689
+ stdin: "ignore",
15690
+ stdout: { maxBytes: 4194304 },
15691
+ stderr: { maxBytes: 131072 }
15692
+ }
15693
+ };
15694
+ }
15695
+ };
15696
+ /** CLI 探测超时。 */
15697
+ const CLI_GRACE_MS = 6e3;
15698
+ /** svn update 超时(大工作副本留足预算)。 */
15699
+ const UPDATE_GRACE_MS = 18e4;
15700
+ /** TortoiseProc 发射超时预算(不 await done,仅约束服务侧资源)。 */
15701
+ const TORTOISE_GRACE_MS = 6e5;
15702
+ /** 前台置顶助手超时预算(轮询 ≤8s + PowerShell 启动余量)。 */
15703
+ const HELPER_GRACE_MS = 2e4;
15704
+ /** 输出载荷上限(1MB,对齐 runSvn stdio 限额;文本解析上限见 svnText.TEXT_OUTPUT_CAP)。 */
15705
+ const OUTPUT_CAP = TEXT_OUTPUT_CAP;
15706
+ /**
15707
+ * 日志拉取的 stdout 上限:默认窗口 LOG_CAP=500 条、Show All 上限 SVN_LOG_SHOW_ALL_CAP=5000 条。
15708
+ *
15709
+ * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 500` → 2.52MB / 2.0s,
15710
+ * `-l 5000` → 18.86MB / 30.1s(约 3.8~5KB/条,含中文路径与提交信息)。故取 64MB 留约 3× 余量,
15711
+ * `-g`(merged)单条更大也吃这份余量。
15712
+ * 实测教训(勿复踩):默认 1MB 上限时收集器保留**尾部**,Assets 全量 100 条(1.19MB)
15713
+ * 恰好把最新的 52 条挤出(用户报「显示的数据不对」)。
15714
+ * @author ddj 2026年09月17号 / 2026年09月18号
15715
+ */
15716
+ const LOG_OUTPUT_CAP = 67108864;
15717
+ /** 变更条目上限(防超大工作副本撑爆载荷)。 */
15718
+ const CHANGES_CAP = 4e3;
15719
+ /** `svn status --xml` 超时(大工作副本留足预算)。 */
15720
+ const STATUS_GRACE_MS = 12e4;
15721
+ /** 其他只读子命令(cat/info)超时。 */
15722
+ const READ_GRACE_MS = 3e4;
15723
+ /** revert/add 超时。 */
15724
+ const MUTATE_GRACE_MS = 12e4;
15725
+ /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
15726
+ const READ_CAP = 8388608;
15727
+ /**
15728
+ * W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
15729
+ * 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
15730
+ * 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
15731
+ */
15732
+ const PATCH_OUTPUT_CAP = 16777216;
15733
+ /** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
15734
+ const SUM_OUTPUT_CAP = 8388608;
15735
+ /**
15736
+ * W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
15737
+ * (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
15738
+ */
15739
+ const REMOTE_GRACE_MS = 15e3;
15740
+ /** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
15741
+ const SUM_GRACE_MS = 6e4;
15742
+ /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
15743
+ const SVN_ROOT_MAX_DEPTH = 10;
15744
+ /** 默认存在性探测(node fs;异常一律按不存在)。 */
15745
+ const defaultExists = async (path) => {
15746
+ try {
15747
+ await stat(path);
15748
+ return true;
15749
+ } catch {
15750
+ return false;
15751
+ }
15752
+ };
15753
+ /**
15754
+ * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
15755
+ * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
15756
+ * @author ddj 2026年09月16号
15757
+ * @param cwd 起始绝对路径
15758
+ * @param exists 存在性探测(测试注入)
15759
+ * @returns 工作副本根绝对路径;未命中返回 null
15760
+ */
15761
+ async function findSvnRoot(cwd, exists = defaultExists) {
15762
+ let dir = cwd;
15763
+ for (let depth = 0; dir && depth < SVN_ROOT_MAX_DEPTH; depth += 1) {
15764
+ if (await exists(join(dir, ".svn"))) return dir;
15765
+ const parent = dirname(dir);
15766
+ if (parent === dir) return null;
15767
+ dir = parent;
15768
+ }
15769
+ return null;
15770
+ }
15771
+ /**
15772
+ * 解析 TortoiseProc.exe(候选序:设置目录 → bin 子目录 → 默认安装目录 → PATH 逐项)。
15773
+ * @author ddj 2026年09月16号
15774
+ * @param dirSetting 设置里的 TortoiseSVN 目录(空 = 默认)
15775
+ * @param exists 存在性探测(测试注入)
15776
+ * @param platform 目标平台(非 win32 恒 null)
15777
+ * @param pathEnv PATH 环境变量(测试注入)
15778
+ * @returns TortoiseProc.exe 绝对路径;不可用返回 null
15779
+ */
15780
+ async function findTortoiseProc(dirSetting, exists = defaultExists, platform = process.platform, pathEnv = process.env?.PATH) {
15781
+ if (platform !== "win32") return null;
15782
+ const dir = dirSetting && dirSetting.trim() ? dirSetting.trim() : "";
15783
+ const candidates = [dir && join(dir, "bin", "TortoiseProc.exe"), dir && join(dir, "TortoiseProc.exe")].filter((item) => Boolean(item));
15784
+ for (const entry of (pathEnv ?? "").split(";")) {
15785
+ const trim = entry.trim();
15786
+ if (trim) candidates.push(join(trim, TORTOISE_EXE));
15787
+ }
15788
+ for (const candidate of candidates) if (await exists(candidate)) return candidate;
15789
+ return null;
15790
+ }
15791
+ /**
15792
+ * svn:ignore 名称合法性校验(host 权威;防换行注入 propset 值与路径穿越)。
15793
+ * 规则:去空白后非空、无换行、无路径分隔符、不以 `-` 开头(防被 svn 当选项解析)。
15794
+ * @author ddj 2026年09月23号
15795
+ * @param name 待写入的忽略名(basename / 通配模式)
15796
+ * @returns 错误文案;合法为 null
15797
+ */
15798
+ function ignoreNameErrorOf(name) {
15799
+ if (typeof name !== "string") return "忽略名必须是字符串";
15800
+ const trimmed = name.trim();
15801
+ if (!trimmed) return "忽略名不能为空";
15802
+ if (/[\r\n]/.test(trimmed)) return "忽略名不能包含换行";
15803
+ if (trimmed.includes("/") || trimmed.includes("\\")) return "忽略名不能包含路径分隔符";
15804
+ if (trimmed.startsWith("-")) return "忽略名不能以 - 开头";
15805
+ return null;
15806
+ }
15807
+ /**
15808
+ * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
15809
+ *
15810
+ * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
15811
+ * 避免自研项与官方同名项并列、用户误点到官方弹窗。
15812
+ * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
15813
+ * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
15814
+ */
15815
+ const SVN_FEATURES = [
14820
15816
  "diff",
14821
15817
  "revert",
14822
15818
  "log"
@@ -15100,6 +16096,20 @@ function safeRel(raw) {
15100
16096
  return rel;
15101
16097
  }
15102
16098
  /**
16099
+ * 会话根可在 SVN 工作副本的子目录;CLI 在 wcRoot 执行时必须换算目标坐标。
16100
+ * @author ddj 2026年09月24号
16101
+ * @param cwd 当前会话目录
16102
+ * @param wcRoot SVN 工作副本根
16103
+ * @param rel 会话相对目标
16104
+ * @returns SVN 根相对目标;越界返回 null
16105
+ */
16106
+ function svnPathOf(cwd, wcRoot, rel) {
16107
+ if (safeRel(rel) === null) return null;
16108
+ const wcRel = relative(wcRoot, resolve(cwd, rel || "."));
16109
+ if (isAbsolute(wcRel) || wcRel === ".." || wcRel.startsWith("../") || wcRel.startsWith("..\\")) return null;
16110
+ return wcRel.replace(/\\/g, "/") || ".";
16111
+ }
16112
+ /**
15103
16113
  * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
15104
16114
  * @author ddj 2026年09月16号
15105
16115
  * @param deps ctx + 设置读取面 + 可注入时钟
@@ -15111,6 +16121,8 @@ function createSvnRpc(deps) {
15111
16121
  const findRoot = deps.findRoot ?? ((cwd) => findSvnRoot(cwd));
15112
16122
  const findTortoise = deps.findTortoise ?? ((dir) => findTortoiseProc(dir));
15113
16123
  const statusCache = /* @__PURE__ */ new Map();
16124
+ /** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
16125
+ const updateRootCache = /* @__PURE__ */ new Map();
15114
16126
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
15115
16127
  const changesCache = /* @__PURE__ */ new Map();
15116
16128
  /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
@@ -15118,6 +16130,7 @@ function createSvnRpc(deps) {
15118
16130
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
15119
16131
  const urlCache = /* @__PURE__ */ new Map();
15120
16132
  let cliProbe = null;
16133
+ const updateJobs = new SvnUpdateJobs(() => ctx.get("subprocess"), deps.platform ?? process.platform, now);
15121
16134
  const subprocess = () => {
15122
16135
  const sub = ctx.get("subprocess");
15123
16136
  if (!sub || typeof sub.spawn !== "function") return null;
@@ -15129,24 +16142,41 @@ function createSvnRpc(deps) {
15129
16142
  if (cliProbe && cliProbe.exe === exe && at - cliProbe.at < 3e5) return cliProbe.ok;
15130
16143
  let ok = false;
15131
16144
  const spawn = subprocess();
15132
- if (spawn) try {
15133
- const outcome = await spawn({
15134
- argv: [
15135
- exe,
15136
- "--version",
15137
- "--quiet"
15138
- ],
15139
- cwd: tmpdir(),
15140
- stdio: {
15141
- stdout: "inherit",
15142
- stderr: "inherit",
15143
- stdin: "ignore"
15144
- },
15145
- graceMs: CLI_GRACE_MS
15146
- }).done;
15147
- ok = (outcome?.exitCode ?? outcome?.code) === 0;
15148
- } catch {
15149
- ok = false;
16145
+ if (spawn) {
16146
+ const controller = new AbortController();
16147
+ const deadline = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("SVN CLI 探测超时")), CLI_GRACE_MS);
16148
+ deadline.unref?.();
16149
+ try {
16150
+ const handle = spawn({
16151
+ argv: [
16152
+ exe,
16153
+ "--version",
16154
+ "--quiet"
16155
+ ],
16156
+ cwd: tmpdir(),
16157
+ stdio: {
16158
+ stdout: "inherit",
16159
+ stderr: "inherit",
16160
+ stdin: "ignore"
16161
+ },
16162
+ graceMs: CLI_GRACE_MS,
16163
+ signal: controller.signal
16164
+ });
16165
+ const expired = new Promise((_, reject) => {
16166
+ controller.signal.addEventListener("abort", () => {
16167
+ try {
16168
+ handle.terminate?.();
16169
+ } catch {}
16170
+ reject(controller.signal.reason);
16171
+ }, { once: true });
16172
+ });
16173
+ const outcome = await Promise.race([handle.done, expired]);
16174
+ ok = (outcome?.exitCode ?? outcome?.code) === 0;
16175
+ } catch {
16176
+ ok = false;
16177
+ } finally {
16178
+ clearTimeout(deadline);
16179
+ }
15150
16180
  }
15151
16181
  cliProbe = {
15152
16182
  at,
@@ -15181,6 +16211,43 @@ function createSvnRpc(deps) {
15181
16211
  return payload;
15182
16212
  };
15183
16213
  /**
16214
+ * 验证更新请求属于当前会话的工作副本;取消/轮询不依赖 CLI 仍可用。
16215
+ * @author ddj 2026年09月24号
16216
+ * @param sessionId 当前会话 id
16217
+ * @param needCli 仅真正启动更新时才探测 SVN 客户端
16218
+ * @returns 已校验的目录和状态,或错误
16219
+ */
16220
+ const updateScope = async (sessionId, needCli = false) => {
16221
+ const sc = await requireSvnSession(ctx, sessionId);
16222
+ if ("err" in sc) return { err: sc.err };
16223
+ if (!needCli) {
16224
+ const cached = updateRootCache.get(sc.cwd);
16225
+ const root = cached && now() - cached.at < 6e4 ? cached.root : await findRoot(sc.cwd);
16226
+ if (root && (!cached || now() - cached.at >= 6e4)) updateRootCache.set(sc.cwd, {
16227
+ at: now(),
16228
+ root
16229
+ });
16230
+ return root ? {
16231
+ cwd: sc.cwd,
16232
+ root,
16233
+ exe: "",
16234
+ cli: false
16235
+ } : { err: "当前工作区不受 SVN 管理" };
16236
+ }
16237
+ const status = await statusOf(sc.cwd);
16238
+ if (!status.managed || !status.wcRoot) return { err: "当前工作区不受 SVN 管理" };
16239
+ updateRootCache.set(sc.cwd, {
16240
+ at: now(),
16241
+ root: status.wcRoot
16242
+ });
16243
+ return {
16244
+ cwd: sc.cwd,
16245
+ root: status.wcRoot,
16246
+ exe: status.svnPath,
16247
+ cli: status.svnCli
16248
+ };
16249
+ };
16250
+ /**
15184
16251
  * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
15185
16252
  * @author ddj 2026年08月26号 / 2026年09月17号
15186
16253
  * @param cwd 运行目录
@@ -15601,979 +16668,1080 @@ function createSvnRpc(deps) {
15601
16668
  output: [...notes, ...failures].join("\n")
15602
16669
  };
15603
16670
  };
15604
- return { handlers: {
15605
- "svn.status": async (args) => {
15606
- const sc = await requireSvnSession(ctx, args.sessionId);
15607
- if ("err" in sc) return {
15608
- ok: false,
15609
- error: sc.err
15610
- };
15611
- return {
15612
- ok: true,
15613
- ...await statusOf(sc.cwd, args.force === true)
15614
- };
15615
- },
15616
- "svn.changes": async (args) => {
15617
- const sc = await requireSvnSession(ctx, args.sessionId);
15618
- if ("err" in sc) return {
15619
- ok: false,
15620
- error: sc.err
15621
- };
15622
- const status = await statusOf(sc.cwd);
15623
- if (!status.managed || !status.wcRoot) return {
15624
- ok: false,
15625
- error: "当前工作区不受 SVN 管理"
15626
- };
15627
- if (!status.svnCli) return {
15628
- ok: false,
15629
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15630
- };
15631
- try {
15632
- const result = await changesOf(status.wcRoot, args.force === true);
16671
+ return {
16672
+ handlers: {
16673
+ "svn.status": async (args) => {
16674
+ const sc = await requireSvnSession(ctx, args.sessionId);
16675
+ if ("err" in sc) return {
16676
+ ok: false,
16677
+ error: sc.err
16678
+ };
15633
16679
  return {
15634
16680
  ok: true,
15635
- wcRoot: status.wcRoot,
15636
- ...result
16681
+ ...await statusOf(sc.cwd, args.force === true)
16682
+ };
16683
+ },
16684
+ "svn.changes": async (args) => {
16685
+ const sc = await requireSvnSession(ctx, args.sessionId);
16686
+ if ("err" in sc) return {
16687
+ ok: false,
16688
+ error: sc.err
16689
+ };
16690
+ const status = await statusOf(sc.cwd);
16691
+ if (!status.managed || !status.wcRoot) return {
16692
+ ok: false,
16693
+ error: "当前工作区不受 SVN 管理"
16694
+ };
16695
+ if (!status.svnCli) return {
16696
+ ok: false,
16697
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16698
+ };
16699
+ try {
16700
+ const result = await changesOf(status.wcRoot, args.force === true);
16701
+ return {
16702
+ ok: true,
16703
+ wcRoot: status.wcRoot,
16704
+ ...result
16705
+ };
16706
+ } catch (error) {
16707
+ return {
16708
+ ok: false,
16709
+ error: "读取变更失败:" + String(error)
16710
+ };
16711
+ }
16712
+ },
16713
+ "svn.diffBase": async (args) => {
16714
+ const sc = await requireSvnSession(ctx, args.sessionId);
16715
+ if ("err" in sc) return {
16716
+ ok: false,
16717
+ error: sc.err
16718
+ };
16719
+ const rel = safeRel(args.path);
16720
+ if (rel === null || rel === "") return {
16721
+ ok: false,
16722
+ error: "路径不合法"
16723
+ };
16724
+ const status = await statusOf(sc.cwd);
16725
+ if (!status.managed || !status.wcRoot) return {
16726
+ ok: false,
16727
+ error: "当前工作区不受 SVN 管理"
16728
+ };
16729
+ if (!status.svnCli) return {
16730
+ ok: false,
16731
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16732
+ };
16733
+ const fs = ctx.get("fs");
16734
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
16735
+ ok: false,
16736
+ error: "缺少 fs"
16737
+ };
16738
+ try {
16739
+ const target = await fs.resolve(rel, { cwd: sc.cwd });
16740
+ const info = await fs.stat(target);
16741
+ if (!info || info.type !== "file") return {
16742
+ ok: false,
16743
+ error: "文件不存在"
16744
+ };
16745
+ if ((info.size ?? 0) > READ_CAP) return {
16746
+ ok: false,
16747
+ error: "文件过大(>8MB),不支持基线差异"
16748
+ };
16749
+ const working = await fs.readText(target);
16750
+ const outcome = await runSvn(status.wcRoot, [
16751
+ "cat",
16752
+ "-r",
16753
+ "BASE",
16754
+ "--",
16755
+ rel
16756
+ ], READ_GRACE_MS);
16757
+ if (outcome.code !== 0) {
16758
+ const text = (outcome.stderr || outcome.stdout).trim();
16759
+ return {
16760
+ ok: true,
16761
+ base: null,
16762
+ working,
16763
+ reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
16764
+ error: tailOfText(text),
16765
+ ...guardSides(null, working)
16766
+ };
16767
+ }
16768
+ return {
16769
+ ok: true,
16770
+ base: outcome.stdout,
16771
+ working,
16772
+ ...guardSides(outcome.stdout, working)
16773
+ };
16774
+ } catch (error) {
16775
+ return {
16776
+ ok: false,
16777
+ error: "读取基线失败:" + String(error)
16778
+ };
16779
+ }
16780
+ },
16781
+ "svn.revert": async (args) => {
16782
+ return mutate("还原", ["revert", "-R"], args.sessionId, args.paths);
16783
+ },
16784
+ "svn.add": async (args) => {
16785
+ return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
16786
+ },
16787
+ /**
16788
+ * 分区(changelist)批量操作:关联或解除工作副本条目的 changelist 登记。
16789
+ * 复用 mutate 全套护栏(路径白名单 / BATCH_PATHS_CAP / 超时 / 变更缓存作废);
16790
+ * 分区名 host 权威校验(`-` 开头会被 svn 当成选项解析),remove 分支不需要名字。
16791
+ * @author ddj 2026年09月23号
16792
+ * @param args.paths 工作区相对路径列表
16793
+ * @param args.name 目标分区名(remove 时忽略)
16794
+ * @param args.remove true = 移出分区(`changelist --remove`)
16795
+ * @returns 批量动作结果
16796
+ */
16797
+ "svn.changelist": async (args) => {
16798
+ if (args.remove !== true) {
16799
+ const nameError = svnChangelistNameErrorOf(String(args.name ?? ""));
16800
+ if (nameError) return {
16801
+ ok: false,
16802
+ error: nameError
16803
+ };
16804
+ }
16805
+ return mutate(args.remove === true ? "移出分区" : "分区", args.remove === true ? ["changelist", "--remove"] : ["changelist", String(args.name ?? "").trim()], args.sessionId, args.paths);
16806
+ },
16807
+ "svn.log": async (args) => {
16808
+ const sc = await requireSvnSession(ctx, args.sessionId);
16809
+ if ("err" in sc) return {
16810
+ ok: false,
16811
+ error: sc.err
16812
+ };
16813
+ const rel = safeRel(args.path);
16814
+ if (rel === null) return {
16815
+ ok: false,
16816
+ error: "路径不合法"
16817
+ };
16818
+ const status = await statusOf(sc.cwd);
16819
+ if (!status.managed || !status.wcRoot) return {
16820
+ ok: false,
16821
+ error: "当前工作区不受 SVN 管理"
16822
+ };
16823
+ if (!status.svnCli) return {
16824
+ ok: false,
16825
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16826
+ };
16827
+ const svnTarget = svnPathOf(sc.cwd, status.wcRoot, rel);
16828
+ if (svnTarget === null) return {
16829
+ ok: false,
16830
+ error: "日志路径不在 SVN 工作副本内"
16831
+ };
16832
+ try {
16833
+ return {
16834
+ ok: true,
16835
+ ...await logOf(status.wcRoot, svnTarget === "." ? "" : svnTarget, args.limit ?? 100, {
16836
+ stopOnCopy: args.stopOnCopy === true,
16837
+ showMerged: args.showMerged === true,
16838
+ range: args.startRev && args.endRev ? {
16839
+ start: Math.floor(args.startRev),
16840
+ end: Math.floor(args.endRev)
16841
+ } : null
16842
+ }, SVN_LOG_SHOW_ALL_CAP),
16843
+ target: rel
16844
+ };
16845
+ } catch (error) {
16846
+ return {
16847
+ ok: false,
16848
+ error: "读取日志失败:" + String(error)
16849
+ };
16850
+ }
16851
+ },
16852
+ "svn.diffRev": async (args) => {
16853
+ const sc = await requireSvnSession(ctx, args.sessionId);
16854
+ if ("err" in sc) return {
16855
+ ok: false,
16856
+ error: sc.err
15637
16857
  };
15638
- } catch (error) {
15639
- return {
16858
+ const rel = safeRel(args.path);
16859
+ if (rel === null || rel === "") return {
15640
16860
  ok: false,
15641
- error: "读取变更失败:" + String(error)
16861
+ error: "路径不合法"
15642
16862
  };
15643
- }
15644
- },
15645
- "svn.diffBase": async (args) => {
15646
- const sc = await requireSvnSession(ctx, args.sessionId);
15647
- if ("err" in sc) return {
15648
- ok: false,
15649
- error: sc.err
15650
- };
15651
- const rel = safeRel(args.path);
15652
- if (rel === null || rel === "") return {
15653
- ok: false,
15654
- error: "路径不合法"
15655
- };
15656
- const status = await statusOf(sc.cwd);
15657
- if (!status.managed || !status.wcRoot) return {
15658
- ok: false,
15659
- error: "当前工作区不受 SVN 管理"
15660
- };
15661
- if (!status.svnCli) return {
15662
- ok: false,
15663
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15664
- };
15665
- const fs = ctx.get("fs");
15666
- if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
15667
- ok: false,
15668
- error: "缺少 fs"
15669
- };
15670
- try {
15671
- const target = await fs.resolve(rel, { cwd: sc.cwd });
15672
- const info = await fs.stat(target);
15673
- if (!info || info.type !== "file") return {
16863
+ const revision = Number(args.revision);
16864
+ if (!Number.isFinite(revision) || revision < 1) return {
15674
16865
  ok: false,
15675
- error: "文件不存在"
16866
+ error: "版本号不合法"
15676
16867
  };
15677
- if ((info.size ?? 0) > READ_CAP) return {
16868
+ const status = await statusOf(sc.cwd);
16869
+ if (!status.managed || !status.wcRoot) return {
15678
16870
  ok: false,
15679
- error: "文件过大(>8MB),不支持基线差异"
16871
+ error: "当前工作区不受 SVN 管理"
15680
16872
  };
15681
- const working = await fs.readText(target);
15682
- const outcome = await runSvn(status.wcRoot, [
15683
- "cat",
15684
- "-r",
15685
- "BASE",
15686
- "--",
15687
- rel
15688
- ], READ_GRACE_MS);
15689
- if (outcome.code !== 0) {
15690
- const text = (outcome.stderr || outcome.stdout).trim();
16873
+ if (!status.svnCli) return {
16874
+ ok: false,
16875
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16876
+ };
16877
+ try {
16878
+ const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision));
15691
16879
  return {
15692
16880
  ok: true,
15693
- base: null,
15694
- working,
15695
- reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
15696
- error: tailOfText(text),
16881
+ ...result,
16882
+ ...guardSides(result.left, result.right)
16883
+ };
16884
+ } catch (error) {
16885
+ return {
16886
+ ok: false,
16887
+ error: "读取版本差异失败:" + String(error)
16888
+ };
16889
+ }
16890
+ },
16891
+ "svn.diffPair": async (args) => {
16892
+ const sc = await requireSvnSession(ctx, args.sessionId);
16893
+ if ("err" in sc) return {
16894
+ ok: false,
16895
+ error: sc.err
16896
+ };
16897
+ const rel = safeRel(args.path);
16898
+ if (rel === null || rel === "") return {
16899
+ ok: false,
16900
+ error: "路径不合法"
16901
+ };
16902
+ const revA = Number(args.revA);
16903
+ const revB = Number(args.revB);
16904
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
16905
+ ok: false,
16906
+ error: "版本号不合法"
16907
+ };
16908
+ const status = await statusOf(sc.cwd);
16909
+ if (!status.managed || !status.wcRoot) return {
16910
+ ok: false,
16911
+ error: "当前工作区不受 SVN 管理"
16912
+ };
16913
+ if (!status.svnCli) return {
16914
+ ok: false,
16915
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16916
+ };
16917
+ try {
16918
+ const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB));
16919
+ return {
16920
+ ok: true,
16921
+ ...result,
16922
+ ...guardSides(result.left, result.right)
16923
+ };
16924
+ } catch (error) {
16925
+ return {
16926
+ ok: false,
16927
+ error: "读取版本差异失败:" + String(error)
16928
+ };
16929
+ }
16930
+ },
16931
+ "svn.diffWorking": async (args) => {
16932
+ const sc = await requireSvnSession(ctx, args.sessionId);
16933
+ if ("err" in sc) return {
16934
+ ok: false,
16935
+ error: sc.err
16936
+ };
16937
+ const rel = safeRel(args.path);
16938
+ if (rel === null || rel === "") return {
16939
+ ok: false,
16940
+ error: "路径不合法"
16941
+ };
16942
+ const revision = Number(args.revision);
16943
+ if (!Number.isFinite(revision) || revision < 1) return {
16944
+ ok: false,
16945
+ error: "版本号不合法"
16946
+ };
16947
+ const status = await statusOf(sc.cwd);
16948
+ if (!status.managed || !status.wcRoot) return {
16949
+ ok: false,
16950
+ error: "当前工作区不受 SVN 管理"
16951
+ };
16952
+ if (!status.svnCli) return {
16953
+ ok: false,
16954
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16955
+ };
16956
+ const svnRel = svnPathOf(sc.cwd, status.wcRoot, rel);
16957
+ if (!svnRel) return {
16958
+ ok: false,
16959
+ error: "比较路径不在 SVN 工作副本内"
16960
+ };
16961
+ const fs = ctx.get("fs");
16962
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
16963
+ ok: false,
16964
+ error: "缺少 fs"
16965
+ };
16966
+ try {
16967
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
16968
+ const info = await fs.stat(resolved);
16969
+ if (!info || info.type !== "file") return {
16970
+ ok: false,
16971
+ error: "文件不存在"
16972
+ };
16973
+ if ((info.size ?? 0) > READ_CAP) return {
16974
+ ok: false,
16975
+ error: "文件过大(>8MB),不支持与工作副本比较"
16976
+ };
16977
+ const working = await fs.readText(resolved);
16978
+ const leftOutcome = await runSvn(status.wcRoot, [
16979
+ "cat",
16980
+ "-r",
16981
+ String(Math.floor(revision)),
16982
+ "--",
16983
+ svnRel
16984
+ ], READ_GRACE_MS);
16985
+ if (leftOutcome.code !== 0) return {
16986
+ ok: true,
16987
+ left: null,
16988
+ right: working,
16989
+ reason: "not-exist",
16990
+ error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim()),
15697
16991
  ...guardSides(null, working)
15698
16992
  };
16993
+ return {
16994
+ ok: true,
16995
+ left: leftOutcome.stdout,
16996
+ right: working,
16997
+ ...guardSides(leftOutcome.stdout, working)
16998
+ };
16999
+ } catch (error) {
17000
+ return {
17001
+ ok: false,
17002
+ error: "读取工作副本差异失败:" + String(error)
17003
+ };
15699
17004
  }
15700
- return {
15701
- ok: true,
15702
- base: outcome.stdout,
15703
- working,
15704
- ...guardSides(outcome.stdout, working)
17005
+ },
17006
+ "svn.patchText": async (args) => {
17007
+ const sc = await requireSvnSession(ctx, args.sessionId);
17008
+ if ("err" in sc) return {
17009
+ ok: false,
17010
+ error: sc.err
15705
17011
  };
15706
- } catch (error) {
15707
- return {
17012
+ const rel = safeRel(args.path);
17013
+ if (rel === null || rel === "") return {
15708
17014
  ok: false,
15709
- error: "读取基线失败:" + String(error)
17015
+ error: "路径不合法"
15710
17016
  };
15711
- }
15712
- },
15713
- "svn.revert": async (args) => {
15714
- return mutate("还原", ["revert", "-R"], args.sessionId, args.paths);
15715
- },
15716
- "svn.add": async (args) => {
15717
- return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
15718
- },
15719
- /**
15720
- * 分区(changelist)批量操作:关联或解除工作副本条目的 changelist 登记。
15721
- * 复用 mutate 全套护栏(路径白名单 / BATCH_PATHS_CAP / 超时 / 变更缓存作废);
15722
- * 分区名 host 权威校验(`-` 开头会被 svn 当成选项解析),remove 分支不需要名字。
15723
- * @author ddj 2026年09月23号
15724
- * @param args.paths 工作区相对路径列表
15725
- * @param args.name 目标分区名(remove 时忽略)
15726
- * @param args.remove true = 移出分区(`changelist --remove`)
15727
- * @returns 批量动作结果
15728
- */
15729
- "svn.changelist": async (args) => {
15730
- if (args.remove !== true) {
15731
- const nameError = svnChangelistNameErrorOf(String(args.name ?? ""));
15732
- if (nameError) return {
17017
+ const status = await statusOf(sc.cwd);
17018
+ if (!status.managed || !status.wcRoot) return {
15733
17019
  ok: false,
15734
- error: nameError
17020
+ error: "当前工作区不受 SVN 管理"
15735
17021
  };
15736
- }
15737
- return mutate(args.remove === true ? "移出分区" : "分区", args.remove === true ? ["changelist", "--remove"] : ["changelist", String(args.name ?? "").trim()], args.sessionId, args.paths);
15738
- },
15739
- "svn.log": async (args) => {
15740
- const sc = await requireSvnSession(ctx, args.sessionId);
15741
- if ("err" in sc) return {
15742
- ok: false,
15743
- error: sc.err
15744
- };
15745
- const rel = safeRel(args.path);
15746
- if (rel === null) return {
15747
- ok: false,
15748
- error: "路径不合法"
15749
- };
15750
- const status = await statusOf(sc.cwd);
15751
- if (!status.managed || !status.wcRoot) return {
15752
- ok: false,
15753
- error: "当前工作区不受 SVN 管理"
15754
- };
15755
- if (!status.svnCli) return {
15756
- ok: false,
15757
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15758
- };
15759
- try {
15760
- return {
15761
- ok: true,
15762
- ...await logOf(status.wcRoot, rel, args.limit ?? 100, {
15763
- stopOnCopy: args.stopOnCopy === true,
15764
- showMerged: args.showMerged === true,
15765
- range: args.startRev && args.endRev ? {
15766
- start: Math.floor(args.startRev),
15767
- end: Math.floor(args.endRev)
15768
- } : null
15769
- }, SVN_LOG_SHOW_ALL_CAP),
15770
- target: rel
17022
+ if (!status.svnCli) return {
17023
+ ok: false,
17024
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
15771
17025
  };
15772
- } catch (error) {
15773
- return {
17026
+ try {
17027
+ const ext = patchExtOf({
17028
+ whitespace: args.whitespace,
17029
+ ignoreEol: args.ignoreEol === true,
17030
+ unified: args.unified
17031
+ });
17032
+ const argv = [
17033
+ "diff",
17034
+ ...ext ? ["-x", ext] : [],
17035
+ "--",
17036
+ rel
17037
+ ];
17038
+ const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP);
17039
+ if (outcome.code !== 0) return {
17040
+ ok: false,
17041
+ error: "生成补丁失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17042
+ };
17043
+ const text = outcome.stdout;
17044
+ const head = text.slice(0, 200);
17045
+ return {
17046
+ ok: true,
17047
+ text,
17048
+ truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
17049
+ binary: text.includes("Cannot display:")
17050
+ };
17051
+ } catch (error) {
17052
+ return {
17053
+ ok: false,
17054
+ error: "生成补丁失败:" + String(error)
17055
+ };
17056
+ }
17057
+ },
17058
+ "svn.diffSum": async (args) => {
17059
+ const sc = await requireSvnSession(ctx, args.sessionId);
17060
+ if ("err" in sc) return {
15774
17061
  ok: false,
15775
- error: "读取日志失败:" + String(error)
17062
+ error: sc.err
15776
17063
  };
15777
- }
15778
- },
15779
- "svn.diffRev": async (args) => {
15780
- const sc = await requireSvnSession(ctx, args.sessionId);
15781
- if ("err" in sc) return {
15782
- ok: false,
15783
- error: sc.err
15784
- };
15785
- const rel = safeRel(args.path);
15786
- if (rel === null || rel === "") return {
15787
- ok: false,
15788
- error: "路径不合法"
15789
- };
15790
- const revision = Number(args.revision);
15791
- if (!Number.isFinite(revision) || revision < 1) return {
15792
- ok: false,
15793
- error: "版本号不合法"
15794
- };
15795
- const status = await statusOf(sc.cwd);
15796
- if (!status.managed || !status.wcRoot) return {
15797
- ok: false,
15798
- error: "当前工作区不受 SVN 管理"
15799
- };
15800
- if (!status.svnCli) return {
15801
- ok: false,
15802
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15803
- };
15804
- try {
15805
- const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision));
15806
- return {
15807
- ok: true,
15808
- ...result,
15809
- ...guardSides(result.left, result.right)
17064
+ const rel = safeRel(args.path);
17065
+ if (rel === null) return {
17066
+ ok: false,
17067
+ error: "路径不合法"
15810
17068
  };
15811
- } catch (error) {
15812
- return {
17069
+ const revA = Number(args.revA);
17070
+ const revB = Number(args.revB);
17071
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
15813
17072
  ok: false,
15814
- error: "读取版本差异失败:" + String(error)
17073
+ error: "版本号不合法"
15815
17074
  };
15816
- }
15817
- },
15818
- "svn.diffPair": async (args) => {
15819
- const sc = await requireSvnSession(ctx, args.sessionId);
15820
- if ("err" in sc) return {
15821
- ok: false,
15822
- error: sc.err
15823
- };
15824
- const rel = safeRel(args.path);
15825
- if (rel === null || rel === "") return {
15826
- ok: false,
15827
- error: "路径不合法"
15828
- };
15829
- const revA = Number(args.revA);
15830
- const revB = Number(args.revB);
15831
- if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
15832
- ok: false,
15833
- error: "版本号不合法"
15834
- };
15835
- const status = await statusOf(sc.cwd);
15836
- if (!status.managed || !status.wcRoot) return {
15837
- ok: false,
15838
- error: "当前工作区不受 SVN 管理"
15839
- };
15840
- if (!status.svnCli) return {
15841
- ok: false,
15842
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15843
- };
15844
- try {
15845
- const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB));
15846
- return {
15847
- ok: true,
15848
- ...result,
15849
- ...guardSides(result.left, result.right)
17075
+ const status = await statusOf(sc.cwd);
17076
+ if (!status.managed || !status.wcRoot) return {
17077
+ ok: false,
17078
+ error: "当前工作区不受 SVN 管理"
17079
+ };
17080
+ if (!status.svnCli) return {
17081
+ ok: false,
17082
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17083
+ };
17084
+ try {
17085
+ const argv = [
17086
+ "diff",
17087
+ "--summarize",
17088
+ "--xml",
17089
+ "-r",
17090
+ String(Math.floor(revA)) + ":" + String(Math.floor(revB))
17091
+ ];
17092
+ if (rel) argv.push("--", rel);
17093
+ const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP);
17094
+ if (outcome.code !== 0) return {
17095
+ ok: false,
17096
+ error: "目录对比失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17097
+ };
17098
+ return {
17099
+ ok: true,
17100
+ ...parseSummarizeXml(outcome.stdout, status.wcRoot)
17101
+ };
17102
+ } catch (error) {
17103
+ return {
17104
+ ok: false,
17105
+ error: "目录对比失败:" + String(error)
17106
+ };
17107
+ }
17108
+ },
17109
+ "svn.remoteStatus": async (args) => {
17110
+ const sc = await requireSvnSession(ctx, args.sessionId);
17111
+ if ("err" in sc) return {
17112
+ ok: false,
17113
+ error: sc.err
17114
+ };
17115
+ const rel = safeRel(args.path);
17116
+ if (rel === null) return {
17117
+ ok: false,
17118
+ error: "路径不合法"
17119
+ };
17120
+ const status = await statusOf(sc.cwd);
17121
+ if (!status.managed || !status.wcRoot) return {
17122
+ ok: false,
17123
+ error: "当前工作区不受 SVN 管理"
17124
+ };
17125
+ if (!status.svnCli) return {
17126
+ ok: false,
17127
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17128
+ };
17129
+ try {
17130
+ const argv = [
17131
+ "status",
17132
+ "-u",
17133
+ "--xml"
17134
+ ];
17135
+ if (rel) argv.push("--", rel);
17136
+ const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP);
17137
+ if (outcome.code !== 0) return {
17138
+ ok: false,
17139
+ error: "远端检查失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17140
+ };
17141
+ return {
17142
+ ok: true,
17143
+ ...parseRemoteStatusXml(outcome.stdout, status.wcRoot)
17144
+ };
17145
+ } catch (error) {
17146
+ return {
17147
+ ok: false,
17148
+ error: "远端检查失败:" + String(error)
17149
+ };
17150
+ }
17151
+ },
17152
+ "svn.conflictArtifacts": async (args) => {
17153
+ const sc = await requireSvnSession(ctx, args.sessionId);
17154
+ if ("err" in sc) return {
17155
+ ok: false,
17156
+ error: sc.err
17157
+ };
17158
+ const rel = safeRel(args.path);
17159
+ if (rel === null || rel === "") return {
17160
+ ok: false,
17161
+ error: "路径不合法"
17162
+ };
17163
+ const status = await statusOf(sc.cwd);
17164
+ if (!status.managed || !status.wcRoot) return {
17165
+ ok: false,
17166
+ error: "当前工作区不受 SVN 管理"
17167
+ };
17168
+ try {
17169
+ return {
17170
+ ok: true,
17171
+ artifacts: await conflictArtifactsOf(status.wcRoot, rel)
17172
+ };
17173
+ } catch (error) {
17174
+ return {
17175
+ ok: false,
17176
+ error: "扫描冲突副本失败:" + String(error)
17177
+ };
17178
+ }
17179
+ },
17180
+ "svn.diffLocalPair": async (args) => {
17181
+ const sc = await requireSvnSession(ctx, args.sessionId);
17182
+ if ("err" in sc) return {
17183
+ ok: false,
17184
+ error: sc.err
15850
17185
  };
15851
- } catch (error) {
15852
- return {
17186
+ const left = safeRel(args.left);
17187
+ const right = safeRel(args.right);
17188
+ if (!left || !right) return {
15853
17189
  ok: false,
15854
- error: "读取版本差异失败:" + String(error)
17190
+ error: "路径不合法(需工作副本内相对路径)"
15855
17191
  };
15856
- }
15857
- },
15858
- "svn.diffWorking": async (args) => {
15859
- const sc = await requireSvnSession(ctx, args.sessionId);
15860
- if ("err" in sc) return {
15861
- ok: false,
15862
- error: sc.err
15863
- };
15864
- const rel = safeRel(args.path);
15865
- if (rel === null || rel === "") return {
15866
- ok: false,
15867
- error: "路径不合法"
15868
- };
15869
- const revision = Number(args.revision);
15870
- if (!Number.isFinite(revision) || revision < 1) return {
15871
- ok: false,
15872
- error: "版本号不合法"
15873
- };
15874
- const status = await statusOf(sc.cwd);
15875
- if (!status.managed || !status.wcRoot) return {
15876
- ok: false,
15877
- error: "当前工作区不受 SVN 管理"
15878
- };
15879
- if (!status.svnCli) return {
15880
- ok: false,
15881
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15882
- };
15883
- const fs = ctx.get("fs");
15884
- if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
15885
- ok: false,
15886
- error: "缺少 fs"
15887
- };
15888
- try {
15889
- const resolved = await fs.resolve(rel, { cwd: sc.cwd });
15890
- const info = await fs.stat(resolved);
15891
- if (!info || info.type !== "file") return {
17192
+ const status = await statusOf(sc.cwd);
17193
+ if (!status.managed || !status.wcRoot) return {
15892
17194
  ok: false,
15893
- error: "文件不存在"
17195
+ error: "当前工作区不受 SVN 管理"
15894
17196
  };
15895
- if ((info.size ?? 0) > READ_CAP) return {
17197
+ const fs = ctx.get("fs");
17198
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
15896
17199
  ok: false,
15897
- error: "文件过大(>8MB),不支持与工作副本比较"
17200
+ error: "缺少 fs"
15898
17201
  };
15899
- const working = await fs.readText(resolved);
15900
- const leftOutcome = await runSvn(status.wcRoot, [
15901
- "cat",
15902
- "-r",
15903
- String(Math.floor(revision)),
15904
- "--",
15905
- rel
15906
- ], READ_GRACE_MS);
15907
- if (leftOutcome.code !== 0) return {
15908
- ok: true,
15909
- left: null,
15910
- right: working,
15911
- reason: "not-exist",
15912
- error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim()),
15913
- ...guardSides(null, working)
17202
+ try {
17203
+ const sides = [];
17204
+ for (const rel of [left, right]) {
17205
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
17206
+ const info = await fs.stat(resolved);
17207
+ if (!info || info.type !== "file") return {
17208
+ ok: false,
17209
+ error: "文件不存在:" + rel
17210
+ };
17211
+ if ((info.size ?? 0) > READ_CAP) return {
17212
+ ok: false,
17213
+ error: "文件过大(>8MB):" + rel
17214
+ };
17215
+ sides.push(await fs.readText(resolved));
17216
+ }
17217
+ return {
17218
+ ok: true,
17219
+ left: sides[0],
17220
+ right: sides[1],
17221
+ ...guardSides(sides[0], sides[1])
17222
+ };
17223
+ } catch (error) {
17224
+ return {
17225
+ ok: false,
17226
+ error: "读取冲突副本失败:" + String(error)
17227
+ };
17228
+ }
17229
+ },
17230
+ "svn.fileSizes": async (args) => {
17231
+ const sc = await requireSvnSession(ctx, args.sessionId);
17232
+ if ("err" in sc) return {
17233
+ ok: false,
17234
+ error: sc.err
15914
17235
  };
15915
- return {
17236
+ const list = Array.isArray(args.paths) ? args.paths.slice(0, 200) : [];
17237
+ if (!list.length) return {
15916
17238
  ok: true,
15917
- left: leftOutcome.stdout,
15918
- right: working,
15919
- ...guardSides(leftOutcome.stdout, working)
17239
+ sizes: []
15920
17240
  };
15921
- } catch (error) {
15922
- return {
17241
+ const fs = ctx.get("fs");
17242
+ if (!fs?.resolve || !fs?.stat) return {
15923
17243
  ok: false,
15924
- error: "读取工作副本差异失败:" + String(error)
17244
+ error: "缺少 fs"
15925
17245
  };
15926
- }
15927
- },
15928
- "svn.patchText": async (args) => {
15929
- const sc = await requireSvnSession(ctx, args.sessionId);
15930
- if ("err" in sc) return {
15931
- ok: false,
15932
- error: sc.err
15933
- };
15934
- const rel = safeRel(args.path);
15935
- if (rel === null || rel === "") return {
15936
- ok: false,
15937
- error: "路径不合法"
15938
- };
15939
- const status = await statusOf(sc.cwd);
15940
- if (!status.managed || !status.wcRoot) return {
15941
- ok: false,
15942
- error: "当前工作区不受 SVN 管理"
15943
- };
15944
- if (!status.svnCli) return {
15945
- ok: false,
15946
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
15947
- };
15948
- try {
15949
- const ext = patchExtOf({
15950
- whitespace: args.whitespace,
15951
- ignoreEol: args.ignoreEol === true,
15952
- unified: args.unified
15953
- });
15954
- const argv = [
15955
- "diff",
15956
- ...ext ? ["-x", ext] : [],
15957
- "--",
15958
- rel
15959
- ];
15960
- const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP);
15961
- if (outcome.code !== 0) return {
15962
- ok: false,
15963
- error: "生成补丁失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17246
+ const sizeOne = async (raw) => {
17247
+ const rel = safeRel(raw);
17248
+ if (rel === null || rel === "") return {
17249
+ path: String(raw ?? ""),
17250
+ size: null
17251
+ };
17252
+ try {
17253
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
17254
+ const info = await fs.stat(resolved);
17255
+ return {
17256
+ path: rel,
17257
+ size: info && info.type === "file" ? info.size ?? null : null
17258
+ };
17259
+ } catch {
17260
+ return {
17261
+ path: rel,
17262
+ size: null
17263
+ };
17264
+ }
15964
17265
  };
15965
- const text = outcome.stdout;
15966
- const head = text.slice(0, 200);
17266
+ const sizes = [];
17267
+ const BATCH = 16;
17268
+ for (let start = 0; start < list.length; start += BATCH) sizes.push(...await Promise.all(list.slice(start, start + BATCH).map(sizeOne)));
15967
17269
  return {
15968
17270
  ok: true,
15969
- text,
15970
- truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
15971
- binary: text.includes("Cannot display:")
17271
+ sizes
15972
17272
  };
15973
- } catch (error) {
15974
- return {
17273
+ },
17274
+ /**
17275
+ * AI 智能整理分析(只读):拉最新变更 → 附 diff 上下文(超限降级 paths-only)→
17276
+ * LLM 流式产出 JSON → normalizeAiPlan 以真实清单白名单归一。任何失败零执行只报错。
17277
+ * @author ddj 2026年09月23号
17278
+ * @param args.sessionId 会话 id
17279
+ * @returns 三段方案 + 分析元信息(条目数/是否含 diff/丢弃数/模型)
17280
+ */
17281
+ "svn.aiPlan": async (args) => {
17282
+ const sc = await requireSvnSession(ctx, args.sessionId);
17283
+ if ("err" in sc) return {
15975
17284
  ok: false,
15976
- error: "生成补丁失败:" + String(error)
17285
+ error: sc.err
15977
17286
  };
15978
- }
15979
- },
15980
- "svn.diffSum": async (args) => {
15981
- const sc = await requireSvnSession(ctx, args.sessionId);
15982
- if ("err" in sc) return {
15983
- ok: false,
15984
- error: sc.err
15985
- };
15986
- const rel = safeRel(args.path);
15987
- if (rel === null) return {
15988
- ok: false,
15989
- error: "路径不合法"
15990
- };
15991
- const revA = Number(args.revA);
15992
- const revB = Number(args.revB);
15993
- if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
15994
- ok: false,
15995
- error: "版本号不合法"
15996
- };
15997
- const status = await statusOf(sc.cwd);
15998
- if (!status.managed || !status.wcRoot) return {
15999
- ok: false,
16000
- error: "当前工作区不受 SVN 管理"
16001
- };
16002
- if (!status.svnCli) return {
16003
- ok: false,
16004
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16005
- };
16006
- try {
16007
- const argv = [
16008
- "diff",
16009
- "--summarize",
16010
- "--xml",
16011
- "-r",
16012
- String(Math.floor(revA)) + ":" + String(Math.floor(revB))
16013
- ];
16014
- if (rel) argv.push("--", rel);
16015
- const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP);
16016
- if (outcome.code !== 0) return {
17287
+ const status = await statusOf(sc.cwd);
17288
+ if (!status.managed || !status.wcRoot) return {
16017
17289
  ok: false,
16018
- error: "目录对比失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17290
+ error: "当前工作区不受 SVN 管理"
16019
17291
  };
16020
- return {
16021
- ok: true,
16022
- ...parseSummarizeXml(outcome.stdout, status.wcRoot)
17292
+ if (!status.svnCli) return {
17293
+ ok: false,
17294
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16023
17295
  };
16024
- } catch (error) {
16025
- return {
17296
+ try {
17297
+ const { entries } = await changesOf(status.wcRoot, true);
17298
+ let diffText = null;
17299
+ try {
17300
+ const outcome = await runSvn(status.wcRoot, [
17301
+ "diff",
17302
+ "-x",
17303
+ "-U0"
17304
+ ], READ_GRACE_MS, AI_PLAN_DIFF_CAP);
17305
+ if (outcome.code === 0 && outcome.stdout.trim() && outcome.stdout.length < 2097152) diffText = outcome.stdout;
17306
+ } catch {}
17307
+ const capped = entries.length > AI_PLAN_PATHS_CAP;
17308
+ const used = capped ? entries.slice(0, AI_PLAN_PATHS_CAP) : entries;
17309
+ const diffIncluded = diffText !== null && !capped;
17310
+ const prompt = buildAiPrompt(used, diffIncluded ? diffText : null, capped);
17311
+ const cfg = settings.ai ? settings.ai() : {
17312
+ enabled: true,
17313
+ provider: "",
17314
+ model: "",
17315
+ effort: ""
17316
+ };
17317
+ const result = await svnAiPlanOf(ctx, cfg, prompt);
17318
+ const { plan, dropped } = normalizeAiPlan(result.raw, entries);
17319
+ return {
17320
+ ok: true,
17321
+ plan,
17322
+ entriesCount: entries.length,
17323
+ diffIncluded,
17324
+ dropped,
17325
+ model: result.model
17326
+ };
17327
+ } catch (error) {
17328
+ return {
17329
+ ok: false,
17330
+ error: String(error instanceof Error ? error.message : error)
17331
+ };
17332
+ }
17333
+ },
17334
+ /**
17335
+ * AI 智能整理执行段③:按目录合并写入 svn:ignore 属性(propget 合并,不整体覆盖)。
17336
+ * 复用受管理/svnCli 校验与变更缓存作废语义(对齐 mutate)。
17337
+ * @author ddj 2026年09月23号
17338
+ * @param args.items 目录聚合写入项(dir + 该目录下新增忽略名)
17339
+ * @returns 新增计数/摘要/原文
17340
+ */
17341
+ "svn.ignore": async (args) => {
17342
+ const sc = await requireSvnSession(ctx, args.sessionId);
17343
+ if ("err" in sc) return {
16026
17344
  ok: false,
16027
- error: "目录对比失败:" + String(error)
17345
+ error: sc.err
16028
17346
  };
16029
- }
16030
- },
16031
- "svn.remoteStatus": async (args) => {
16032
- const sc = await requireSvnSession(ctx, args.sessionId);
16033
- if ("err" in sc) return {
16034
- ok: false,
16035
- error: sc.err
16036
- };
16037
- const rel = safeRel(args.path);
16038
- if (rel === null) return {
16039
- ok: false,
16040
- error: "路径不合法"
16041
- };
16042
- const status = await statusOf(sc.cwd);
16043
- if (!status.managed || !status.wcRoot) return {
16044
- ok: false,
16045
- error: "当前工作区不受 SVN 管理"
16046
- };
16047
- if (!status.svnCli) return {
16048
- ok: false,
16049
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16050
- };
16051
- try {
16052
- const argv = [
16053
- "status",
16054
- "-u",
16055
- "--xml"
16056
- ];
16057
- if (rel) argv.push("--", rel);
16058
- const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP);
16059
- if (outcome.code !== 0) return {
17347
+ const items = Array.isArray(args.items) ? args.items : [];
17348
+ if (!items.length) return {
16060
17349
  ok: false,
16061
- error: "远端检查失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17350
+ error: "未选择任何路径"
16062
17351
  };
16063
- return {
16064
- ok: true,
16065
- ...parseRemoteStatusXml(outcome.stdout, status.wcRoot)
17352
+ const clean = [];
17353
+ let total = 0;
17354
+ for (const item of items) {
17355
+ const rel = safeRel(item?.dir);
17356
+ if (rel === null) return {
17357
+ ok: false,
17358
+ error: "路径不合法"
17359
+ };
17360
+ const names = Array.isArray(item?.names) ? item.names : [];
17361
+ if (!names.length) return {
17362
+ ok: false,
17363
+ error: "忽略名列表为空"
17364
+ };
17365
+ const checked = [];
17366
+ for (const name of names) {
17367
+ const nameError = ignoreNameErrorOf(name);
17368
+ if (nameError) return {
17369
+ ok: false,
17370
+ error: nameError
17371
+ };
17372
+ checked.push(String(name).trim());
17373
+ }
17374
+ total += checked.length;
17375
+ if (total > 64) return {
17376
+ ok: false,
17377
+ error: "一次最多处理 64 个路径"
17378
+ };
17379
+ clean.push({
17380
+ dir: rel === "" ? "." : rel,
17381
+ names: checked
17382
+ });
17383
+ }
17384
+ const status = await statusOf(sc.cwd);
17385
+ if (!status.managed || !status.wcRoot) return {
17386
+ ok: false,
17387
+ error: "当前工作区不受 SVN 管理"
16066
17388
  };
16067
- } catch (error) {
16068
- return {
17389
+ if (!status.svnCli) return {
16069
17390
  ok: false,
16070
- error: "远端检查失败:" + String(error)
17391
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16071
17392
  };
16072
- }
16073
- },
16074
- "svn.conflictArtifacts": async (args) => {
16075
- const sc = await requireSvnSession(ctx, args.sessionId);
16076
- if ("err" in sc) return {
16077
- ok: false,
16078
- error: sc.err
16079
- };
16080
- const rel = safeRel(args.path);
16081
- if (rel === null || rel === "") return {
16082
- ok: false,
16083
- error: "路径不合法"
16084
- };
16085
- const status = await statusOf(sc.cwd);
16086
- if (!status.managed || !status.wcRoot) return {
16087
- ok: false,
16088
- error: "当前工作区不受 SVN 管理"
16089
- };
16090
- try {
16091
- return {
16092
- ok: true,
16093
- artifacts: await conflictArtifactsOf(status.wcRoot, rel)
17393
+ try {
17394
+ const result = await ignoreApplyOf(status.wcRoot, clean);
17395
+ changesCache.delete(status.wcRoot);
17396
+ if (!result.ok) return {
17397
+ ok: false,
17398
+ error: result.summary + (result.output ? "\n" + tailOfText(result.output) : "")
17399
+ };
17400
+ return {
17401
+ ok: true,
17402
+ count: result.count,
17403
+ summary: result.summary,
17404
+ output: result.output
17405
+ };
17406
+ } catch (error) {
17407
+ return {
17408
+ ok: false,
17409
+ error: "svn:ignore 写入失败:" + String(error)
17410
+ };
17411
+ }
17412
+ },
17413
+ /**
17414
+ * 混合通道投递口(01-hybrid-deep-analysis):会话 agent 深度分析后 POST 方案入收件箱。
17415
+ * 只读 + 内存态:经 normalizeAiPlan 以最新变更清单白名单归一(幻觉路径丢弃计数),
17416
+ * 零接受拒收(防 agent 空转误报成功);覆盖式入箱(新投递顶掉旧件)。
17417
+ * @author ddj 2026年09月23号
17418
+ * @param args.plan agent 产出的方案 JSON(形状宽松,归一兜底)
17419
+ * @returns 接受路径数与丢弃数
17420
+ */
17421
+ "svn.aiPlanSubmit": async (args) => {
17422
+ const sc = await requireSvnSession(ctx, args.sessionId);
17423
+ if ("err" in sc) return {
17424
+ ok: false,
17425
+ error: sc.err
16094
17426
  };
16095
- } catch (error) {
16096
- return {
17427
+ const status = await statusOf(sc.cwd);
17428
+ if (!status.managed || !status.wcRoot) return {
16097
17429
  ok: false,
16098
- error: "扫描冲突副本失败:" + String(error)
17430
+ error: "当前工作区不受 SVN 管理"
16099
17431
  };
16100
- }
16101
- },
16102
- "svn.diffLocalPair": async (args) => {
16103
- const sc = await requireSvnSession(ctx, args.sessionId);
16104
- if ("err" in sc) return {
16105
- ok: false,
16106
- error: sc.err
16107
- };
16108
- const left = safeRel(args.left);
16109
- const right = safeRel(args.right);
16110
- if (!left || !right) return {
16111
- ok: false,
16112
- error: "路径不合法(需工作副本内相对路径)"
16113
- };
16114
- const status = await statusOf(sc.cwd);
16115
- if (!status.managed || !status.wcRoot) return {
16116
- ok: false,
16117
- error: "当前工作区不受 SVN 管理"
16118
- };
16119
- const fs = ctx.get("fs");
16120
- if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
16121
- ok: false,
16122
- error: "缺少 fs"
16123
- };
16124
- try {
16125
- const sides = [];
16126
- for (const rel of [left, right]) {
16127
- const resolved = await fs.resolve(rel, { cwd: sc.cwd });
16128
- const info = await fs.stat(resolved);
16129
- if (!info || info.type !== "file") return {
17432
+ if (!status.svnCli) return {
17433
+ ok: false,
17434
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17435
+ };
17436
+ try {
17437
+ const { entries } = await changesOf(status.wcRoot, true);
17438
+ const { plan, dropped } = normalizeAiPlan(args.plan, entries);
17439
+ const accepted = plan.reverts.length + plan.ignores.length + plan.groups.reduce((n, group) => n + group.paths.length, 0);
17440
+ if (!accepted) return {
16130
17441
  ok: false,
16131
- error: "文件不存在:" + rel
17442
+ error: "方案为空:无任何路径通过白名单校验(丢弃 " + dropped + " 项)"
16132
17443
  };
16133
- if ((info.size ?? 0) > READ_CAP) return {
17444
+ planInbox.set(status.wcRoot, {
17445
+ plan,
17446
+ dropped,
17447
+ at: now()
17448
+ });
17449
+ return {
17450
+ ok: true,
17451
+ accepted,
17452
+ dropped
17453
+ };
17454
+ } catch (error) {
17455
+ return {
16134
17456
  ok: false,
16135
- error: "文件过大(>8MB):" + rel
17457
+ error: "方案投递失败:" + String(error)
16136
17458
  };
16137
- sides.push(await fs.readText(resolved));
16138
17459
  }
16139
- return {
17460
+ },
17461
+ /**
17462
+ * 混合通道取件口:面板轮询取收件箱方案。
17463
+ * since = 注入时刻——早于它的旧投递不算新件(防上一轮残留被误认为本轮结果);
17464
+ * TTL 过期返回 plan:null。非破坏性读取(重复取到同一份由 client 侧取件即停消化)。
17465
+ * @author ddj 2026年09月23号
17466
+ * @param args.since 注入时刻时间戳(缺省 0 = 取任意件)
17467
+ * @returns 方案与元信息(无新件 plan 为 null)
17468
+ */
17469
+ "svn.aiPlanPending": async (args) => {
17470
+ const sc = await requireSvnSession(ctx, args.sessionId);
17471
+ if ("err" in sc) return {
17472
+ ok: false,
17473
+ error: sc.err
17474
+ };
17475
+ const status = await statusOf(sc.cwd);
17476
+ if (!status.managed || !status.wcRoot) return {
17477
+ ok: false,
17478
+ error: "当前工作区不受 SVN 管理"
17479
+ };
17480
+ const hit = planInbox.get(status.wcRoot);
17481
+ const since = Number(args.since) || 0;
17482
+ if (!hit || hit.at < since || now() - hit.at > 18e5) return {
16140
17483
  ok: true,
16141
- left: sides[0],
16142
- right: sides[1],
16143
- ...guardSides(sides[0], sides[1])
17484
+ plan: null,
17485
+ dropped: 0,
17486
+ at: 0
16144
17487
  };
16145
- } catch (error) {
16146
17488
  return {
17489
+ ok: true,
17490
+ plan: hit.plan,
17491
+ dropped: hit.dropped,
17492
+ at: hit.at
17493
+ };
17494
+ },
17495
+ "svn.wcRev": async (args) => {
17496
+ const sc = await requireSvnSession(ctx, args.sessionId);
17497
+ if ("err" in sc) return {
16147
17498
  ok: false,
16148
- error: "读取冲突副本失败:" + String(error)
17499
+ error: sc.err
16149
17500
  };
16150
- }
16151
- },
16152
- "svn.fileSizes": async (args) => {
16153
- const sc = await requireSvnSession(ctx, args.sessionId);
16154
- if ("err" in sc) return {
16155
- ok: false,
16156
- error: sc.err
16157
- };
16158
- const list = Array.isArray(args.paths) ? args.paths.slice(0, 200) : [];
16159
- if (!list.length) return {
16160
- ok: true,
16161
- sizes: []
16162
- };
16163
- const fs = ctx.get("fs");
16164
- if (!fs?.resolve || !fs?.stat) return {
16165
- ok: false,
16166
- error: "缺少 fs"
16167
- };
16168
- const sizeOne = async (raw) => {
16169
- const rel = safeRel(raw);
17501
+ const rel = safeRel(args.path);
16170
17502
  if (rel === null || rel === "") return {
16171
- path: String(raw ?? ""),
16172
- size: null
17503
+ ok: true,
17504
+ revision: null
17505
+ };
17506
+ const status = await statusOf(sc.cwd);
17507
+ if (!status.managed || !status.wcRoot) return {
17508
+ ok: false,
17509
+ error: "当前工作区不受 SVN 管理"
17510
+ };
17511
+ if (!status.svnCli) return {
17512
+ ok: false,
17513
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17514
+ };
17515
+ const outcome = await runSvn(status.wcRoot, [
17516
+ "info",
17517
+ "--xml",
17518
+ "--",
17519
+ rel
17520
+ ], READ_GRACE_MS);
17521
+ if (outcome.code !== 0) return {
17522
+ ok: true,
17523
+ revision: null
17524
+ };
17525
+ return {
17526
+ ok: true,
17527
+ revision: parseSvnInfoRevision(outcome.stdout)
17528
+ };
17529
+ },
17530
+ "svn.cleanup": async (args) => {
17531
+ const sc = await requireSvnSession(ctx, args.sessionId);
17532
+ if ("err" in sc) return {
17533
+ ok: false,
17534
+ error: sc.err
17535
+ };
17536
+ const status = await statusOf(sc.cwd);
17537
+ if (!status.managed || !status.wcRoot) return {
17538
+ ok: false,
17539
+ error: "当前工作区不受 SVN 管理"
17540
+ };
17541
+ if (!status.svnCli) return {
17542
+ ok: false,
17543
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16173
17544
  };
17545
+ const argv = ["cleanup"];
17546
+ if (args.removeUnversioned === true) argv.push("--remove-unversioned");
17547
+ if (args.removeIgnored === true) argv.push("--remove-ignored");
16174
17548
  try {
16175
- const resolved = await fs.resolve(rel, { cwd: sc.cwd });
16176
- const info = await fs.stat(resolved);
17549
+ const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
17550
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
17551
+ if (outcome.code !== 0) return {
17552
+ ok: false,
17553
+ error: "清理失败:" + tailOfText(result.output)
17554
+ };
17555
+ changesCache.delete(status.wcRoot);
16177
17556
  return {
16178
- path: rel,
16179
- size: info && info.type === "file" ? info.size ?? null : null
17557
+ ok: true,
17558
+ ...result
16180
17559
  };
16181
- } catch {
17560
+ } catch (error) {
16182
17561
  return {
16183
- path: rel,
16184
- size: null
17562
+ ok: false,
17563
+ error: "清理失败:" + String(error)
16185
17564
  };
16186
17565
  }
16187
- };
16188
- const sizes = [];
16189
- const BATCH = 16;
16190
- for (let start = 0; start < list.length; start += BATCH) sizes.push(...await Promise.all(list.slice(start, start + BATCH).map(sizeOne)));
16191
- return {
16192
- ok: true,
16193
- sizes
16194
- };
16195
- },
16196
- /**
16197
- * AI 智能整理分析(只读):拉最新变更 → 附 diff 上下文(超限降级 paths-only)→
16198
- * LLM 流式产出 JSON → normalizeAiPlan 以真实清单白名单归一。任何失败零执行只报错。
16199
- * @author ddj 2026年09月23号
16200
- * @param args.sessionId 会话 id
16201
- * @returns 三段方案 + 分析元信息(条目数/是否含 diff/丢弃数/模型)
16202
- */
16203
- "svn.aiPlan": async (args) => {
16204
- const sc = await requireSvnSession(ctx, args.sessionId);
16205
- if ("err" in sc) return {
16206
- ok: false,
16207
- error: sc.err
16208
- };
16209
- const status = await statusOf(sc.cwd);
16210
- if (!status.managed || !status.wcRoot) return {
16211
- ok: false,
16212
- error: "当前工作区不受 SVN 管理"
16213
- };
16214
- if (!status.svnCli) return {
16215
- ok: false,
16216
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16217
- };
16218
- try {
16219
- const { entries } = await changesOf(status.wcRoot, true);
16220
- let diffText = null;
16221
- try {
16222
- const outcome = await runSvn(status.wcRoot, [
16223
- "diff",
16224
- "-x",
16225
- "-U0"
16226
- ], READ_GRACE_MS, AI_PLAN_DIFF_CAP);
16227
- if (outcome.code === 0 && outcome.stdout.trim() && outcome.stdout.length < 2097152) diffText = outcome.stdout;
16228
- } catch {}
16229
- const capped = entries.length > AI_PLAN_PATHS_CAP;
16230
- const used = capped ? entries.slice(0, AI_PLAN_PATHS_CAP) : entries;
16231
- const diffIncluded = diffText !== null && !capped;
16232
- const prompt = buildAiPrompt(used, diffIncluded ? diffText : null, capped);
16233
- const cfg = settings.ai ? settings.ai() : {
16234
- enabled: true,
16235
- provider: "",
16236
- model: "",
16237
- effort: ""
16238
- };
16239
- const result = await svnAiPlanOf(ctx, cfg, prompt);
16240
- const { plan, dropped } = normalizeAiPlan(result.raw, entries);
16241
- return {
16242
- ok: true,
16243
- plan,
16244
- entriesCount: entries.length,
16245
- diffIncluded,
16246
- dropped,
16247
- model: result.model
16248
- };
16249
- } catch (error) {
16250
- return {
17566
+ },
17567
+ "svn.update": async (args) => {
17568
+ const sc = await requireSvnSession(ctx, args.sessionId);
17569
+ if ("err" in sc) return {
16251
17570
  ok: false,
16252
- error: String(error instanceof Error ? error.message : error)
17571
+ error: sc.err
16253
17572
  };
16254
- }
16255
- },
16256
- /**
16257
- * AI 智能整理执行段③:按目录合并写入 svn:ignore 属性(propget 合并,不整体覆盖)。
16258
- * 复用受管理/svnCli 校验与变更缓存作废语义(对齐 mutate)。
16259
- * @author ddj 2026年09月23号
16260
- * @param args.items 目录聚合写入项(dir + 该目录下新增忽略名)
16261
- * @returns 新增计数/摘要/原文
16262
- */
16263
- "svn.ignore": async (args) => {
16264
- const sc = await requireSvnSession(ctx, args.sessionId);
16265
- if ("err" in sc) return {
16266
- ok: false,
16267
- error: sc.err
16268
- };
16269
- const items = Array.isArray(args.items) ? args.items : [];
16270
- if (!items.length) return {
16271
- ok: false,
16272
- error: "未选择任何路径"
16273
- };
16274
- const clean = [];
16275
- let total = 0;
16276
- for (const item of items) {
16277
- const rel = safeRel(item?.dir);
17573
+ const rel = safeRel(args.path);
16278
17574
  if (rel === null) return {
16279
17575
  ok: false,
16280
17576
  error: "路径不合法"
16281
17577
  };
16282
- const names = Array.isArray(item?.names) ? item.names : [];
16283
- if (!names.length) return {
17578
+ const status = await statusOf(sc.cwd);
17579
+ if (!status.managed) return {
16284
17580
  ok: false,
16285
- error: "忽略名列表为空"
17581
+ error: "当前工作区不受 SVN 管理"
16286
17582
  };
16287
- const checked = [];
16288
- for (const name of names) {
16289
- const nameError = ignoreNameErrorOf(name);
16290
- if (nameError) return {
17583
+ if (!status.svnCli) return {
17584
+ ok: false,
17585
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17586
+ };
17587
+ try {
17588
+ const outcome = await runSvn(sc.cwd, [
17589
+ "update",
17590
+ "--",
17591
+ rel || "."
17592
+ ], UPDATE_GRACE_MS);
17593
+ const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
17594
+ if (outcome.code !== 0) return {
16291
17595
  ok: false,
16292
- error: nameError
17596
+ error: "更新失败:" + tailOfText(result.output)
17597
+ };
17598
+ return {
17599
+ ok: true,
17600
+ ...result
17601
+ };
17602
+ } catch (error) {
17603
+ return {
17604
+ ok: false,
17605
+ error: "更新失败:" + String(error)
16293
17606
  };
16294
- checked.push(String(name).trim());
16295
17607
  }
16296
- total += checked.length;
16297
- if (total > 64) return {
17608
+ },
17609
+ "svn.updateStart": async (args) => {
17610
+ const scope = await updateScope(args.sessionId, true);
17611
+ if ("err" in scope) return {
16298
17612
  ok: false,
16299
- error: "一次最多处理 64 个路径"
17613
+ error: scope.err
16300
17614
  };
16301
- clean.push({
16302
- dir: rel === "" ? "." : rel,
16303
- names: checked
16304
- });
16305
- }
16306
- const status = await statusOf(sc.cwd);
16307
- if (!status.managed || !status.wcRoot) return {
16308
- ok: false,
16309
- error: "当前工作区不受 SVN 管理"
16310
- };
16311
- if (!status.svnCli) return {
16312
- ok: false,
16313
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16314
- };
16315
- try {
16316
- const result = await ignoreApplyOf(status.wcRoot, clean);
16317
- changesCache.delete(status.wcRoot);
16318
- if (!result.ok) return {
17615
+ if (!scope.cli) return {
16319
17616
  ok: false,
16320
- error: result.summary + (result.output ? "\n" + tailOfText(result.output) : "")
16321
- };
16322
- return {
16323
- ok: true,
16324
- count: result.count,
16325
- summary: result.summary,
16326
- output: result.output
17617
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16327
17618
  };
16328
- } catch (error) {
16329
- return {
17619
+ const rel = safeRel(args.path);
17620
+ if (rel === null) return {
16330
17621
  ok: false,
16331
- error: "svn:ignore 写入失败:" + String(error)
17622
+ error: "路径不合法"
16332
17623
  };
16333
- }
16334
- },
16335
- /**
16336
- * 混合通道投递口(01-hybrid-deep-analysis):会话 agent 深度分析后 POST 方案入收件箱。
16337
- * 只读 + 内存态:经 normalizeAiPlan 以最新变更清单白名单归一(幻觉路径丢弃计数),
16338
- * 零接受拒收(防 agent 空转误报成功);覆盖式入箱(新投递顶掉旧件)。
16339
- * @author ddj 2026年09月23号
16340
- * @param args.plan agent 产出的方案 JSON(形状宽松,归一兜底)
16341
- * @returns 接受路径数与丢弃数
16342
- */
16343
- "svn.aiPlanSubmit": async (args) => {
16344
- const sc = await requireSvnSession(ctx, args.sessionId);
16345
- if ("err" in sc) return {
16346
- ok: false,
16347
- error: sc.err
16348
- };
16349
- const status = await statusOf(sc.cwd);
16350
- if (!status.managed || !status.wcRoot) return {
16351
- ok: false,
16352
- error: "当前工作区不受 SVN 管理"
16353
- };
16354
- if (!status.svnCli) return {
16355
- ok: false,
16356
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16357
- };
16358
- try {
16359
- const { entries } = await changesOf(status.wcRoot, true);
16360
- const { plan, dropped } = normalizeAiPlan(args.plan, entries);
16361
- const accepted = plan.reverts.length + plan.ignores.length + plan.groups.reduce((n, group) => n + group.paths.length, 0);
16362
- if (!accepted) return {
17624
+ try {
17625
+ const fs = ctx.get("fs");
17626
+ const cwd = fs?.resolve && fs?.processPath ? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd;
17627
+ return {
17628
+ ok: true,
17629
+ ...updateJobs.start({
17630
+ cwd,
17631
+ root: scope.root,
17632
+ path: rel,
17633
+ exe: scope.exe
17634
+ })
17635
+ };
17636
+ } catch (error) {
17637
+ return {
17638
+ ok: false,
17639
+ error: "启动 SVN 更新失败:" + String(error)
17640
+ };
17641
+ }
17642
+ },
17643
+ "svn.updatePoll": async (args) => {
17644
+ const scope = await updateScope(args.sessionId);
17645
+ if ("err" in scope) return {
16363
17646
  ok: false,
16364
- error: "方案为空:无任何路径通过白名单校验(丢弃 " + dropped + " 项)"
16365
- };
16366
- planInbox.set(status.wcRoot, {
16367
- plan,
16368
- dropped,
16369
- at: now()
16370
- });
16371
- return {
16372
- ok: true,
16373
- accepted,
16374
- dropped
17647
+ error: scope.err
16375
17648
  };
16376
- } catch (error) {
16377
- return {
17649
+ try {
17650
+ return {
17651
+ ok: true,
17652
+ ...updateJobs.poll(scope.root, args.jobId, args.since)
17653
+ };
17654
+ } catch (error) {
17655
+ return {
17656
+ ok: false,
17657
+ error: String(error)
17658
+ };
17659
+ }
17660
+ },
17661
+ "svn.updateCancel": async (args) => {
17662
+ const scope = await updateScope(args.sessionId);
17663
+ if ("err" in scope) return {
16378
17664
  ok: false,
16379
- error: "方案投递失败:" + String(error)
17665
+ error: scope.err
16380
17666
  };
16381
- }
16382
- },
16383
- /**
16384
- * 混合通道取件口:面板轮询取收件箱方案。
16385
- * since = 注入时刻——早于它的旧投递不算新件(防上一轮残留被误认为本轮结果);
16386
- * TTL 过期返回 plan:null。非破坏性读取(重复取到同一份由 client 侧取件即停消化)。
16387
- * @author ddj 2026年09月23号
16388
- * @param args.since 注入时刻时间戳(缺省 0 = 取任意件)
16389
- * @returns 方案与元信息(无新件 plan 为 null)
16390
- */
16391
- "svn.aiPlanPending": async (args) => {
16392
- const sc = await requireSvnSession(ctx, args.sessionId);
16393
- if ("err" in sc) return {
16394
- ok: false,
16395
- error: sc.err
16396
- };
16397
- const status = await statusOf(sc.cwd);
16398
- if (!status.managed || !status.wcRoot) return {
16399
- ok: false,
16400
- error: "当前工作区不受 SVN 管理"
16401
- };
16402
- const hit = planInbox.get(status.wcRoot);
16403
- const since = Number(args.since) || 0;
16404
- if (!hit || hit.at < since || now() - hit.at > 18e5) return {
16405
- ok: true,
16406
- plan: null,
16407
- dropped: 0,
16408
- at: 0
16409
- };
16410
- return {
16411
- ok: true,
16412
- plan: hit.plan,
16413
- dropped: hit.dropped,
16414
- at: hit.at
16415
- };
16416
- },
16417
- "svn.wcRev": async (args) => {
16418
- const sc = await requireSvnSession(ctx, args.sessionId);
16419
- if ("err" in sc) return {
16420
- ok: false,
16421
- error: sc.err
16422
- };
16423
- const rel = safeRel(args.path);
16424
- if (rel === null || rel === "") return {
16425
- ok: true,
16426
- revision: null
16427
- };
16428
- const status = await statusOf(sc.cwd);
16429
- if (!status.managed || !status.wcRoot) return {
16430
- ok: false,
16431
- error: "当前工作区不受 SVN 管理"
16432
- };
16433
- if (!status.svnCli) return {
16434
- ok: false,
16435
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16436
- };
16437
- const outcome = await runSvn(status.wcRoot, [
16438
- "info",
16439
- "--xml",
16440
- "--",
16441
- rel
16442
- ], READ_GRACE_MS);
16443
- if (outcome.code !== 0) return {
16444
- ok: true,
16445
- revision: null
16446
- };
16447
- return {
16448
- ok: true,
16449
- revision: parseSvnInfoRevision(outcome.stdout)
16450
- };
16451
- },
16452
- "svn.cleanup": async (args) => {
16453
- const sc = await requireSvnSession(ctx, args.sessionId);
16454
- if ("err" in sc) return {
16455
- ok: false,
16456
- error: sc.err
16457
- };
16458
- const status = await statusOf(sc.cwd);
16459
- if (!status.managed || !status.wcRoot) return {
16460
- ok: false,
16461
- error: "当前工作区不受 SVN 管理"
16462
- };
16463
- if (!status.svnCli) return {
16464
- ok: false,
16465
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16466
- };
16467
- const argv = ["cleanup"];
16468
- if (args.removeUnversioned === true) argv.push("--remove-unversioned");
16469
- if (args.removeIgnored === true) argv.push("--remove-ignored");
16470
- try {
16471
- const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
16472
- const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
16473
- if (outcome.code !== 0) return {
17667
+ try {
17668
+ return {
17669
+ ok: true,
17670
+ ...updateJobs.cancel(scope.root, args.jobId)
17671
+ };
17672
+ } catch (error) {
17673
+ return {
17674
+ ok: false,
17675
+ error: String(error)
17676
+ };
17677
+ }
17678
+ },
17679
+ "svn.updateActive": async (args) => {
17680
+ const scope = await updateScope(args.sessionId);
17681
+ if ("err" in scope) return {
16474
17682
  ok: false,
16475
- error: "清理失败:" + tailOfText(result.output)
17683
+ error: scope.err
16476
17684
  };
16477
- changesCache.delete(status.wcRoot);
16478
17685
  return {
16479
17686
  ok: true,
16480
- ...result
17687
+ job: updateJobs.active(scope.root)
16481
17688
  };
16482
- } catch (error) {
16483
- return {
17689
+ },
17690
+ "svn.tortoise": async (args) => {
17691
+ const sc = await requireSvnSession(ctx, args.sessionId);
17692
+ if ("err" in sc) return {
16484
17693
  ok: false,
16485
- error: "清理失败:" + String(error)
17694
+ error: sc.err
16486
17695
  };
16487
- }
16488
- },
16489
- "svn.update": async (args) => {
16490
- const sc = await requireSvnSession(ctx, args.sessionId);
16491
- if ("err" in sc) return {
16492
- ok: false,
16493
- error: sc.err
16494
- };
16495
- const rel = safeRel(args.path);
16496
- if (rel === null) return {
16497
- ok: false,
16498
- error: "路径不合法"
16499
- };
16500
- const status = await statusOf(sc.cwd);
16501
- if (!status.managed) return {
16502
- ok: false,
16503
- error: "当前工作区不受 SVN 管理"
16504
- };
16505
- if (!status.svnCli) return {
16506
- ok: false,
16507
- error: "svn 命令不可用(可在设置页配置 svn 路径)"
16508
- };
16509
- try {
16510
- const outcome = await runSvn(sc.cwd, ["update", rel || "."], UPDATE_GRACE_MS);
16511
- const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
16512
- if (outcome.code !== 0) return {
17696
+ const action = args.action;
17697
+ if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
16513
17698
  ok: false,
16514
- error: "更新失败:" + tailOfText(result.output)
17699
+ error: "未知动作: " + String(action)
16515
17700
  };
16516
- return {
16517
- ok: true,
16518
- ...result
16519
- };
16520
- } catch (error) {
16521
- return {
17701
+ const rel = safeRel(args.path);
17702
+ if (rel === null) return {
16522
17703
  ok: false,
16523
- error: "更新失败:" + String(error)
17704
+ error: "路径不合法"
16524
17705
  };
16525
- }
16526
- },
16527
- "svn.tortoise": async (args) => {
16528
- const sc = await requireSvnSession(ctx, args.sessionId);
16529
- if ("err" in sc) return {
16530
- ok: false,
16531
- error: sc.err
16532
- };
16533
- const action = args.action;
16534
- if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
16535
- ok: false,
16536
- error: "未知动作: " + String(action)
16537
- };
16538
- const rel = safeRel(args.path);
16539
- if (rel === null) return {
16540
- ok: false,
16541
- error: "路径不合法"
16542
- };
16543
- const status = await statusOf(sc.cwd);
16544
- if (!status.managed) return {
16545
- ok: false,
16546
- error: "当前工作区不受 SVN 管理"
16547
- };
16548
- if (!status.tortoise) return {
16549
- ok: false,
16550
- error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
16551
- };
16552
- const fs = ctx.get("fs");
16553
- if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
16554
- ok: false,
16555
- error: "缺少 fs"
16556
- };
16557
- try {
16558
- const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
16559
- if (!await fs.stat(target)) return {
17706
+ const status = await statusOf(sc.cwd);
17707
+ if (!status.managed) return {
16560
17708
  ok: false,
16561
- error: "路径不存在"
17709
+ error: "当前工作区不受 SVN 管理"
16562
17710
  };
16563
- const abs = fs.processPath(target);
16564
- launchTortoise(status.tortoiseExe, action, abs);
16565
- return {
16566
- ok: true,
16567
- launched: abs
17711
+ if (!status.tortoise) return {
17712
+ ok: false,
17713
+ error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
16568
17714
  };
16569
- } catch (error) {
16570
- return {
17715
+ const fs = ctx.get("fs");
17716
+ if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
16571
17717
  ok: false,
16572
- error: "启动 TortoiseSVN 失败:" + String(error)
17718
+ error: "缺少 fs"
16573
17719
  };
17720
+ try {
17721
+ const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
17722
+ if (!await fs.stat(target)) return {
17723
+ ok: false,
17724
+ error: "路径不存在"
17725
+ };
17726
+ const abs = fs.processPath(target);
17727
+ launchTortoise(status.tortoiseExe, action, abs);
17728
+ return {
17729
+ ok: true,
17730
+ launched: abs
17731
+ };
17732
+ } catch (error) {
17733
+ return {
17734
+ ok: false,
17735
+ error: "启动 TortoiseSVN 失败:" + String(error)
17736
+ };
17737
+ }
16574
17738
  }
17739
+ },
17740
+ dispose: () => {
17741
+ updateJobs.dispose();
17742
+ updateRootCache.clear();
16575
17743
  }
16576
- } };
17744
+ };
16577
17745
  }
16578
17746
  //#endregion
16579
17747
  //#region src/shared/dap.ts
@@ -18246,10 +19414,12 @@ function apply(ctx, config) {
18246
19414
  ctx,
18247
19415
  settings: openSettings
18248
19416
  }).handlers;
18249
- const svnHandlers = createSvnRpc({
19417
+ /** SVN RPC(检测/更新/Tortoise 发射;settings 提供 svnPath/tortoisePath)。 */
19418
+ const svnRpc = createSvnRpc({
18250
19419
  ctx,
18251
19420
  settings: openSettings
18252
- }).handlers;
19421
+ });
19422
+ const svnHandlers = svnRpc.handlers;
18253
19423
  /** 调试 RPC(DAP 桥单例:spawn 扩展适配器 + 事件缓冲 + findFile 反向匹配)。 */
18254
19424
  const dapRpc = createDapRpc(ctx);
18255
19425
  const dapHandlers = dapRpc.handlers;
@@ -18279,6 +19449,7 @@ function apply(ctx, config) {
18279
19449
  disposeAllServers();
18280
19450
  });
18281
19451
  ctx.effect(() => () => fileVersions.dispose());
19452
+ ctx.effect(() => () => svnRpc.dispose());
18282
19453
  ctx.effect(() => () => dapRpc.dispose());
18283
19454
  hookExitReclaim();
18284
19455
  log.info("编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入" + (rulesInstalled ? "已接入" : "未接入") + ",技能组" + (skillsDispatched ? "装配中" : "未装配") + ")");