dsh-vscode-mode 0.9.0 → 0.11.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.
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";
@@ -8335,6 +8335,32 @@ function patchInsertPerfConfig(text) {
8335
8335
  return patchRemovePerfConfig(text).replace(/\n+$/, "") + "\n" + perfConfigBlock();
8336
8336
  }
8337
8337
  //#endregion
8338
+ //#region src/settingsDocPath.ts
8339
+ /**
8340
+ * Resolve the settings document only when the request names its exact configured path.
8341
+ *
8342
+ * Both sides are resolved with the path rules of `platform` (win32 → `C:`-style paths and
8343
+ * case-insensitive comparison; otherwise POSIX rules and case-sensitive comparison), so the
8344
+ * function keeps its meaning when the host platform is passed explicitly instead of implied.
8345
+ * @author ddj 2026年10月02号
8346
+ * @param configuredPath Path exposed by the active settings provider
8347
+ * @param requestedPath Path requested by the editor RPC
8348
+ * @param platform Platform whose path rules resolve both sides and drive case comparison
8349
+ * @returns The configured absolute path when authorized; otherwise null
8350
+ */
8351
+ function settingsDocPath(configuredPath, requestedPath, platform = process.platform) {
8352
+ if (typeof configuredPath !== "string" || !configuredPath.trim() || !requestedPath) return null;
8353
+ try {
8354
+ const api = platform === "win32" ? win32 : posix;
8355
+ const configured = api.resolve(configuredPath);
8356
+ const requested = api.resolve(requestedPath);
8357
+ const fold = (value) => platform === "win32" ? value.toLowerCase() : value;
8358
+ return fold(configured) === fold(requested) ? configured : null;
8359
+ } catch {
8360
+ return null;
8361
+ }
8362
+ }
8363
+ //#endregion
8338
8364
  //#region src/rpc.ts
8339
8365
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
8340
8366
  const staleCheckedAt = /* @__PURE__ */ new Map();
@@ -8384,6 +8410,20 @@ function snippetTargetOf(path) {
8384
8410
  return isSnippetFilePath(path) ? path.replace(/\\/g, "/") : null;
8385
8411
  }
8386
8412
  /**
8413
+ * 返回与当前 settings provider 文档路径精确匹配的授权目标。
8414
+ * @author ddj 2026年10月02号
8415
+ * @param ctx DSH host 上下文
8416
+ * @param path 编辑器请求路径
8417
+ * @returns provider 当前配置文档的绝对路径;未匹配返回 null
8418
+ */
8419
+ function settingsTargetOf(ctx, path) {
8420
+ try {
8421
+ return settingsDocPath(ctx.get("settings")?.documentPath, path);
8422
+ } catch {
8423
+ return null;
8424
+ }
8425
+ }
8426
+ /**
8387
8427
  * 二进制/文本读取共用前置(edrv.read 与 edrv.readBinary):解析路径 + debugRecord +
8388
8428
  * stat + 文件类型校验 + 32MB 二进制上限,保证两条读通道的错误口径完全一致。
8389
8429
  * @author ddj 2026年09月22号
@@ -8748,6 +8788,31 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8748
8788
  };
8749
8789
  },
8750
8790
  "edrv.read": async (args) => {
8791
+ const settingsTarget = settingsTargetOf(ctx, args.path);
8792
+ if (settingsTarget && args.encoding !== "base64") try {
8793
+ const info = await stat(settingsTarget);
8794
+ if (!info.isFile()) return {
8795
+ ok: false,
8796
+ error: "设置配置文件不存在"
8797
+ };
8798
+ if (info.size > 8388608) return {
8799
+ ok: false,
8800
+ error: "设置配置文件过大(>8MB)"
8801
+ };
8802
+ const content = await readFile(settingsTarget, "utf8");
8803
+ return {
8804
+ ok: true,
8805
+ content,
8806
+ size: content.length,
8807
+ version: fileVersionOf(info)
8808
+ };
8809
+ } catch (error) {
8810
+ return {
8811
+ ok: false,
8812
+ error: "读取设置配置文件失败:" + String(error),
8813
+ resolvedPath: settingsTarget
8814
+ };
8815
+ }
8751
8816
  const snippetTarget = snippetTargetOf(args.path);
8752
8817
  if (snippetTarget && args.encoding !== "base64") try {
8753
8818
  const content = await readFile(snippetTarget, "utf8");
@@ -8829,11 +8894,47 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8829
8894
  }
8830
8895
  },
8831
8896
  "edrv.versions": async (args) => {
8897
+ const items = [];
8898
+ const regularPaths = [];
8899
+ for (const path of args.paths) {
8900
+ const target = settingsTargetOf(ctx, path);
8901
+ if (!target) {
8902
+ regularPaths.push(path);
8903
+ continue;
8904
+ }
8905
+ try {
8906
+ const info = await stat(target);
8907
+ items.push({
8908
+ path,
8909
+ version: fileVersionOf(info),
8910
+ size: info.size,
8911
+ type: "file"
8912
+ });
8913
+ } catch (error) {
8914
+ items.push({
8915
+ path,
8916
+ version: "",
8917
+ type: "missing",
8918
+ error: String(error)
8919
+ });
8920
+ }
8921
+ }
8922
+ if (regularPaths.length === 0) return {
8923
+ ok: true,
8924
+ items
8925
+ };
8832
8926
  if (!fileVersions) return {
8833
8927
  ok: false,
8834
8928
  error: "文件版本观察器未装配"
8835
8929
  };
8836
- return fileVersions.versions(args);
8930
+ const regular = await fileVersions.versions({
8931
+ ...args,
8932
+ paths: regularPaths
8933
+ });
8934
+ return {
8935
+ ...regular,
8936
+ items: [...items, ...regular.items]
8937
+ };
8837
8938
  },
8838
8939
  "edrv.original": async (args) => {
8839
8940
  const sc = await requireSession(ctx, args.sessionId);
@@ -8890,6 +8991,29 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8890
8991
  }
8891
8992
  },
8892
8993
  "edrv.save": async (args) => {
8994
+ const settingsTarget = settingsTargetOf(ctx, args.path);
8995
+ if (settingsTarget) try {
8996
+ const currentRev = fileVersionOf(await stat(settingsTarget));
8997
+ if (args.rev && args.rev !== currentRev) return {
8998
+ ok: false,
8999
+ conflict: true,
9000
+ error: "设置配置文件已被外部修改,请先重新加载"
9001
+ };
9002
+ if (settingsTargetOf(ctx, args.path) !== settingsTarget) return {
9003
+ ok: false,
9004
+ error: "设置配置文件路径已变化,拒绝保存"
9005
+ };
9006
+ await writeFile(settingsTarget, args.content, "utf8");
9007
+ return {
9008
+ ok: true,
9009
+ rev: fileVersionOf(await stat(settingsTarget).catch(() => null)) || currentRev
9010
+ };
9011
+ } catch (error) {
9012
+ return {
9013
+ ok: false,
9014
+ error: "保存设置配置文件失败:" + String(error)
9015
+ };
9016
+ }
8893
9017
  const snippetTarget = snippetTargetOf(args.path);
8894
9018
  if (snippetTarget) try {
8895
9019
  await writeFile(snippetTarget, args.content, "utf8");
@@ -9580,6 +9704,29 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
9580
9704
  revision: section?.revision
9581
9705
  };
9582
9706
  },
9707
+ "vscode.settingsDocumentPath": async () => {
9708
+ try {
9709
+ const settings = ctx.get("settings");
9710
+ if (typeof settings?.prepareDocument !== "function") return {
9711
+ ok: false,
9712
+ error: "设置配置文件不可用"
9713
+ };
9714
+ const path = await settings.prepareDocument();
9715
+ if (typeof path !== "string" || !path.trim()) return {
9716
+ ok: false,
9717
+ error: "设置配置文件路径为空"
9718
+ };
9719
+ return {
9720
+ ok: true,
9721
+ path
9722
+ };
9723
+ } catch (error) {
9724
+ return {
9725
+ ok: false,
9726
+ error: "准备设置配置文件失败:" + String(error)
9727
+ };
9728
+ }
9729
+ },
9583
9730
  "vscode.fileOpenSettingsUpdate": async (args) => {
9584
9731
  const settings = ctx.get("settings");
9585
9732
  const patch = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) };
@@ -10892,6 +11039,18 @@ function deriveDefinitionFromLocations(queryPos, locations, docText, docAbsPath)
10892
11039
  * 请求封装(definition/references/documentSymbol/workspaceSymbol/hover)、状态上报。
10893
11040
  * 作者 ddj 2026-08-27
10894
11041
  */
11042
+ const MAX_DOC_VERSION = 2147483647;
11043
+ /**
11044
+ * 使用合法、递增的 Int32 文档版本;旧客户端的毫秒时间戳不能直发给 Roslyn。
11045
+ * @author ddj 2026年09月24号
11046
+ * @param incoming 客户端传来的版本
11047
+ * @param previous 当前服务器记录的版本
11048
+ * @returns 可发送给语言服务器的版本
11049
+ */
11050
+ function lspDocVersion(incoming, previous) {
11051
+ const next = Math.min(MAX_DOC_VERSION, (previous ?? 0) + 1);
11052
+ return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next;
11053
+ }
10895
11054
  /**
10896
11055
  * 创建语言服务器会话。
10897
11056
  * @author ddj 2026年08月27号
@@ -11079,13 +11238,14 @@ function createLspServer(spec, root, languageId, logger) {
11079
11238
  sync(path, text, version) {
11080
11239
  const existing = docs.get(path);
11081
11240
  const uri = existing?.uri ?? docUriOf(path);
11241
+ const safeVersion = lspDocVersion(version, existing?.version);
11082
11242
  if (existing) {
11083
- existing.version = version;
11243
+ existing.version = safeVersion;
11084
11244
  existing.text = text;
11085
11245
  if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didChange", {
11086
11246
  textDocument: {
11087
11247
  uri,
11088
- version
11248
+ version: safeVersion
11089
11249
  },
11090
11250
  contentChanges: [{ text }]
11091
11251
  });
@@ -11094,13 +11254,13 @@ function createLspServer(spec, root, languageId, logger) {
11094
11254
  docs.set(path, {
11095
11255
  path,
11096
11256
  uri,
11097
- version,
11257
+ version: safeVersion,
11098
11258
  text
11099
11259
  });
11100
11260
  if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didOpen", { textDocument: {
11101
11261
  uri,
11102
11262
  languageId,
11103
- version,
11263
+ version: safeVersion,
11104
11264
  text
11105
11265
  } });
11106
11266
  },
@@ -11606,6 +11766,13 @@ function normalizeSemanticData(data, types, modifiers) {
11606
11766
  * 实例注册表(key=root|languageId,refcount 共享),崩溃退避重启,session/disposed 清理。
11607
11767
  * 作者 ddj 2026-08-27
11608
11768
  */
11769
+ const BACKOFF_MS = [
11770
+ 1e3,
11771
+ 5e3,
11772
+ 3e4
11773
+ ];
11774
+ const MAX_FAILURES = 3;
11775
+ const STABLE_MS = 6e4;
11609
11776
  /**
11610
11777
  * 创建 LSP 管理器。
11611
11778
  * @author ddj 2026年08月27号
@@ -11616,6 +11783,20 @@ function createLspManager(logger) {
11616
11783
  const entries = /* @__PURE__ */ new Map();
11617
11784
  const keyOf = (root, languageId) => root + "|" + languageId;
11618
11785
  const log = (line) => logger?.("[lsp-manager] " + line);
11786
+ /**
11787
+ * 重试耗尽后保留退出根因,并让 UI 不再误报“未启动”。
11788
+ * @author ddj 2026年09月24号
11789
+ * @param entry 服务器条目
11790
+ * @returns 当前可见状态
11791
+ */
11792
+ const statusOf = (entry) => {
11793
+ const status = entry.server.status();
11794
+ return entry.failures >= MAX_FAILURES ? {
11795
+ ...status,
11796
+ phase: "unavailable",
11797
+ reason: (status.reason ?? "服务器已退出") + "(已停止自动重试)"
11798
+ } : status;
11799
+ };
11619
11800
  /** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
11620
11801
  const dropEntry = (entry) => {
11621
11802
  if (entry.restartTimer) {
@@ -11625,6 +11806,25 @@ function createLspManager(logger) {
11625
11806
  entries.delete(entry.key);
11626
11807
  entry.server.dispose().catch(() => {});
11627
11808
  };
11809
+ const scheduleRestart = (entry) => {
11810
+ if (entry.restartTimer || entry.refs === 0 || entries.get(entry.key) !== entry) return;
11811
+ if (entry.failures >= MAX_FAILURES) {
11812
+ log("unavailable after " + entry.failures + " failures: " + entry.key);
11813
+ manager.onStatusChange?.(statusOf(entry));
11814
+ return;
11815
+ }
11816
+ const delay = BACKOFF_MS[Math.min(Math.max(entry.failures - 1, 0), BACKOFF_MS.length - 1)];
11817
+ entry.restartTimer = setTimeout(() => {
11818
+ entry.restartTimer = null;
11819
+ if (entries.get(entry.key) !== entry || entry.refs === 0) return;
11820
+ (async () => {
11821
+ if (!await entry.server.start() && entries.get(entry.key) === entry && !entry.restartTimer) {
11822
+ entry.failures++;
11823
+ scheduleRestart(entry);
11824
+ }
11825
+ })();
11826
+ }, delay);
11827
+ };
11628
11828
  const manager = {
11629
11829
  acquire(root, languageId, resolveSpec) {
11630
11830
  const key = keyOf(root, languageId);
@@ -11632,17 +11832,33 @@ function createLspManager(logger) {
11632
11832
  if (!entry) {
11633
11833
  const spec = resolveSpec(languageId);
11634
11834
  const server = createLspServer(spec, root, languageId, log);
11635
- server.onStateChange = (status) => manager.onStatusChange?.(status);
11636
11835
  entry = {
11637
11836
  key,
11638
11837
  server,
11639
11838
  refs: 0,
11640
11839
  failures: 0,
11840
+ readyAt: 0,
11641
11841
  restartTimer: null
11642
11842
  };
11643
11843
  entries.set(key, entry);
11844
+ server.onStateChange = (status) => {
11845
+ const live = entries.get(key);
11846
+ if (live?.server === server) {
11847
+ if (status.phase === "ready" && live.readyAt === 0) live.readyAt = Date.now();
11848
+ if (status.phase === "idle" && status.reason?.startsWith("服务器已退出")) {
11849
+ if (live.readyAt && Date.now() - live.readyAt >= STABLE_MS) live.failures = 0;
11850
+ live.failures++;
11851
+ live.readyAt = 0;
11852
+ scheduleRestart(live);
11853
+ }
11854
+ }
11855
+ manager.onStatusChange?.(live?.server === server ? statusOf(live) : status);
11856
+ };
11644
11857
  if (spec.ready) server.start().then((ok) => {
11645
- if (!ok && entries.has(key)) entry.failures++;
11858
+ if (!ok && entries.get(key) === entry && server.phase === "idle" && !entry.restartTimer) {
11859
+ entry.failures++;
11860
+ scheduleRestart(entry);
11861
+ }
11646
11862
  });
11647
11863
  }
11648
11864
  entry.refs++;
@@ -11665,7 +11881,7 @@ function createLspManager(logger) {
11665
11881
  for (const entry of [...entries.values()]) if (entry.server.languageId === languageId) dropEntry(entry);
11666
11882
  },
11667
11883
  statusAll() {
11668
- return [...entries.values()].map((entry) => entry.server.status());
11884
+ return [...entries.values()].map(statusOf);
11669
11885
  },
11670
11886
  peek(root, languageId) {
11671
11887
  return entries.get(keyOf(root, languageId))?.server;
@@ -12920,6 +13136,74 @@ function langOfPath(path) {
12920
13136
  })) if (exts.includes(ext)) return lang;
12921
13137
  return null;
12922
13138
  }
13139
+ /**
13140
+ * 判断 needle 是否为 haystack 的子序列(两侧均已小写)。
13141
+ *
13142
+ * 与 Monaco 候选列表的模糊匹配同口径,保证「分桶判为命中」的条目确实是
13143
+ * 用户输入能筛出来的那些,避免分桶顺序与前端过滤结果互相矛盾。
13144
+ * @author ddj 2026年09月24号
13145
+ * @param haystack 候选文本(已小写)
13146
+ * @param needle 输入前缀(已小写、非空)
13147
+ * @returns 是否为子序列
13148
+ */
13149
+ function isSubsequence(haystack, needle) {
13150
+ let matched = 0;
13151
+ for (let i = 0; i < haystack.length && matched < needle.length; i++) if (haystack[i] === needle[matched]) matched++;
13152
+ return matched === needle.length;
13153
+ }
13154
+ /**
13155
+ * 取补全项的匹配文本(Monaco 口径:filterText 优先,回落 label;统一小写)。
13156
+ * @author ddj 2026年09月24号
13157
+ * @param item 补全项
13158
+ * @returns 小写匹配文本;无可用文本时为空串
13159
+ */
13160
+ function textOfItem(item) {
13161
+ const raw = typeof item?.filterText === "string" && item.filterText ? item.filterText : item?.label;
13162
+ return typeof raw === "string" ? raw.toLowerCase() : "";
13163
+ }
13164
+ /**
13165
+ * 按输入前缀分桶后截断补全列表。
13166
+ *
13167
+ * 分桶优先级:startsWith > includes > 子序列 > 其余;桶内保持服务器原始顺序
13168
+ * (服务器已按相关性排序,不重新打分)。无前缀或未超限时与旧行为完全一致。
13169
+ * @author ddj 2026年09月24号
13170
+ * @param items 服务器归一化后的补全条目
13171
+ * @param prefix 当前输入前缀(Monaco getWordUntilPosition 的词)
13172
+ * @param cap 条目上限(缺省 COMPLETION_ITEM_CAP;测试可注入小值)
13173
+ * @returns 有界条目与截断标记
13174
+ */
13175
+ function capCompletions(items, prefix, cap = 500) {
13176
+ if (!Array.isArray(items)) return {
13177
+ items: [],
13178
+ truncated: false
13179
+ };
13180
+ if (items.length <= cap) return {
13181
+ items,
13182
+ truncated: false
13183
+ };
13184
+ const needle = String(prefix ?? "").toLowerCase();
13185
+ if (!needle) return {
13186
+ items: items.slice(0, cap),
13187
+ truncated: true
13188
+ };
13189
+ const buckets = [
13190
+ [],
13191
+ [],
13192
+ [],
13193
+ []
13194
+ ];
13195
+ for (const item of items) {
13196
+ const text = textOfItem(item);
13197
+ if (text.startsWith(needle)) buckets[0].push(item);
13198
+ else if (text.includes(needle)) buckets[1].push(item);
13199
+ else if (isSubsequence(text, needle)) buckets[2].push(item);
13200
+ else buckets[3].push(item);
13201
+ }
13202
+ return {
13203
+ items: buckets[0].concat(buckets[1], buckets[2], buckets[3]).slice(0, cap),
13204
+ truncated: true
13205
+ };
13206
+ }
12923
13207
  //#endregion
12924
13208
  //#region src/lsp/envRequirements.ts
12925
13209
  /**
@@ -13484,13 +13768,16 @@ function createLspRpc(deps) {
13484
13768
  ok: true,
13485
13769
  completions: void 0
13486
13770
  };
13771
+ const capped = capCompletions(completions.items, args.prefix);
13772
+ const payload = {
13773
+ ...completions,
13774
+ items: capped.items
13775
+ };
13776
+ if (capped.truncated) payload.truncated = true;
13777
+ else delete payload.truncated;
13487
13778
  return {
13488
13779
  ok: true,
13489
- completions: completions.items.length > 500 ? {
13490
- ...completions,
13491
- items: completions.items.slice(0, 500),
13492
- truncated: true
13493
- } : completions
13780
+ completions: payload
13494
13781
  };
13495
13782
  } catch (error) {
13496
13783
  return {
@@ -14382,7 +14669,27 @@ const UPDATE_ACTION_LABEL = {
14382
14669
  " ": "无变化"
14383
14670
  };
14384
14671
  /** update 结果行:行首若干空白 + 单字母 + 空白 + 路径(路径可含空格,取整段)。 */
14385
- const UPDATE_LINE_RE = /^([ADGCEUR]|\s{3})\s{2,}(\S.*)$/;
14672
+ const UPDATE_LINE_RE = /^([A-Z_ ]{1,4})\s{2,}(\S.*)$/;
14673
+ /**
14674
+ * 解析 update 的单行状态(正文/摘要不匹配)。属性列 U/C 也按该条目状态处理。
14675
+ * @author ddj 2026年09月24号
14676
+ * @param line 原始输出行
14677
+ * @returns 可识别条目或 null
14678
+ */
14679
+ function updateLineOf(line) {
14680
+ const hit = UPDATE_LINE_RE.exec(line);
14681
+ if (!hit) return null;
14682
+ const status = hit[1].replace(/[ _]/g, "");
14683
+ if (!status || /[^ADGCEUR]/.test(status)) return null;
14684
+ const action = status.includes("C") ? "C" : status[0];
14685
+ const path = hit[2].trim();
14686
+ if (!path) return null;
14687
+ return {
14688
+ action,
14689
+ path,
14690
+ label: UPDATE_ACTION_LABEL[action] ?? action
14691
+ };
14692
+ }
14386
14693
  /**
14387
14694
  * 解析 `svn update` 文本输出为**条目级**结构化结果(附冲突清单与一行摘要)。
14388
14695
  *
@@ -14404,16 +14711,10 @@ function updateResultOf(stdout, stderr, code) {
14404
14711
  const entries = [];
14405
14712
  const conflicts = [];
14406
14713
  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);
14714
+ const entry = updateLineOf(line);
14715
+ if (entry) {
14716
+ if (entries.length < 4e3) entries.push(entry);
14717
+ if (entry.action === "C" && conflicts.length < 64) conflicts.push(entry.path);
14417
14718
  continue;
14418
14719
  }
14419
14720
  const loose = /^(?:\s?[A-Z_ ]?C)\s+(\S.*)$/.exec(line);
@@ -14697,153 +14998,798 @@ function parseSvnInfoRevision(xml) {
14697
14998
  const at = /<commit[^>]*\brevision="(\d+)"/.exec(text);
14698
14999
  return at ? Number(at[1]) : null;
14699
15000
  }
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
- };
15001
+ //#endregion
15002
+ //#region src/svnUpdateJob.ts
14757
15003
  /**
14758
- * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
14759
- * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
14760
- * @author ddj 2026年09月16号
14761
- * @param cwd 起始绝对路径
14762
- * @param exists 存在性探测(测试注入)
14763
- * @returns 工作副本根绝对路径;未命中返回 null
15004
+ * dsh-vscode-mode host — SVN update job with incremental output and bounded replay.
15005
+ * @author ddj 2026年09月24号
14764
15006
  */
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
- }
15007
+ const ROW_CAP = 2e4;
15008
+ const PAGE_SIZE = 250;
15009
+ const LINE_CAP = 16e3;
15010
+ const JOB_CAP = 16;
15011
+ const RESULT_TTL_MS = 6e5;
15012
+ const UPDATE_MAX_MS = 36e5;
15013
+ const COLLECT_MS = 100;
14775
15014
  /**
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
15015
+ * 创建每个任务独立的 SVN 状态计数。
15016
+ * @author ddj 2026年09月24号
15017
+ * @returns 初始计数
14783
15018
  */
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;
15019
+ function emptyCounts() {
15020
+ return {
15021
+ A: 0,
15022
+ U: 0,
15023
+ D: 0,
15024
+ G: 0,
15025
+ C: 0,
15026
+ E: 0,
15027
+ R: 0
15028
+ };
14794
15029
  }
14795
15030
  /**
14796
- * svn:ignore 名称合法性校验(host 权威;防换行注入 propset 值与路径穿越)。
14797
- * 规则:去空白后非空、无换行、无路径分隔符、不以 `-` 开头(防被 svn 当选项解析)。
14798
- * @author ddj 2026年09月23号
14799
- * @param name 待写入的忽略名(basename / 通配模式)
14800
- * @returns 错误文案;合法为 null
15031
+ * 工作副本根作为并发键;Windows 路径大小写不敏感。
15032
+ * @author ddj 2026年09月24号
15033
+ * @param root 工作副本根
15034
+ * @param platform 目标平台
15035
+ * @returns 并发键
14801
15036
  */
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;
15037
+ function rootKey(root, platform) {
15038
+ return platform === "win32" ? root.toLowerCase() : root;
14810
15039
  }
14811
15040
  /**
14812
- * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
14813
- *
14814
- * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
14815
- * 避免自研项与官方同名项并列、用户误点到官方弹窗。
14816
- * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
14817
- * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
15041
+ * 有状态更新任务:原 RPC 与新后台任务独立,测试可注入进程服务/时钟。
15042
+ * @author ddj 2026年09月24号
14818
15043
  */
14819
- const SVN_FEATURES = [
14820
- "diff",
14821
- "revert",
14822
- "log"
14823
- ];
14824
- /** `svn status --xml` 的 `<wc-status item="...">` 合法取值(未知值一律按 modified 兜底)。 */
14825
- const KNOWN_ITEM_STATUS = /* @__PURE__ */ new Set([
14826
- "normal",
14827
- "added",
14828
- "deleted",
14829
- "replaced",
14830
- "conflicted",
14831
- "missing",
14832
- "unversioned",
14833
- "obstructed",
14834
- "ignored",
14835
- "modified",
14836
- "external",
14837
- "incomplete"
14838
- ]);
14839
- /** 受版本控制判据:unversioned/ignored 之外均为版本化条目。 */
14840
- const UNVERSIONED_STATUS = /* @__PURE__ */ new Set(["unversioned", "ignored"]);
14841
- /**
14842
- * 归一化 XML 中的条目路径为工作区相对路径(`/` 分隔、无前导 `./`)。
14843
- * XML 路径相对「执行 status 时的 cwd」(实测 Windows 下用 `\`),故 host 必须以
14844
- * 工作副本根为 cwd 执行,这里只做分隔符与形态归一;`''`/`.` 表示根自身。
14845
- * @author ddj 2026年09月16号
14846
- * @param raw XML path 属性原文(已反转义)
15044
+ var SvnUpdateJobs = class {
15045
+ service;
15046
+ jobs = /* @__PURE__ */ new Map();
15047
+ platform;
15048
+ now;
15049
+ /**
15050
+ * 建立任务管理器。
15051
+ * @author ddj 2026年09月24号
15052
+ * @param service DSH subprocess 服务获取器
15053
+ * @param platform 平台
15054
+ * @param now 时钟
15055
+ */
15056
+ constructor(service, platform = process.platform, now = Date.now) {
15057
+ this.service = service;
15058
+ this.platform = platform;
15059
+ this.now = now;
15060
+ }
15061
+ /**
15062
+ * 同根重复点击只返回当前任务;旧结果可由 active 取回,新点击启动新任务。
15063
+ * @author ddj 2026年09月24号
15064
+ * @param target 已验证的工作副本目标
15065
+ * @returns 任务初始状态
15066
+ */
15067
+ start(target) {
15068
+ this.prune();
15069
+ const key = rootKey(target.root, this.platform);
15070
+ const old = this.jobs.get(key);
15071
+ if (old && !this.isFinal(old.phase)) return this.stateOf(old);
15072
+ if (!old && this.jobs.size >= JOB_CAP) throw new Error("同时保留的 SVN 更新任务过多,请稍后重试");
15073
+ const job = {
15074
+ id: randomUUID(),
15075
+ root: target.root,
15076
+ cwd: target.cwd,
15077
+ target: target.path,
15078
+ phase: "starting",
15079
+ counts: emptyCounts(),
15080
+ revision: null,
15081
+ error: "",
15082
+ rawTail: "",
15083
+ truncated: false,
15084
+ startedAt: this.now(),
15085
+ endedAt: null,
15086
+ rows: [],
15087
+ seq: 0,
15088
+ pending: "",
15089
+ cancelled: false,
15090
+ exited: false
15091
+ };
15092
+ this.jobs.set(key, job);
15093
+ this.launch(job, target.exe);
15094
+ return this.stateOf(job);
15095
+ }
15096
+ /**
15097
+ * 游标分页取状态;即使前端掉线,有限窗口仍可重新读取。
15098
+ * @author ddj 2026年09月24号
15099
+ * @param root 已验证会话的工作副本根
15100
+ * @param jobId 任务 id
15101
+ * @param since 最后一条已读序号
15102
+ * @returns 一页增量及状态
15103
+ */
15104
+ poll(root, jobId, since) {
15105
+ const job = this.getJob(root, jobId);
15106
+ const offset = job.rows[0]?.seq ?? job.seq + 1;
15107
+ const cursor = Math.max(offset - 1, Number.isFinite(since) ? Math.floor(since) : 0);
15108
+ const start = Math.max(0, cursor - offset + 1);
15109
+ const rows = job.rows.slice(start, start + PAGE_SIZE);
15110
+ return {
15111
+ ...this.stateOf(job),
15112
+ rows,
15113
+ offset,
15114
+ totalSeq: job.seq,
15115
+ nextSeq: rows.at(-1)?.seq ?? cursor
15116
+ };
15117
+ }
15118
+ /**
15119
+ * 请求终止整个受管进程范围;真实退出前保持 cancelling 状态。
15120
+ * @author ddj 2026年09月24号
15121
+ * @param root 已验证会话的工作副本根
15122
+ * @param jobId 任务 id
15123
+ * @returns 当前状态
15124
+ */
15125
+ cancel(root, jobId) {
15126
+ const job = this.getJob(root, jobId);
15127
+ if (!this.isFinal(job.phase) && !job.exited) {
15128
+ job.cancelled = true;
15129
+ job.phase = "cancelling";
15130
+ job.probeAbort?.abort(/* @__PURE__ */ new Error("用户已取消 SVN 更新"));
15131
+ try {
15132
+ job.stop?.();
15133
+ } catch (error) {
15134
+ job.error = String(error);
15135
+ }
15136
+ }
15137
+ return this.stateOf(job);
15138
+ }
15139
+ /**
15140
+ * 按当前会话工作副本恢复进行中或最近一次结果。
15141
+ * @author ddj 2026年09月24号
15142
+ * @param root 已验证的工作副本根
15143
+ * @returns 状态或 null
15144
+ */
15145
+ active(root) {
15146
+ this.prune();
15147
+ const job = this.jobs.get(rootKey(root, this.platform));
15148
+ return job ? this.stateOf(job) : null;
15149
+ }
15150
+ /**
15151
+ * 插件卸载回收任务与定时器。
15152
+ * @author ddj 2026年09月24号
15153
+ */
15154
+ dispose() {
15155
+ for (const job of this.jobs.values()) {
15156
+ if (job.timer) clearInterval(job.timer);
15157
+ if (job.deadline) clearTimeout(job.deadline);
15158
+ if (!this.isFinal(job.phase)) {
15159
+ job.cancelled = true;
15160
+ job.probeAbort?.abort(/* @__PURE__ */ new Error("SVN 插件已卸载"));
15161
+ try {
15162
+ job.stop?.();
15163
+ } catch {}
15164
+ }
15165
+ }
15166
+ this.jobs.clear();
15167
+ }
15168
+ /**
15169
+ * 先用只读 version 探测捕获通道,再允许启动会修改工作副本的 update。
15170
+ * @author ddj 2026年09月24号
15171
+ * @param job 待启动任务
15172
+ * @param exe 已配置 SVN CLI
15173
+ */
15174
+ async launch(job, exe) {
15175
+ const controller = new AbortController();
15176
+ job.probeAbort = controller;
15177
+ const probeDeadline = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("SVN 输出探测超时(8 秒)")), 8e3);
15178
+ probeDeadline.unref?.();
15179
+ try {
15180
+ const service = this.service();
15181
+ if (!service) throw new Error("subprocess 服务不可用");
15182
+ const modes = this.platform === "win32" ? ["terminal", "collect"] : ["collect", "terminal"];
15183
+ let mode = null;
15184
+ let reason = "";
15185
+ for (const candidate of modes) {
15186
+ controller.signal.throwIfAborted();
15187
+ try {
15188
+ await this.probe(service, exe, job.cwd, candidate, controller.signal);
15189
+ mode = candidate;
15190
+ break;
15191
+ } catch (error) {
15192
+ if (controller.signal.aborted) throw error;
15193
+ reason = String(error);
15194
+ }
15195
+ }
15196
+ if (job.cancelled) {
15197
+ this.finish(job, null);
15198
+ return;
15199
+ }
15200
+ if (!mode) throw new Error("SVN 输出通道不可用:" + reason);
15201
+ clearTimeout(probeDeadline);
15202
+ await this.startUpdate(job, service, mode, exe, controller);
15203
+ } catch (error) {
15204
+ job.error = String(error);
15205
+ this.finish(job, null);
15206
+ } finally {
15207
+ clearTimeout(probeDeadline);
15208
+ job.probeAbort = void 0;
15209
+ }
15210
+ }
15211
+ /**
15212
+ * 分配实际更新句柄时继续允许取消;未完成的分配不盲目重试更新。
15213
+ * @author ddj 2026年09月24号
15214
+ * @param job 当前任务
15215
+ * @param service DSH 子进程服务
15216
+ * @param mode 已探测可用的流式通道
15217
+ * @param exe SVN 可执行文件
15218
+ * @param controller 启动阶段的取消信号
15219
+ */
15220
+ async startUpdate(job, service, mode, exe, controller) {
15221
+ const argv = [
15222
+ exe,
15223
+ "--non-interactive",
15224
+ "update",
15225
+ "--",
15226
+ job.target || "."
15227
+ ];
15228
+ job.phase = "running";
15229
+ job.stop = () => controller.abort(/* @__PURE__ */ new Error("更新启动期间被取消"));
15230
+ job.deadline = setTimeout(() => {
15231
+ job.error = "SVN 更新超过 60 分钟,已请求停止;工作副本可能已部分更新";
15232
+ try {
15233
+ job.stop?.();
15234
+ } catch {}
15235
+ }, UPDATE_MAX_MS);
15236
+ job.deadline.unref?.();
15237
+ if (mode === "terminal" && service.spawnTerminal) {
15238
+ const pending = service.spawnTerminal({
15239
+ ...this.terminalSpec(argv, job.cwd),
15240
+ signal: controller.signal
15241
+ });
15242
+ const handle = await this.awaitTerminal(pending, controller.signal);
15243
+ job.stop = () => {
15244
+ handle.terminate().catch((error) => {
15245
+ job.error = String(error);
15246
+ });
15247
+ };
15248
+ if (job.cancelled) job.stop();
15249
+ this.watchTerminal(job, handle);
15250
+ } else {
15251
+ const handle = service.spawn({
15252
+ ...this.collectSpec(argv, job.cwd),
15253
+ signal: controller.signal
15254
+ });
15255
+ job.stop = () => handle.terminate();
15256
+ if (job.cancelled) job.stop();
15257
+ this.watchCollect(job, handle);
15258
+ }
15259
+ }
15260
+ /**
15261
+ * 终端句柄分配与 Abort 之间的微任务间隙也必须回收晚到句柄。
15262
+ * @author ddj 2026年09月24号
15263
+ * @param pending 分配中的终端
15264
+ * @param signal 用户取消或截止信号
15265
+ * @returns 可受管的终端句柄
15266
+ */
15267
+ async awaitTerminal(pending, signal) {
15268
+ let handle = null;
15269
+ const stop = () => {
15270
+ if (handle) handle.terminate().catch(() => {});
15271
+ };
15272
+ signal.addEventListener("abort", stop, { once: true });
15273
+ try {
15274
+ const guarded = pending.then((ready) => {
15275
+ handle = ready;
15276
+ if (signal.aborted) stop();
15277
+ return ready;
15278
+ });
15279
+ return await this.waitProbe(guarded, signal, () => {});
15280
+ } finally {
15281
+ signal.removeEventListener("abort", stop);
15282
+ }
15283
+ }
15284
+ /**
15285
+ * 探针/启动 Promise 可由超时和用户取消立即打断,并终止已分配的句柄。
15286
+ * @author ddj 2026年09月24号
15287
+ * @param pending 待完成操作
15288
+ * @param signal 任务取消信号
15289
+ * @param stop 停止已经分配的子进程
15290
+ * @returns 操作结果
15291
+ */
15292
+ waitProbe(pending, signal, stop) {
15293
+ return new Promise((resolve, reject) => {
15294
+ let finished = false;
15295
+ const abort = () => {
15296
+ if (finished) return;
15297
+ finished = true;
15298
+ try {
15299
+ stop();
15300
+ } catch {}
15301
+ reject(signal.reason ?? /* @__PURE__ */ new Error("SVN 探测已取消"));
15302
+ };
15303
+ if (signal.aborted) {
15304
+ abort();
15305
+ pending.catch(() => {});
15306
+ return;
15307
+ }
15308
+ signal.addEventListener("abort", abort, { once: true });
15309
+ pending.then((value) => {
15310
+ signal.removeEventListener("abort", abort);
15311
+ if (!finished) {
15312
+ finished = true;
15313
+ resolve(value);
15314
+ }
15315
+ }, (error) => {
15316
+ signal.removeEventListener("abort", abort);
15317
+ if (!finished) {
15318
+ finished = true;
15319
+ reject(error);
15320
+ }
15321
+ });
15322
+ });
15323
+ }
15324
+ /**
15325
+ * 探针失败只换读法,不会重试一次已启动的实际 update。
15326
+ * @author ddj 2026年09月24号
15327
+ * @param service DSH 服务
15328
+ * @param exe SVN 命令
15329
+ * @param cwd 进程目录
15330
+ * @param mode 输出通道
15331
+ * @param signal 用户取消或探针超时
15332
+ */
15333
+ async probe(service, exe, cwd, mode, signal) {
15334
+ signal.throwIfAborted();
15335
+ const argv = [
15336
+ exe,
15337
+ "--version",
15338
+ "--quiet"
15339
+ ];
15340
+ if (mode === "terminal") {
15341
+ if (!service.spawnTerminal) throw new Error("终端输出通道不可用");
15342
+ const pending = service.spawnTerminal({
15343
+ ...this.terminalSpec(argv, cwd),
15344
+ signal
15345
+ });
15346
+ const handle = await this.awaitTerminal(pending, signal);
15347
+ let text = "";
15348
+ let failure = "";
15349
+ const drained = new Promise((resolve) => {
15350
+ handle.output.on("data", (chunk) => {
15351
+ text += String(chunk);
15352
+ });
15353
+ handle.output.once("end", resolve);
15354
+ handle.output.once("error", (error) => {
15355
+ failure = String(error);
15356
+ resolve();
15357
+ });
15358
+ });
15359
+ const outcome = await this.waitProbe(handle.done, signal, () => {
15360
+ handle.terminate().catch(() => {});
15361
+ });
15362
+ await this.waitProbe(Promise.race([drained, new Promise((resolve) => {
15363
+ setTimeout(resolve, 2e3).unref?.();
15364
+ })]), signal, () => {
15365
+ handle.terminate().catch(() => {});
15366
+ });
15367
+ if (outcome.exitCode !== 0 || !text.trim() || failure) throw new Error("终端输出探测失败:" + failure);
15368
+ return;
15369
+ }
15370
+ const handle = service.spawn({
15371
+ ...this.collectSpec(argv, cwd),
15372
+ signal
15373
+ });
15374
+ const outcome = await this.waitProbe(handle.done, signal, () => handle.terminate());
15375
+ const text = handle.collected?.stdout?.readFrom(0).text ?? "";
15376
+ if (outcome.exitCode !== 0 || !text.trim()) throw new Error("收集输出探测失败");
15377
+ }
15378
+ /**
15379
+ * DSH PTY 读取:TextDecoder 保证跨数据块的 UTF-8 字符不被截断。
15380
+ * @author ddj 2026年09月24号
15381
+ * @param job 活动任务
15382
+ * @param handle 终端句柄
15383
+ */
15384
+ watchTerminal(job, handle) {
15385
+ const decoder = new TextDecoder();
15386
+ let drained = false;
15387
+ let finishOutput = () => {};
15388
+ const outputDone = new Promise((resolve) => {
15389
+ finishOutput = resolve;
15390
+ });
15391
+ handle.output.on("data", (chunk) => {
15392
+ const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
15393
+ this.ingest(job, text);
15394
+ });
15395
+ handle.output.on("end", () => {
15396
+ drained = true;
15397
+ this.ingest(job, decoder.decode());
15398
+ finishOutput();
15399
+ });
15400
+ handle.output.on("error", (error) => {
15401
+ job.error = "读取输出失败:" + String(error);
15402
+ finishOutput();
15403
+ });
15404
+ handle.done.then(async (outcome) => {
15405
+ job.exited = true;
15406
+ if (!drained) await Promise.race([outputDone, new Promise((resolve) => {
15407
+ setTimeout(resolve, 2e3).unref?.();
15408
+ })]);
15409
+ this.finish(job, outcome.exitCode);
15410
+ }, (error) => {
15411
+ job.exited = true;
15412
+ job.error = String(error);
15413
+ this.finish(job, null);
15414
+ });
15415
+ }
15416
+ /**
15417
+ * 管道收集的读取器独立管理 stdout/stderr 字节偏移;只消费完整行,避免截断中文。
15418
+ * @author ddj 2026年09月24号
15419
+ * @param job 活动任务
15420
+ * @param handle 子进程句柄
15421
+ */
15422
+ watchCollect(job, handle) {
15423
+ const offsets = {
15424
+ stdout: 0,
15425
+ stderr: 0
15426
+ };
15427
+ job.timer = setInterval(() => this.readCollect(job, handle, offsets, false), COLLECT_MS);
15428
+ job.timer.unref?.();
15429
+ handle.done.then((outcome) => {
15430
+ job.exited = true;
15431
+ if (job.timer) clearInterval(job.timer);
15432
+ this.readCollect(job, handle, offsets, true);
15433
+ this.finish(job, outcome.exitCode ?? outcome.code ?? null);
15434
+ }, (error) => {
15435
+ job.exited = true;
15436
+ job.error = String(error);
15437
+ this.finish(job, null);
15438
+ });
15439
+ }
15440
+ /**
15441
+ * 从两个 bounded collector 取完整行;lossy 时标记不完整且不伪造丢失计数。
15442
+ * @author ddj 2026年09月24号
15443
+ * @param job 活动任务
15444
+ * @param handle 子进程句柄
15445
+ * @param offsets 各路已消费字节位置
15446
+ * @param final 进程已结束,可以消费末尾无换行内容
15447
+ */
15448
+ readCollect(job, handle, offsets, final) {
15449
+ for (const key of ["stdout", "stderr"]) {
15450
+ const reader = handle.collected?.[key];
15451
+ if (!reader) {
15452
+ job.truncated = true;
15453
+ continue;
15454
+ }
15455
+ const read = reader.readFrom(offsets[key]);
15456
+ if (read.lossy) {
15457
+ job.truncated = true;
15458
+ offsets[key] = read.nextOffset;
15459
+ job.pending = "";
15460
+ this.recordLine(job, "输出前段已截断,以下是可恢复的尾部记录", null);
15461
+ const firstEnd = read.text.indexOf("\n");
15462
+ if (firstEnd >= 0) this.ingest(job, read.text.slice(firstEnd + 1));
15463
+ continue;
15464
+ }
15465
+ const last = final ? read.text.length : read.text.lastIndexOf("\n") + 1;
15466
+ if (!last) continue;
15467
+ const chunk = read.text.slice(0, last);
15468
+ offsets[key] = final ? read.nextOffset : offsets[key] + Buffer.byteLength(chunk, "utf8");
15469
+ this.ingest(job, chunk);
15470
+ }
15471
+ }
15472
+ /**
15473
+ * 增量行缓冲:ANSI 和 CR/LF 归一;过长单行主动截断并告警。
15474
+ * @author ddj 2026年09月24号
15475
+ * @param job 活动任务
15476
+ * @param text 新输出
15477
+ */
15478
+ ingest(job, text) {
15479
+ if (!text) return;
15480
+ job.pending += text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
15481
+ const lines = job.pending.split(/\r\n|\n|\r/);
15482
+ job.pending = lines.pop() ?? "";
15483
+ for (const line of lines) this.recordLine(job, line, updateLineOf(line));
15484
+ if (job.pending.length > LINE_CAP) {
15485
+ job.truncated = true;
15486
+ this.recordLine(job, job.pending.slice(0, LINE_CAP) + "…(单行过长)", null);
15487
+ job.pending = "";
15488
+ }
15489
+ }
15490
+ /**
15491
+ * 记录有界行与原文;修订号只读 svn 自己的完成/无变化行。
15492
+ * @author ddj 2026年09月24号
15493
+ * @param job 活动任务
15494
+ * @param line 完整输出行
15495
+ * @param entry 可识别的逐文件状态
15496
+ */
15497
+ recordLine(job, line, entry) {
15498
+ const raw = line.trimEnd();
15499
+ if (!raw) return;
15500
+ job.rawTail = (job.rawTail + raw + "\n").slice(-32e3);
15501
+ const rev = /(?:Updated to revision|At revision|已更新到版本|版本)\s*\.?\s*(\d+)/i.exec(raw);
15502
+ if (rev) job.revision = Number(rev[1]);
15503
+ if (entry) job.counts[entry.action] += 1;
15504
+ const path = entry?.path ?? "";
15505
+ const row = {
15506
+ seq: ++job.seq,
15507
+ action: entry?.action ?? null,
15508
+ path,
15509
+ relPath: entry ? this.relPathOf(job, path) : null,
15510
+ label: entry?.label ?? raw.trim(),
15511
+ raw
15512
+ };
15513
+ job.rows.push(row);
15514
+ if (job.rows.length > ROW_CAP) {
15515
+ job.rows.splice(0, 1e3);
15516
+ job.truncated = true;
15517
+ }
15518
+ }
15519
+ /**
15520
+ * 只给工作区内的输出路径开放文件动作(不信任 CLI 文本直接当作目标)。
15521
+ * @author ddj 2026年09月24号
15522
+ * @param job 活动任务
15523
+ * @param path SVN 输出路径
15524
+ * @returns 工作区相对路径或 null
15525
+ */
15526
+ relPathOf(job, path) {
15527
+ const abs = resolve(job.cwd, path);
15528
+ const wcRel = relative(job.root, abs);
15529
+ const rel = relative(job.cwd, abs);
15530
+ if (isAbsolute(wcRel) || wcRel === ".." || wcRel.startsWith("..\\") || wcRel.startsWith("../")) return null;
15531
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith("..\\") || rel.startsWith("../")) return null;
15532
+ return rel.replace(/\\/g, "/") || null;
15533
+ }
15534
+ /**
15535
+ * 保留终态行供用户检查,取消不能误报成功。
15536
+ * @author ddj 2026年09月24号
15537
+ * @param job 活动任务
15538
+ * @param code 真实退出码
15539
+ */
15540
+ finish(job, code) {
15541
+ if (this.isFinal(job.phase)) return;
15542
+ if (job.pending.trim()) this.recordLine(job, job.pending, updateLineOf(job.pending));
15543
+ job.pending = "";
15544
+ if (job.timer) {
15545
+ clearInterval(job.timer);
15546
+ job.timer = void 0;
15547
+ }
15548
+ if (job.deadline) {
15549
+ clearTimeout(job.deadline);
15550
+ job.deadline = void 0;
15551
+ }
15552
+ job.endedAt = this.now();
15553
+ if (job.cancelled) job.phase = "cancelled";
15554
+ else if (code === 0 && !job.error) job.phase = "completed";
15555
+ else {
15556
+ job.phase = "failed";
15557
+ job.error ||= "SVN 更新失败(退出码 " + String(code) + "):" + job.rawTail.slice(-500);
15558
+ }
15559
+ }
15560
+ /**
15561
+ * 快照不暴露可变数组;耗时使用现场时钟或终态时间。
15562
+ * @author ddj 2026年09月24号
15563
+ * @param job 目标任务
15564
+ * @returns 纯数据快照
15565
+ */
15566
+ stateOf(job) {
15567
+ return {
15568
+ jobId: job.id,
15569
+ target: job.target,
15570
+ phase: job.phase,
15571
+ counts: { ...job.counts },
15572
+ revision: job.revision,
15573
+ elapsedMs: Math.max(0, (job.endedAt ?? this.now()) - job.startedAt),
15574
+ error: job.error,
15575
+ rawTail: this.isFinal(job.phase) || job.truncated ? job.rawTail : "",
15576
+ truncated: job.truncated,
15577
+ nextSeq: job.seq
15578
+ };
15579
+ }
15580
+ /**
15581
+ * 任务 ID 必须属于调用会话的工作副本根。
15582
+ * @author ddj 2026年09月24号
15583
+ * @param root 已验证工作副本根
15584
+ * @param jobId 客户端任务 id
15585
+ * @returns job
15586
+ */
15587
+ getJob(root, jobId) {
15588
+ this.prune();
15589
+ const job = this.jobs.get(rootKey(root, this.platform));
15590
+ if (!job || job.id !== jobId) throw new Error("SVN 更新任务不存在或工作区不匹配");
15591
+ return job;
15592
+ }
15593
+ /**
15594
+ * 清理过期终态,不丢活动中的更新。
15595
+ * @author ddj 2026年09月24号
15596
+ */
15597
+ prune() {
15598
+ for (const [key, job] of this.jobs) if (job.endedAt !== null && this.now() - job.endedAt > RESULT_TTL_MS) this.jobs.delete(key);
15599
+ }
15600
+ /**
15601
+ * 终态判断集中定义,避免取消请求被误视作结束。
15602
+ * @author ddj 2026年09月24号
15603
+ * @param phase 当前阶段
15604
+ * @returns 是否结束
15605
+ */
15606
+ isFinal(phase) {
15607
+ return phase === "completed" || phase === "failed" || phase === "cancelled";
15608
+ }
15609
+ /**
15610
+ * 不启 shell:每个 argv 直接传入,标准输入忽略且提供进程清理预算。
15611
+ * @author ddj 2026年09月24号
15612
+ * @param argv 可执行文件及参数
15613
+ * @param cwd 目标工作目录
15614
+ * @returns DSH terminal spec
15615
+ */
15616
+ terminalSpec(argv, cwd) {
15617
+ return {
15618
+ argv,
15619
+ cwd,
15620
+ rows: 30,
15621
+ cols: 180,
15622
+ terminalType: "xterm-256color",
15623
+ graceMs: 1e4
15624
+ };
15625
+ }
15626
+ /**
15627
+ * collected reader 大于单次 RPC:后台按 100ms 消费,避免 1MB 尾部静默丢头。
15628
+ * @author ddj 2026年09月24号
15629
+ * @param argv 可执行文件及参数
15630
+ * @param cwd 目标工作目录
15631
+ * @returns DSH process spec
15632
+ */
15633
+ collectSpec(argv, cwd) {
15634
+ return {
15635
+ argv,
15636
+ cwd,
15637
+ graceMs: 1e4,
15638
+ stdio: {
15639
+ stdin: "ignore",
15640
+ stdout: { maxBytes: 4194304 },
15641
+ stderr: { maxBytes: 131072 }
15642
+ }
15643
+ };
15644
+ }
15645
+ };
15646
+ /** CLI 探测超时。 */
15647
+ const CLI_GRACE_MS = 6e3;
15648
+ /** svn update 超时(大工作副本留足预算)。 */
15649
+ const UPDATE_GRACE_MS = 18e4;
15650
+ /** TortoiseProc 发射超时预算(不 await done,仅约束服务侧资源)。 */
15651
+ const TORTOISE_GRACE_MS = 6e5;
15652
+ /** 前台置顶助手超时预算(轮询 ≤8s + PowerShell 启动余量)。 */
15653
+ const HELPER_GRACE_MS = 2e4;
15654
+ /** 输出载荷上限(1MB,对齐 runSvn stdio 限额;文本解析上限见 svnText.TEXT_OUTPUT_CAP)。 */
15655
+ const OUTPUT_CAP = TEXT_OUTPUT_CAP;
15656
+ /**
15657
+ * 日志拉取的 stdout 上限:默认窗口 LOG_CAP=500 条、Show All 上限 SVN_LOG_SHOW_ALL_CAP=5000 条。
15658
+ *
15659
+ * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 500` → 2.52MB / 2.0s,
15660
+ * `-l 5000` → 18.86MB / 30.1s(约 3.8~5KB/条,含中文路径与提交信息)。故取 64MB 留约 3× 余量,
15661
+ * `-g`(merged)单条更大也吃这份余量。
15662
+ * 实测教训(勿复踩):默认 1MB 上限时收集器保留**尾部**,Assets 全量 100 条(1.19MB)
15663
+ * 恰好把最新的 52 条挤出(用户报「显示的数据不对」)。
15664
+ * @author ddj 2026年09月17号 / 2026年09月18号
15665
+ */
15666
+ const LOG_OUTPUT_CAP = 67108864;
15667
+ /** 变更条目上限(防超大工作副本撑爆载荷)。 */
15668
+ const CHANGES_CAP = 4e3;
15669
+ /** `svn status --xml` 超时(大工作副本留足预算)。 */
15670
+ const STATUS_GRACE_MS = 12e4;
15671
+ /** 其他只读子命令(cat/info)超时。 */
15672
+ const READ_GRACE_MS = 3e4;
15673
+ /** revert/add 超时。 */
15674
+ const MUTATE_GRACE_MS = 12e4;
15675
+ /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
15676
+ const READ_CAP = 8388608;
15677
+ /**
15678
+ * W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
15679
+ * 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
15680
+ * 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
15681
+ */
15682
+ const PATCH_OUTPUT_CAP = 16777216;
15683
+ /** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
15684
+ const SUM_OUTPUT_CAP = 8388608;
15685
+ /**
15686
+ * W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
15687
+ * (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
15688
+ */
15689
+ const REMOTE_GRACE_MS = 15e3;
15690
+ /** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
15691
+ const SUM_GRACE_MS = 6e4;
15692
+ /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
15693
+ const SVN_ROOT_MAX_DEPTH = 10;
15694
+ /** 默认存在性探测(node fs;异常一律按不存在)。 */
15695
+ const defaultExists = async (path) => {
15696
+ try {
15697
+ await stat(path);
15698
+ return true;
15699
+ } catch {
15700
+ return false;
15701
+ }
15702
+ };
15703
+ /**
15704
+ * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
15705
+ * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
15706
+ * @author ddj 2026年09月16号
15707
+ * @param cwd 起始绝对路径
15708
+ * @param exists 存在性探测(测试注入)
15709
+ * @returns 工作副本根绝对路径;未命中返回 null
15710
+ */
15711
+ async function findSvnRoot(cwd, exists = defaultExists) {
15712
+ let dir = cwd;
15713
+ for (let depth = 0; dir && depth < SVN_ROOT_MAX_DEPTH; depth += 1) {
15714
+ if (await exists(join(dir, ".svn"))) return dir;
15715
+ const parent = dirname(dir);
15716
+ if (parent === dir) return null;
15717
+ dir = parent;
15718
+ }
15719
+ return null;
15720
+ }
15721
+ /**
15722
+ * 解析 TortoiseProc.exe(候选序:设置目录 → bin 子目录 → 默认安装目录 → PATH 逐项)。
15723
+ * @author ddj 2026年09月16号
15724
+ * @param dirSetting 设置里的 TortoiseSVN 目录(空 = 默认)
15725
+ * @param exists 存在性探测(测试注入)
15726
+ * @param platform 目标平台(非 win32 恒 null)
15727
+ * @param pathEnv PATH 环境变量(测试注入)
15728
+ * @returns TortoiseProc.exe 绝对路径;不可用返回 null
15729
+ */
15730
+ async function findTortoiseProc(dirSetting, exists = defaultExists, platform = process.platform, pathEnv = process.env?.PATH) {
15731
+ if (platform !== "win32") return null;
15732
+ const dir = dirSetting && dirSetting.trim() ? dirSetting.trim() : "";
15733
+ const candidates = [dir && join(dir, "bin", "TortoiseProc.exe"), dir && join(dir, "TortoiseProc.exe")].filter((item) => Boolean(item));
15734
+ for (const entry of (pathEnv ?? "").split(";")) {
15735
+ const trim = entry.trim();
15736
+ if (trim) candidates.push(join(trim, TORTOISE_EXE));
15737
+ }
15738
+ for (const candidate of candidates) if (await exists(candidate)) return candidate;
15739
+ return null;
15740
+ }
15741
+ /**
15742
+ * svn:ignore 名称合法性校验(host 权威;防换行注入 propset 值与路径穿越)。
15743
+ * 规则:去空白后非空、无换行、无路径分隔符、不以 `-` 开头(防被 svn 当选项解析)。
15744
+ * @author ddj 2026年09月23号
15745
+ * @param name 待写入的忽略名(basename / 通配模式)
15746
+ * @returns 错误文案;合法为 null
15747
+ */
15748
+ function ignoreNameErrorOf(name) {
15749
+ if (typeof name !== "string") return "忽略名必须是字符串";
15750
+ const trimmed = name.trim();
15751
+ if (!trimmed) return "忽略名不能为空";
15752
+ if (/[\r\n]/.test(trimmed)) return "忽略名不能包含换行";
15753
+ if (trimmed.includes("/") || trimmed.includes("\\")) return "忽略名不能包含路径分隔符";
15754
+ if (trimmed.startsWith("-")) return "忽略名不能以 - 开头";
15755
+ return null;
15756
+ }
15757
+ /**
15758
+ * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
15759
+ *
15760
+ * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
15761
+ * 避免自研项与官方同名项并列、用户误点到官方弹窗。
15762
+ * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
15763
+ * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
15764
+ */
15765
+ const SVN_FEATURES = [
15766
+ "diff",
15767
+ "revert",
15768
+ "log"
15769
+ ];
15770
+ /** `svn status --xml` 的 `<wc-status item="...">` 合法取值(未知值一律按 modified 兜底)。 */
15771
+ const KNOWN_ITEM_STATUS = /* @__PURE__ */ new Set([
15772
+ "normal",
15773
+ "added",
15774
+ "deleted",
15775
+ "replaced",
15776
+ "conflicted",
15777
+ "missing",
15778
+ "unversioned",
15779
+ "obstructed",
15780
+ "ignored",
15781
+ "modified",
15782
+ "external",
15783
+ "incomplete"
15784
+ ]);
15785
+ /** 受版本控制判据:unversioned/ignored 之外均为版本化条目。 */
15786
+ const UNVERSIONED_STATUS = /* @__PURE__ */ new Set(["unversioned", "ignored"]);
15787
+ /**
15788
+ * 归一化 XML 中的条目路径为工作区相对路径(`/` 分隔、无前导 `./`)。
15789
+ * XML 路径相对「执行 status 时的 cwd」(实测 Windows 下用 `\`),故 host 必须以
15790
+ * 工作副本根为 cwd 执行,这里只做分隔符与形态归一;`''`/`.` 表示根自身。
15791
+ * @author ddj 2026年09月16号
15792
+ * @param raw XML path 属性原文(已反转义)
14847
15793
  * @returns 归一化路径;根自身返回 ''
14848
15794
  */
14849
15795
  function changePathOf(raw) {
@@ -15100,6 +16046,20 @@ function safeRel(raw) {
15100
16046
  return rel;
15101
16047
  }
15102
16048
  /**
16049
+ * 会话根可在 SVN 工作副本的子目录;CLI 在 wcRoot 执行时必须换算目标坐标。
16050
+ * @author ddj 2026年09月24号
16051
+ * @param cwd 当前会话目录
16052
+ * @param wcRoot SVN 工作副本根
16053
+ * @param rel 会话相对目标
16054
+ * @returns SVN 根相对目标;越界返回 null
16055
+ */
16056
+ function svnPathOf(cwd, wcRoot, rel) {
16057
+ if (safeRel(rel) === null) return null;
16058
+ const wcRel = relative(wcRoot, resolve(cwd, rel || "."));
16059
+ if (isAbsolute(wcRel) || wcRel === ".." || wcRel.startsWith("../") || wcRel.startsWith("..\\")) return null;
16060
+ return wcRel.replace(/\\/g, "/") || ".";
16061
+ }
16062
+ /**
15103
16063
  * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
15104
16064
  * @author ddj 2026年09月16号
15105
16065
  * @param deps ctx + 设置读取面 + 可注入时钟
@@ -15111,6 +16071,8 @@ function createSvnRpc(deps) {
15111
16071
  const findRoot = deps.findRoot ?? ((cwd) => findSvnRoot(cwd));
15112
16072
  const findTortoise = deps.findTortoise ?? ((dir) => findTortoiseProc(dir));
15113
16073
  const statusCache = /* @__PURE__ */ new Map();
16074
+ /** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
16075
+ const updateRootCache = /* @__PURE__ */ new Map();
15114
16076
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
15115
16077
  const changesCache = /* @__PURE__ */ new Map();
15116
16078
  /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
@@ -15118,6 +16080,7 @@ function createSvnRpc(deps) {
15118
16080
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
15119
16081
  const urlCache = /* @__PURE__ */ new Map();
15120
16082
  let cliProbe = null;
16083
+ const updateJobs = new SvnUpdateJobs(() => ctx.get("subprocess"), deps.platform ?? process.platform, now);
15121
16084
  const subprocess = () => {
15122
16085
  const sub = ctx.get("subprocess");
15123
16086
  if (!sub || typeof sub.spawn !== "function") return null;
@@ -15129,24 +16092,41 @@ function createSvnRpc(deps) {
15129
16092
  if (cliProbe && cliProbe.exe === exe && at - cliProbe.at < 3e5) return cliProbe.ok;
15130
16093
  let ok = false;
15131
16094
  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;
16095
+ if (spawn) {
16096
+ const controller = new AbortController();
16097
+ const deadline = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("SVN CLI 探测超时")), CLI_GRACE_MS);
16098
+ deadline.unref?.();
16099
+ try {
16100
+ const handle = spawn({
16101
+ argv: [
16102
+ exe,
16103
+ "--version",
16104
+ "--quiet"
16105
+ ],
16106
+ cwd: tmpdir(),
16107
+ stdio: {
16108
+ stdout: "inherit",
16109
+ stderr: "inherit",
16110
+ stdin: "ignore"
16111
+ },
16112
+ graceMs: CLI_GRACE_MS,
16113
+ signal: controller.signal
16114
+ });
16115
+ const expired = new Promise((_, reject) => {
16116
+ controller.signal.addEventListener("abort", () => {
16117
+ try {
16118
+ handle.terminate?.();
16119
+ } catch {}
16120
+ reject(controller.signal.reason);
16121
+ }, { once: true });
16122
+ });
16123
+ const outcome = await Promise.race([handle.done, expired]);
16124
+ ok = (outcome?.exitCode ?? outcome?.code) === 0;
16125
+ } catch {
16126
+ ok = false;
16127
+ } finally {
16128
+ clearTimeout(deadline);
16129
+ }
15150
16130
  }
15151
16131
  cliProbe = {
15152
16132
  at,
@@ -15181,6 +16161,43 @@ function createSvnRpc(deps) {
15181
16161
  return payload;
15182
16162
  };
15183
16163
  /**
16164
+ * 验证更新请求属于当前会话的工作副本;取消/轮询不依赖 CLI 仍可用。
16165
+ * @author ddj 2026年09月24号
16166
+ * @param sessionId 当前会话 id
16167
+ * @param needCli 仅真正启动更新时才探测 SVN 客户端
16168
+ * @returns 已校验的目录和状态,或错误
16169
+ */
16170
+ const updateScope = async (sessionId, needCli = false) => {
16171
+ const sc = await requireSvnSession(ctx, sessionId);
16172
+ if ("err" in sc) return { err: sc.err };
16173
+ if (!needCli) {
16174
+ const cached = updateRootCache.get(sc.cwd);
16175
+ const root = cached && now() - cached.at < 6e4 ? cached.root : await findRoot(sc.cwd);
16176
+ if (root && (!cached || now() - cached.at >= 6e4)) updateRootCache.set(sc.cwd, {
16177
+ at: now(),
16178
+ root
16179
+ });
16180
+ return root ? {
16181
+ cwd: sc.cwd,
16182
+ root,
16183
+ exe: "",
16184
+ cli: false
16185
+ } : { err: "当前工作区不受 SVN 管理" };
16186
+ }
16187
+ const status = await statusOf(sc.cwd);
16188
+ if (!status.managed || !status.wcRoot) return { err: "当前工作区不受 SVN 管理" };
16189
+ updateRootCache.set(sc.cwd, {
16190
+ at: now(),
16191
+ root: status.wcRoot
16192
+ });
16193
+ return {
16194
+ cwd: sc.cwd,
16195
+ root: status.wcRoot,
16196
+ exe: status.svnPath,
16197
+ cli: status.svnCli
16198
+ };
16199
+ };
16200
+ /**
15184
16201
  * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
15185
16202
  * @author ddj 2026年08月26号 / 2026年09月17号
15186
16203
  * @param cwd 运行目录
@@ -15601,979 +16618,1080 @@ function createSvnRpc(deps) {
15601
16618
  output: [...notes, ...failures].join("\n")
15602
16619
  };
15603
16620
  };
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);
16621
+ return {
16622
+ handlers: {
16623
+ "svn.status": async (args) => {
16624
+ const sc = await requireSvnSession(ctx, args.sessionId);
16625
+ if ("err" in sc) return {
16626
+ ok: false,
16627
+ error: sc.err
16628
+ };
15633
16629
  return {
15634
16630
  ok: true,
15635
- wcRoot: status.wcRoot,
15636
- ...result
16631
+ ...await statusOf(sc.cwd, args.force === true)
16632
+ };
16633
+ },
16634
+ "svn.changes": async (args) => {
16635
+ const sc = await requireSvnSession(ctx, args.sessionId);
16636
+ if ("err" in sc) return {
16637
+ ok: false,
16638
+ error: sc.err
16639
+ };
16640
+ const status = await statusOf(sc.cwd);
16641
+ if (!status.managed || !status.wcRoot) return {
16642
+ ok: false,
16643
+ error: "当前工作区不受 SVN 管理"
16644
+ };
16645
+ if (!status.svnCli) return {
16646
+ ok: false,
16647
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16648
+ };
16649
+ try {
16650
+ const result = await changesOf(status.wcRoot, args.force === true);
16651
+ return {
16652
+ ok: true,
16653
+ wcRoot: status.wcRoot,
16654
+ ...result
16655
+ };
16656
+ } catch (error) {
16657
+ return {
16658
+ ok: false,
16659
+ error: "读取变更失败:" + String(error)
16660
+ };
16661
+ }
16662
+ },
16663
+ "svn.diffBase": async (args) => {
16664
+ const sc = await requireSvnSession(ctx, args.sessionId);
16665
+ if ("err" in sc) return {
16666
+ ok: false,
16667
+ error: sc.err
16668
+ };
16669
+ const rel = safeRel(args.path);
16670
+ if (rel === null || rel === "") return {
16671
+ ok: false,
16672
+ error: "路径不合法"
16673
+ };
16674
+ const status = await statusOf(sc.cwd);
16675
+ if (!status.managed || !status.wcRoot) return {
16676
+ ok: false,
16677
+ error: "当前工作区不受 SVN 管理"
16678
+ };
16679
+ if (!status.svnCli) return {
16680
+ ok: false,
16681
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16682
+ };
16683
+ const fs = ctx.get("fs");
16684
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
16685
+ ok: false,
16686
+ error: "缺少 fs"
16687
+ };
16688
+ try {
16689
+ const target = await fs.resolve(rel, { cwd: sc.cwd });
16690
+ const info = await fs.stat(target);
16691
+ if (!info || info.type !== "file") return {
16692
+ ok: false,
16693
+ error: "文件不存在"
16694
+ };
16695
+ if ((info.size ?? 0) > READ_CAP) return {
16696
+ ok: false,
16697
+ error: "文件过大(>8MB),不支持基线差异"
16698
+ };
16699
+ const working = await fs.readText(target);
16700
+ const outcome = await runSvn(status.wcRoot, [
16701
+ "cat",
16702
+ "-r",
16703
+ "BASE",
16704
+ "--",
16705
+ rel
16706
+ ], READ_GRACE_MS);
16707
+ if (outcome.code !== 0) {
16708
+ const text = (outcome.stderr || outcome.stdout).trim();
16709
+ return {
16710
+ ok: true,
16711
+ base: null,
16712
+ working,
16713
+ reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
16714
+ error: tailOfText(text),
16715
+ ...guardSides(null, working)
16716
+ };
16717
+ }
16718
+ return {
16719
+ ok: true,
16720
+ base: outcome.stdout,
16721
+ working,
16722
+ ...guardSides(outcome.stdout, working)
16723
+ };
16724
+ } catch (error) {
16725
+ return {
16726
+ ok: false,
16727
+ error: "读取基线失败:" + String(error)
16728
+ };
16729
+ }
16730
+ },
16731
+ "svn.revert": async (args) => {
16732
+ return mutate("还原", ["revert", "-R"], args.sessionId, args.paths);
16733
+ },
16734
+ "svn.add": async (args) => {
16735
+ return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
16736
+ },
16737
+ /**
16738
+ * 分区(changelist)批量操作:关联或解除工作副本条目的 changelist 登记。
16739
+ * 复用 mutate 全套护栏(路径白名单 / BATCH_PATHS_CAP / 超时 / 变更缓存作废);
16740
+ * 分区名 host 权威校验(`-` 开头会被 svn 当成选项解析),remove 分支不需要名字。
16741
+ * @author ddj 2026年09月23号
16742
+ * @param args.paths 工作区相对路径列表
16743
+ * @param args.name 目标分区名(remove 时忽略)
16744
+ * @param args.remove true = 移出分区(`changelist --remove`)
16745
+ * @returns 批量动作结果
16746
+ */
16747
+ "svn.changelist": async (args) => {
16748
+ if (args.remove !== true) {
16749
+ const nameError = svnChangelistNameErrorOf(String(args.name ?? ""));
16750
+ if (nameError) return {
16751
+ ok: false,
16752
+ error: nameError
16753
+ };
16754
+ }
16755
+ return mutate(args.remove === true ? "移出分区" : "分区", args.remove === true ? ["changelist", "--remove"] : ["changelist", String(args.name ?? "").trim()], args.sessionId, args.paths);
16756
+ },
16757
+ "svn.log": async (args) => {
16758
+ const sc = await requireSvnSession(ctx, args.sessionId);
16759
+ if ("err" in sc) return {
16760
+ ok: false,
16761
+ error: sc.err
16762
+ };
16763
+ const rel = safeRel(args.path);
16764
+ if (rel === null) return {
16765
+ ok: false,
16766
+ error: "路径不合法"
16767
+ };
16768
+ const status = await statusOf(sc.cwd);
16769
+ if (!status.managed || !status.wcRoot) return {
16770
+ ok: false,
16771
+ error: "当前工作区不受 SVN 管理"
16772
+ };
16773
+ if (!status.svnCli) return {
16774
+ ok: false,
16775
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16776
+ };
16777
+ const svnTarget = svnPathOf(sc.cwd, status.wcRoot, rel);
16778
+ if (svnTarget === null) return {
16779
+ ok: false,
16780
+ error: "日志路径不在 SVN 工作副本内"
16781
+ };
16782
+ try {
16783
+ return {
16784
+ ok: true,
16785
+ ...await logOf(status.wcRoot, svnTarget === "." ? "" : svnTarget, args.limit ?? 100, {
16786
+ stopOnCopy: args.stopOnCopy === true,
16787
+ showMerged: args.showMerged === true,
16788
+ range: args.startRev && args.endRev ? {
16789
+ start: Math.floor(args.startRev),
16790
+ end: Math.floor(args.endRev)
16791
+ } : null
16792
+ }, SVN_LOG_SHOW_ALL_CAP),
16793
+ target: rel
16794
+ };
16795
+ } catch (error) {
16796
+ return {
16797
+ ok: false,
16798
+ error: "读取日志失败:" + String(error)
16799
+ };
16800
+ }
16801
+ },
16802
+ "svn.diffRev": async (args) => {
16803
+ const sc = await requireSvnSession(ctx, args.sessionId);
16804
+ if ("err" in sc) return {
16805
+ ok: false,
16806
+ error: sc.err
15637
16807
  };
15638
- } catch (error) {
15639
- return {
16808
+ const rel = safeRel(args.path);
16809
+ if (rel === null || rel === "") return {
15640
16810
  ok: false,
15641
- error: "读取变更失败:" + String(error)
16811
+ error: "路径不合法"
15642
16812
  };
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 {
16813
+ const revision = Number(args.revision);
16814
+ if (!Number.isFinite(revision) || revision < 1) return {
15674
16815
  ok: false,
15675
- error: "文件不存在"
16816
+ error: "版本号不合法"
15676
16817
  };
15677
- if ((info.size ?? 0) > READ_CAP) return {
16818
+ const status = await statusOf(sc.cwd);
16819
+ if (!status.managed || !status.wcRoot) return {
15678
16820
  ok: false,
15679
- error: "文件过大(>8MB),不支持基线差异"
16821
+ error: "当前工作区不受 SVN 管理"
15680
16822
  };
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();
16823
+ if (!status.svnCli) return {
16824
+ ok: false,
16825
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16826
+ };
16827
+ try {
16828
+ const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision));
15691
16829
  return {
15692
16830
  ok: true,
15693
- base: null,
15694
- working,
15695
- reason: /pristine|E200009/i.test(text) ? "no-pristine" : "cat-failed",
15696
- error: tailOfText(text),
16831
+ ...result,
16832
+ ...guardSides(result.left, result.right)
16833
+ };
16834
+ } catch (error) {
16835
+ return {
16836
+ ok: false,
16837
+ error: "读取版本差异失败:" + String(error)
16838
+ };
16839
+ }
16840
+ },
16841
+ "svn.diffPair": async (args) => {
16842
+ const sc = await requireSvnSession(ctx, args.sessionId);
16843
+ if ("err" in sc) return {
16844
+ ok: false,
16845
+ error: sc.err
16846
+ };
16847
+ const rel = safeRel(args.path);
16848
+ if (rel === null || rel === "") return {
16849
+ ok: false,
16850
+ error: "路径不合法"
16851
+ };
16852
+ const revA = Number(args.revA);
16853
+ const revB = Number(args.revB);
16854
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
16855
+ ok: false,
16856
+ error: "版本号不合法"
16857
+ };
16858
+ const status = await statusOf(sc.cwd);
16859
+ if (!status.managed || !status.wcRoot) return {
16860
+ ok: false,
16861
+ error: "当前工作区不受 SVN 管理"
16862
+ };
16863
+ if (!status.svnCli) return {
16864
+ ok: false,
16865
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16866
+ };
16867
+ try {
16868
+ const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB));
16869
+ return {
16870
+ ok: true,
16871
+ ...result,
16872
+ ...guardSides(result.left, result.right)
16873
+ };
16874
+ } catch (error) {
16875
+ return {
16876
+ ok: false,
16877
+ error: "读取版本差异失败:" + String(error)
16878
+ };
16879
+ }
16880
+ },
16881
+ "svn.diffWorking": async (args) => {
16882
+ const sc = await requireSvnSession(ctx, args.sessionId);
16883
+ if ("err" in sc) return {
16884
+ ok: false,
16885
+ error: sc.err
16886
+ };
16887
+ const rel = safeRel(args.path);
16888
+ if (rel === null || rel === "") return {
16889
+ ok: false,
16890
+ error: "路径不合法"
16891
+ };
16892
+ const revision = Number(args.revision);
16893
+ if (!Number.isFinite(revision) || revision < 1) return {
16894
+ ok: false,
16895
+ error: "版本号不合法"
16896
+ };
16897
+ const status = await statusOf(sc.cwd);
16898
+ if (!status.managed || !status.wcRoot) return {
16899
+ ok: false,
16900
+ error: "当前工作区不受 SVN 管理"
16901
+ };
16902
+ if (!status.svnCli) return {
16903
+ ok: false,
16904
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16905
+ };
16906
+ const svnRel = svnPathOf(sc.cwd, status.wcRoot, rel);
16907
+ if (!svnRel) return {
16908
+ ok: false,
16909
+ error: "比较路径不在 SVN 工作副本内"
16910
+ };
16911
+ const fs = ctx.get("fs");
16912
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
16913
+ ok: false,
16914
+ error: "缺少 fs"
16915
+ };
16916
+ try {
16917
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
16918
+ const info = await fs.stat(resolved);
16919
+ if (!info || info.type !== "file") return {
16920
+ ok: false,
16921
+ error: "文件不存在"
16922
+ };
16923
+ if ((info.size ?? 0) > READ_CAP) return {
16924
+ ok: false,
16925
+ error: "文件过大(>8MB),不支持与工作副本比较"
16926
+ };
16927
+ const working = await fs.readText(resolved);
16928
+ const leftOutcome = await runSvn(status.wcRoot, [
16929
+ "cat",
16930
+ "-r",
16931
+ String(Math.floor(revision)),
16932
+ "--",
16933
+ svnRel
16934
+ ], READ_GRACE_MS);
16935
+ if (leftOutcome.code !== 0) return {
16936
+ ok: true,
16937
+ left: null,
16938
+ right: working,
16939
+ reason: "not-exist",
16940
+ error: tailOfText((leftOutcome.stderr || leftOutcome.stdout).trim()),
15697
16941
  ...guardSides(null, working)
15698
16942
  };
16943
+ return {
16944
+ ok: true,
16945
+ left: leftOutcome.stdout,
16946
+ right: working,
16947
+ ...guardSides(leftOutcome.stdout, working)
16948
+ };
16949
+ } catch (error) {
16950
+ return {
16951
+ ok: false,
16952
+ error: "读取工作副本差异失败:" + String(error)
16953
+ };
15699
16954
  }
15700
- return {
15701
- ok: true,
15702
- base: outcome.stdout,
15703
- working,
15704
- ...guardSides(outcome.stdout, working)
16955
+ },
16956
+ "svn.patchText": async (args) => {
16957
+ const sc = await requireSvnSession(ctx, args.sessionId);
16958
+ if ("err" in sc) return {
16959
+ ok: false,
16960
+ error: sc.err
15705
16961
  };
15706
- } catch (error) {
15707
- return {
16962
+ const rel = safeRel(args.path);
16963
+ if (rel === null || rel === "") return {
15708
16964
  ok: false,
15709
- error: "读取基线失败:" + String(error)
16965
+ error: "路径不合法"
15710
16966
  };
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 {
16967
+ const status = await statusOf(sc.cwd);
16968
+ if (!status.managed || !status.wcRoot) return {
15733
16969
  ok: false,
15734
- error: nameError
16970
+ error: "当前工作区不受 SVN 管理"
15735
16971
  };
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
16972
+ if (!status.svnCli) return {
16973
+ ok: false,
16974
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
15771
16975
  };
15772
- } catch (error) {
15773
- return {
16976
+ try {
16977
+ const ext = patchExtOf({
16978
+ whitespace: args.whitespace,
16979
+ ignoreEol: args.ignoreEol === true,
16980
+ unified: args.unified
16981
+ });
16982
+ const argv = [
16983
+ "diff",
16984
+ ...ext ? ["-x", ext] : [],
16985
+ "--",
16986
+ rel
16987
+ ];
16988
+ const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP);
16989
+ if (outcome.code !== 0) return {
16990
+ ok: false,
16991
+ error: "生成补丁失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
16992
+ };
16993
+ const text = outcome.stdout;
16994
+ const head = text.slice(0, 200);
16995
+ return {
16996
+ ok: true,
16997
+ text,
16998
+ truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
16999
+ binary: text.includes("Cannot display:")
17000
+ };
17001
+ } catch (error) {
17002
+ return {
17003
+ ok: false,
17004
+ error: "生成补丁失败:" + String(error)
17005
+ };
17006
+ }
17007
+ },
17008
+ "svn.diffSum": async (args) => {
17009
+ const sc = await requireSvnSession(ctx, args.sessionId);
17010
+ if ("err" in sc) return {
15774
17011
  ok: false,
15775
- error: "读取日志失败:" + String(error)
17012
+ error: sc.err
15776
17013
  };
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)
17014
+ const rel = safeRel(args.path);
17015
+ if (rel === null) return {
17016
+ ok: false,
17017
+ error: "路径不合法"
15810
17018
  };
15811
- } catch (error) {
15812
- return {
17019
+ const revA = Number(args.revA);
17020
+ const revB = Number(args.revB);
17021
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) return {
15813
17022
  ok: false,
15814
- error: "读取版本差异失败:" + String(error)
17023
+ error: "版本号不合法"
15815
17024
  };
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)
17025
+ const status = await statusOf(sc.cwd);
17026
+ if (!status.managed || !status.wcRoot) return {
17027
+ ok: false,
17028
+ error: "当前工作区不受 SVN 管理"
17029
+ };
17030
+ if (!status.svnCli) return {
17031
+ ok: false,
17032
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17033
+ };
17034
+ try {
17035
+ const argv = [
17036
+ "diff",
17037
+ "--summarize",
17038
+ "--xml",
17039
+ "-r",
17040
+ String(Math.floor(revA)) + ":" + String(Math.floor(revB))
17041
+ ];
17042
+ if (rel) argv.push("--", rel);
17043
+ const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP);
17044
+ if (outcome.code !== 0) return {
17045
+ ok: false,
17046
+ error: "目录对比失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17047
+ };
17048
+ return {
17049
+ ok: true,
17050
+ ...parseSummarizeXml(outcome.stdout, status.wcRoot)
17051
+ };
17052
+ } catch (error) {
17053
+ return {
17054
+ ok: false,
17055
+ error: "目录对比失败:" + String(error)
17056
+ };
17057
+ }
17058
+ },
17059
+ "svn.remoteStatus": async (args) => {
17060
+ const sc = await requireSvnSession(ctx, args.sessionId);
17061
+ if ("err" in sc) return {
17062
+ ok: false,
17063
+ error: sc.err
17064
+ };
17065
+ const rel = safeRel(args.path);
17066
+ if (rel === null) return {
17067
+ ok: false,
17068
+ error: "路径不合法"
17069
+ };
17070
+ const status = await statusOf(sc.cwd);
17071
+ if (!status.managed || !status.wcRoot) return {
17072
+ ok: false,
17073
+ error: "当前工作区不受 SVN 管理"
17074
+ };
17075
+ if (!status.svnCli) return {
17076
+ ok: false,
17077
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17078
+ };
17079
+ try {
17080
+ const argv = [
17081
+ "status",
17082
+ "-u",
17083
+ "--xml"
17084
+ ];
17085
+ if (rel) argv.push("--", rel);
17086
+ const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP);
17087
+ if (outcome.code !== 0) return {
17088
+ ok: false,
17089
+ error: "远端检查失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17090
+ };
17091
+ return {
17092
+ ok: true,
17093
+ ...parseRemoteStatusXml(outcome.stdout, status.wcRoot)
17094
+ };
17095
+ } catch (error) {
17096
+ return {
17097
+ ok: false,
17098
+ error: "远端检查失败:" + String(error)
17099
+ };
17100
+ }
17101
+ },
17102
+ "svn.conflictArtifacts": async (args) => {
17103
+ const sc = await requireSvnSession(ctx, args.sessionId);
17104
+ if ("err" in sc) return {
17105
+ ok: false,
17106
+ error: sc.err
17107
+ };
17108
+ const rel = safeRel(args.path);
17109
+ if (rel === null || rel === "") return {
17110
+ ok: false,
17111
+ error: "路径不合法"
17112
+ };
17113
+ const status = await statusOf(sc.cwd);
17114
+ if (!status.managed || !status.wcRoot) return {
17115
+ ok: false,
17116
+ error: "当前工作区不受 SVN 管理"
17117
+ };
17118
+ try {
17119
+ return {
17120
+ ok: true,
17121
+ artifacts: await conflictArtifactsOf(status.wcRoot, rel)
17122
+ };
17123
+ } catch (error) {
17124
+ return {
17125
+ ok: false,
17126
+ error: "扫描冲突副本失败:" + String(error)
17127
+ };
17128
+ }
17129
+ },
17130
+ "svn.diffLocalPair": async (args) => {
17131
+ const sc = await requireSvnSession(ctx, args.sessionId);
17132
+ if ("err" in sc) return {
17133
+ ok: false,
17134
+ error: sc.err
15850
17135
  };
15851
- } catch (error) {
15852
- return {
17136
+ const left = safeRel(args.left);
17137
+ const right = safeRel(args.right);
17138
+ if (!left || !right) return {
15853
17139
  ok: false,
15854
- error: "读取版本差异失败:" + String(error)
17140
+ error: "路径不合法(需工作副本内相对路径)"
15855
17141
  };
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 {
17142
+ const status = await statusOf(sc.cwd);
17143
+ if (!status.managed || !status.wcRoot) return {
15892
17144
  ok: false,
15893
- error: "文件不存在"
17145
+ error: "当前工作区不受 SVN 管理"
15894
17146
  };
15895
- if ((info.size ?? 0) > READ_CAP) return {
17147
+ const fs = ctx.get("fs");
17148
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
15896
17149
  ok: false,
15897
- error: "文件过大(>8MB),不支持与工作副本比较"
17150
+ error: "缺少 fs"
15898
17151
  };
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)
17152
+ try {
17153
+ const sides = [];
17154
+ for (const rel of [left, right]) {
17155
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
17156
+ const info = await fs.stat(resolved);
17157
+ if (!info || info.type !== "file") return {
17158
+ ok: false,
17159
+ error: "文件不存在:" + rel
17160
+ };
17161
+ if ((info.size ?? 0) > READ_CAP) return {
17162
+ ok: false,
17163
+ error: "文件过大(>8MB):" + rel
17164
+ };
17165
+ sides.push(await fs.readText(resolved));
17166
+ }
17167
+ return {
17168
+ ok: true,
17169
+ left: sides[0],
17170
+ right: sides[1],
17171
+ ...guardSides(sides[0], sides[1])
17172
+ };
17173
+ } catch (error) {
17174
+ return {
17175
+ ok: false,
17176
+ error: "读取冲突副本失败:" + String(error)
17177
+ };
17178
+ }
17179
+ },
17180
+ "svn.fileSizes": async (args) => {
17181
+ const sc = await requireSvnSession(ctx, args.sessionId);
17182
+ if ("err" in sc) return {
17183
+ ok: false,
17184
+ error: sc.err
15914
17185
  };
15915
- return {
17186
+ const list = Array.isArray(args.paths) ? args.paths.slice(0, 200) : [];
17187
+ if (!list.length) return {
15916
17188
  ok: true,
15917
- left: leftOutcome.stdout,
15918
- right: working,
15919
- ...guardSides(leftOutcome.stdout, working)
17189
+ sizes: []
15920
17190
  };
15921
- } catch (error) {
15922
- return {
17191
+ const fs = ctx.get("fs");
17192
+ if (!fs?.resolve || !fs?.stat) return {
15923
17193
  ok: false,
15924
- error: "读取工作副本差异失败:" + String(error)
17194
+ error: "缺少 fs"
15925
17195
  };
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"))
17196
+ const sizeOne = async (raw) => {
17197
+ const rel = safeRel(raw);
17198
+ if (rel === null || rel === "") return {
17199
+ path: String(raw ?? ""),
17200
+ size: null
17201
+ };
17202
+ try {
17203
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd });
17204
+ const info = await fs.stat(resolved);
17205
+ return {
17206
+ path: rel,
17207
+ size: info && info.type === "file" ? info.size ?? null : null
17208
+ };
17209
+ } catch {
17210
+ return {
17211
+ path: rel,
17212
+ size: null
17213
+ };
17214
+ }
15964
17215
  };
15965
- const text = outcome.stdout;
15966
- const head = text.slice(0, 200);
17216
+ const sizes = [];
17217
+ const BATCH = 16;
17218
+ for (let start = 0; start < list.length; start += BATCH) sizes.push(...await Promise.all(list.slice(start, start + BATCH).map(sizeOne)));
15967
17219
  return {
15968
17220
  ok: true,
15969
- text,
15970
- truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
15971
- binary: text.includes("Cannot display:")
17221
+ sizes
15972
17222
  };
15973
- } catch (error) {
15974
- return {
17223
+ },
17224
+ /**
17225
+ * AI 智能整理分析(只读):拉最新变更 → 附 diff 上下文(超限降级 paths-only)→
17226
+ * LLM 流式产出 JSON → normalizeAiPlan 以真实清单白名单归一。任何失败零执行只报错。
17227
+ * @author ddj 2026年09月23号
17228
+ * @param args.sessionId 会话 id
17229
+ * @returns 三段方案 + 分析元信息(条目数/是否含 diff/丢弃数/模型)
17230
+ */
17231
+ "svn.aiPlan": async (args) => {
17232
+ const sc = await requireSvnSession(ctx, args.sessionId);
17233
+ if ("err" in sc) return {
15975
17234
  ok: false,
15976
- error: "生成补丁失败:" + String(error)
17235
+ error: sc.err
15977
17236
  };
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 {
17237
+ const status = await statusOf(sc.cwd);
17238
+ if (!status.managed || !status.wcRoot) return {
16017
17239
  ok: false,
16018
- error: "目录对比失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17240
+ error: "当前工作区不受 SVN 管理"
16019
17241
  };
16020
- return {
16021
- ok: true,
16022
- ...parseSummarizeXml(outcome.stdout, status.wcRoot)
17242
+ if (!status.svnCli) return {
17243
+ ok: false,
17244
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16023
17245
  };
16024
- } catch (error) {
16025
- return {
17246
+ try {
17247
+ const { entries } = await changesOf(status.wcRoot, true);
17248
+ let diffText = null;
17249
+ try {
17250
+ const outcome = await runSvn(status.wcRoot, [
17251
+ "diff",
17252
+ "-x",
17253
+ "-U0"
17254
+ ], READ_GRACE_MS, AI_PLAN_DIFF_CAP);
17255
+ if (outcome.code === 0 && outcome.stdout.trim() && outcome.stdout.length < 2097152) diffText = outcome.stdout;
17256
+ } catch {}
17257
+ const capped = entries.length > AI_PLAN_PATHS_CAP;
17258
+ const used = capped ? entries.slice(0, AI_PLAN_PATHS_CAP) : entries;
17259
+ const diffIncluded = diffText !== null && !capped;
17260
+ const prompt = buildAiPrompt(used, diffIncluded ? diffText : null, capped);
17261
+ const cfg = settings.ai ? settings.ai() : {
17262
+ enabled: true,
17263
+ provider: "",
17264
+ model: "",
17265
+ effort: ""
17266
+ };
17267
+ const result = await svnAiPlanOf(ctx, cfg, prompt);
17268
+ const { plan, dropped } = normalizeAiPlan(result.raw, entries);
17269
+ return {
17270
+ ok: true,
17271
+ plan,
17272
+ entriesCount: entries.length,
17273
+ diffIncluded,
17274
+ dropped,
17275
+ model: result.model
17276
+ };
17277
+ } catch (error) {
17278
+ return {
17279
+ ok: false,
17280
+ error: String(error instanceof Error ? error.message : error)
17281
+ };
17282
+ }
17283
+ },
17284
+ /**
17285
+ * AI 智能整理执行段③:按目录合并写入 svn:ignore 属性(propget 合并,不整体覆盖)。
17286
+ * 复用受管理/svnCli 校验与变更缓存作废语义(对齐 mutate)。
17287
+ * @author ddj 2026年09月23号
17288
+ * @param args.items 目录聚合写入项(dir + 该目录下新增忽略名)
17289
+ * @returns 新增计数/摘要/原文
17290
+ */
17291
+ "svn.ignore": async (args) => {
17292
+ const sc = await requireSvnSession(ctx, args.sessionId);
17293
+ if ("err" in sc) return {
16026
17294
  ok: false,
16027
- error: "目录对比失败:" + String(error)
17295
+ error: sc.err
16028
17296
  };
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 {
17297
+ const items = Array.isArray(args.items) ? args.items : [];
17298
+ if (!items.length) return {
16060
17299
  ok: false,
16061
- error: "远端检查失败:" + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join("\n"))
17300
+ error: "未选择任何路径"
16062
17301
  };
16063
- return {
16064
- ok: true,
16065
- ...parseRemoteStatusXml(outcome.stdout, status.wcRoot)
17302
+ const clean = [];
17303
+ let total = 0;
17304
+ for (const item of items) {
17305
+ const rel = safeRel(item?.dir);
17306
+ if (rel === null) return {
17307
+ ok: false,
17308
+ error: "路径不合法"
17309
+ };
17310
+ const names = Array.isArray(item?.names) ? item.names : [];
17311
+ if (!names.length) return {
17312
+ ok: false,
17313
+ error: "忽略名列表为空"
17314
+ };
17315
+ const checked = [];
17316
+ for (const name of names) {
17317
+ const nameError = ignoreNameErrorOf(name);
17318
+ if (nameError) return {
17319
+ ok: false,
17320
+ error: nameError
17321
+ };
17322
+ checked.push(String(name).trim());
17323
+ }
17324
+ total += checked.length;
17325
+ if (total > 64) return {
17326
+ ok: false,
17327
+ error: "一次最多处理 64 个路径"
17328
+ };
17329
+ clean.push({
17330
+ dir: rel === "" ? "." : rel,
17331
+ names: checked
17332
+ });
17333
+ }
17334
+ const status = await statusOf(sc.cwd);
17335
+ if (!status.managed || !status.wcRoot) return {
17336
+ ok: false,
17337
+ error: "当前工作区不受 SVN 管理"
16066
17338
  };
16067
- } catch (error) {
16068
- return {
17339
+ if (!status.svnCli) return {
16069
17340
  ok: false,
16070
- error: "远端检查失败:" + String(error)
17341
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16071
17342
  };
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)
17343
+ try {
17344
+ const result = await ignoreApplyOf(status.wcRoot, clean);
17345
+ changesCache.delete(status.wcRoot);
17346
+ if (!result.ok) return {
17347
+ ok: false,
17348
+ error: result.summary + (result.output ? "\n" + tailOfText(result.output) : "")
17349
+ };
17350
+ return {
17351
+ ok: true,
17352
+ count: result.count,
17353
+ summary: result.summary,
17354
+ output: result.output
17355
+ };
17356
+ } catch (error) {
17357
+ return {
17358
+ ok: false,
17359
+ error: "svn:ignore 写入失败:" + String(error)
17360
+ };
17361
+ }
17362
+ },
17363
+ /**
17364
+ * 混合通道投递口(01-hybrid-deep-analysis):会话 agent 深度分析后 POST 方案入收件箱。
17365
+ * 只读 + 内存态:经 normalizeAiPlan 以最新变更清单白名单归一(幻觉路径丢弃计数),
17366
+ * 零接受拒收(防 agent 空转误报成功);覆盖式入箱(新投递顶掉旧件)。
17367
+ * @author ddj 2026年09月23号
17368
+ * @param args.plan agent 产出的方案 JSON(形状宽松,归一兜底)
17369
+ * @returns 接受路径数与丢弃数
17370
+ */
17371
+ "svn.aiPlanSubmit": async (args) => {
17372
+ const sc = await requireSvnSession(ctx, args.sessionId);
17373
+ if ("err" in sc) return {
17374
+ ok: false,
17375
+ error: sc.err
16094
17376
  };
16095
- } catch (error) {
16096
- return {
17377
+ const status = await statusOf(sc.cwd);
17378
+ if (!status.managed || !status.wcRoot) return {
16097
17379
  ok: false,
16098
- error: "扫描冲突副本失败:" + String(error)
17380
+ error: "当前工作区不受 SVN 管理"
16099
17381
  };
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 {
17382
+ if (!status.svnCli) return {
17383
+ ok: false,
17384
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17385
+ };
17386
+ try {
17387
+ const { entries } = await changesOf(status.wcRoot, true);
17388
+ const { plan, dropped } = normalizeAiPlan(args.plan, entries);
17389
+ const accepted = plan.reverts.length + plan.ignores.length + plan.groups.reduce((n, group) => n + group.paths.length, 0);
17390
+ if (!accepted) return {
16130
17391
  ok: false,
16131
- error: "文件不存在:" + rel
17392
+ error: "方案为空:无任何路径通过白名单校验(丢弃 " + dropped + " 项)"
16132
17393
  };
16133
- if ((info.size ?? 0) > READ_CAP) return {
17394
+ planInbox.set(status.wcRoot, {
17395
+ plan,
17396
+ dropped,
17397
+ at: now()
17398
+ });
17399
+ return {
17400
+ ok: true,
17401
+ accepted,
17402
+ dropped
17403
+ };
17404
+ } catch (error) {
17405
+ return {
16134
17406
  ok: false,
16135
- error: "文件过大(>8MB):" + rel
17407
+ error: "方案投递失败:" + String(error)
16136
17408
  };
16137
- sides.push(await fs.readText(resolved));
16138
17409
  }
16139
- return {
17410
+ },
17411
+ /**
17412
+ * 混合通道取件口:面板轮询取收件箱方案。
17413
+ * since = 注入时刻——早于它的旧投递不算新件(防上一轮残留被误认为本轮结果);
17414
+ * TTL 过期返回 plan:null。非破坏性读取(重复取到同一份由 client 侧取件即停消化)。
17415
+ * @author ddj 2026年09月23号
17416
+ * @param args.since 注入时刻时间戳(缺省 0 = 取任意件)
17417
+ * @returns 方案与元信息(无新件 plan 为 null)
17418
+ */
17419
+ "svn.aiPlanPending": async (args) => {
17420
+ const sc = await requireSvnSession(ctx, args.sessionId);
17421
+ if ("err" in sc) return {
17422
+ ok: false,
17423
+ error: sc.err
17424
+ };
17425
+ const status = await statusOf(sc.cwd);
17426
+ if (!status.managed || !status.wcRoot) return {
17427
+ ok: false,
17428
+ error: "当前工作区不受 SVN 管理"
17429
+ };
17430
+ const hit = planInbox.get(status.wcRoot);
17431
+ const since = Number(args.since) || 0;
17432
+ if (!hit || hit.at < since || now() - hit.at > 18e5) return {
16140
17433
  ok: true,
16141
- left: sides[0],
16142
- right: sides[1],
16143
- ...guardSides(sides[0], sides[1])
17434
+ plan: null,
17435
+ dropped: 0,
17436
+ at: 0
16144
17437
  };
16145
- } catch (error) {
16146
17438
  return {
17439
+ ok: true,
17440
+ plan: hit.plan,
17441
+ dropped: hit.dropped,
17442
+ at: hit.at
17443
+ };
17444
+ },
17445
+ "svn.wcRev": async (args) => {
17446
+ const sc = await requireSvnSession(ctx, args.sessionId);
17447
+ if ("err" in sc) return {
16147
17448
  ok: false,
16148
- error: "读取冲突副本失败:" + String(error)
17449
+ error: sc.err
16149
17450
  };
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);
17451
+ const rel = safeRel(args.path);
16170
17452
  if (rel === null || rel === "") return {
16171
- path: String(raw ?? ""),
16172
- size: null
17453
+ ok: true,
17454
+ revision: null
17455
+ };
17456
+ const status = await statusOf(sc.cwd);
17457
+ if (!status.managed || !status.wcRoot) return {
17458
+ ok: false,
17459
+ error: "当前工作区不受 SVN 管理"
17460
+ };
17461
+ if (!status.svnCli) return {
17462
+ ok: false,
17463
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17464
+ };
17465
+ const outcome = await runSvn(status.wcRoot, [
17466
+ "info",
17467
+ "--xml",
17468
+ "--",
17469
+ rel
17470
+ ], READ_GRACE_MS);
17471
+ if (outcome.code !== 0) return {
17472
+ ok: true,
17473
+ revision: null
17474
+ };
17475
+ return {
17476
+ ok: true,
17477
+ revision: parseSvnInfoRevision(outcome.stdout)
17478
+ };
17479
+ },
17480
+ "svn.cleanup": async (args) => {
17481
+ const sc = await requireSvnSession(ctx, args.sessionId);
17482
+ if ("err" in sc) return {
17483
+ ok: false,
17484
+ error: sc.err
17485
+ };
17486
+ const status = await statusOf(sc.cwd);
17487
+ if (!status.managed || !status.wcRoot) return {
17488
+ ok: false,
17489
+ error: "当前工作区不受 SVN 管理"
17490
+ };
17491
+ if (!status.svnCli) return {
17492
+ ok: false,
17493
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16173
17494
  };
17495
+ const argv = ["cleanup"];
17496
+ if (args.removeUnversioned === true) argv.push("--remove-unversioned");
17497
+ if (args.removeIgnored === true) argv.push("--remove-ignored");
16174
17498
  try {
16175
- const resolved = await fs.resolve(rel, { cwd: sc.cwd });
16176
- const info = await fs.stat(resolved);
17499
+ const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS);
17500
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, "清理");
17501
+ if (outcome.code !== 0) return {
17502
+ ok: false,
17503
+ error: "清理失败:" + tailOfText(result.output)
17504
+ };
17505
+ changesCache.delete(status.wcRoot);
16177
17506
  return {
16178
- path: rel,
16179
- size: info && info.type === "file" ? info.size ?? null : null
17507
+ ok: true,
17508
+ ...result
16180
17509
  };
16181
- } catch {
17510
+ } catch (error) {
16182
17511
  return {
16183
- path: rel,
16184
- size: null
17512
+ ok: false,
17513
+ error: "清理失败:" + String(error)
16185
17514
  };
16186
17515
  }
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 {
17516
+ },
17517
+ "svn.update": async (args) => {
17518
+ const sc = await requireSvnSession(ctx, args.sessionId);
17519
+ if ("err" in sc) return {
16251
17520
  ok: false,
16252
- error: String(error instanceof Error ? error.message : error)
17521
+ error: sc.err
16253
17522
  };
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);
17523
+ const rel = safeRel(args.path);
16278
17524
  if (rel === null) return {
16279
17525
  ok: false,
16280
17526
  error: "路径不合法"
16281
17527
  };
16282
- const names = Array.isArray(item?.names) ? item.names : [];
16283
- if (!names.length) return {
17528
+ const status = await statusOf(sc.cwd);
17529
+ if (!status.managed) return {
16284
17530
  ok: false,
16285
- error: "忽略名列表为空"
17531
+ error: "当前工作区不受 SVN 管理"
16286
17532
  };
16287
- const checked = [];
16288
- for (const name of names) {
16289
- const nameError = ignoreNameErrorOf(name);
16290
- if (nameError) return {
17533
+ if (!status.svnCli) return {
17534
+ ok: false,
17535
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
17536
+ };
17537
+ try {
17538
+ const outcome = await runSvn(sc.cwd, [
17539
+ "update",
17540
+ "--",
17541
+ rel || "."
17542
+ ], UPDATE_GRACE_MS);
17543
+ const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code);
17544
+ if (outcome.code !== 0) return {
16291
17545
  ok: false,
16292
- error: nameError
17546
+ error: "更新失败:" + tailOfText(result.output)
17547
+ };
17548
+ return {
17549
+ ok: true,
17550
+ ...result
17551
+ };
17552
+ } catch (error) {
17553
+ return {
17554
+ ok: false,
17555
+ error: "更新失败:" + String(error)
16293
17556
  };
16294
- checked.push(String(name).trim());
16295
17557
  }
16296
- total += checked.length;
16297
- if (total > 64) return {
17558
+ },
17559
+ "svn.updateStart": async (args) => {
17560
+ const scope = await updateScope(args.sessionId, true);
17561
+ if ("err" in scope) return {
16298
17562
  ok: false,
16299
- error: "一次最多处理 64 个路径"
17563
+ error: scope.err
16300
17564
  };
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 {
17565
+ if (!scope.cli) return {
16319
17566
  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
17567
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16327
17568
  };
16328
- } catch (error) {
16329
- return {
17569
+ const rel = safeRel(args.path);
17570
+ if (rel === null) return {
16330
17571
  ok: false,
16331
- error: "svn:ignore 写入失败:" + String(error)
17572
+ error: "路径不合法"
16332
17573
  };
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 {
17574
+ try {
17575
+ const fs = ctx.get("fs");
17576
+ const cwd = fs?.resolve && fs?.processPath ? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd;
17577
+ return {
17578
+ ok: true,
17579
+ ...updateJobs.start({
17580
+ cwd,
17581
+ root: scope.root,
17582
+ path: rel,
17583
+ exe: scope.exe
17584
+ })
17585
+ };
17586
+ } catch (error) {
17587
+ return {
17588
+ ok: false,
17589
+ error: "启动 SVN 更新失败:" + String(error)
17590
+ };
17591
+ }
17592
+ },
17593
+ "svn.updatePoll": async (args) => {
17594
+ const scope = await updateScope(args.sessionId);
17595
+ if ("err" in scope) return {
16363
17596
  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
17597
+ error: scope.err
16375
17598
  };
16376
- } catch (error) {
16377
- return {
17599
+ try {
17600
+ return {
17601
+ ok: true,
17602
+ ...updateJobs.poll(scope.root, args.jobId, args.since)
17603
+ };
17604
+ } catch (error) {
17605
+ return {
17606
+ ok: false,
17607
+ error: String(error)
17608
+ };
17609
+ }
17610
+ },
17611
+ "svn.updateCancel": async (args) => {
17612
+ const scope = await updateScope(args.sessionId);
17613
+ if ("err" in scope) return {
16378
17614
  ok: false,
16379
- error: "方案投递失败:" + String(error)
17615
+ error: scope.err
16380
17616
  };
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 {
17617
+ try {
17618
+ return {
17619
+ ok: true,
17620
+ ...updateJobs.cancel(scope.root, args.jobId)
17621
+ };
17622
+ } catch (error) {
17623
+ return {
17624
+ ok: false,
17625
+ error: String(error)
17626
+ };
17627
+ }
17628
+ },
17629
+ "svn.updateActive": async (args) => {
17630
+ const scope = await updateScope(args.sessionId);
17631
+ if ("err" in scope) return {
16474
17632
  ok: false,
16475
- error: "清理失败:" + tailOfText(result.output)
17633
+ error: scope.err
16476
17634
  };
16477
- changesCache.delete(status.wcRoot);
16478
17635
  return {
16479
17636
  ok: true,
16480
- ...result
17637
+ job: updateJobs.active(scope.root)
16481
17638
  };
16482
- } catch (error) {
16483
- return {
17639
+ },
17640
+ "svn.tortoise": async (args) => {
17641
+ const sc = await requireSvnSession(ctx, args.sessionId);
17642
+ if ("err" in sc) return {
16484
17643
  ok: false,
16485
- error: "清理失败:" + String(error)
17644
+ error: sc.err
16486
17645
  };
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 {
17646
+ const action = args.action;
17647
+ if (action !== "update" && action !== "commit" && action !== "log" && action !== "diff" && action !== "blame" && action !== "revert") return {
16513
17648
  ok: false,
16514
- error: "更新失败:" + tailOfText(result.output)
17649
+ error: "未知动作: " + String(action)
16515
17650
  };
16516
- return {
16517
- ok: true,
16518
- ...result
16519
- };
16520
- } catch (error) {
16521
- return {
17651
+ const rel = safeRel(args.path);
17652
+ if (rel === null) return {
16522
17653
  ok: false,
16523
- error: "更新失败:" + String(error)
17654
+ error: "路径不合法"
16524
17655
  };
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 {
17656
+ const status = await statusOf(sc.cwd);
17657
+ if (!status.managed) return {
16560
17658
  ok: false,
16561
- error: "路径不存在"
17659
+ error: "当前工作区不受 SVN 管理"
16562
17660
  };
16563
- const abs = fs.processPath(target);
16564
- launchTortoise(status.tortoiseExe, action, abs);
16565
- return {
16566
- ok: true,
16567
- launched: abs
17661
+ if (!status.tortoise) return {
17662
+ ok: false,
17663
+ error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
16568
17664
  };
16569
- } catch (error) {
16570
- return {
17665
+ const fs = ctx.get("fs");
17666
+ if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
16571
17667
  ok: false,
16572
- error: "启动 TortoiseSVN 失败:" + String(error)
17668
+ error: "缺少 fs"
16573
17669
  };
17670
+ try {
17671
+ const target = await fs.resolve(rel || ".", { cwd: sc.cwd });
17672
+ if (!await fs.stat(target)) return {
17673
+ ok: false,
17674
+ error: "路径不存在"
17675
+ };
17676
+ const abs = fs.processPath(target);
17677
+ launchTortoise(status.tortoiseExe, action, abs);
17678
+ return {
17679
+ ok: true,
17680
+ launched: abs
17681
+ };
17682
+ } catch (error) {
17683
+ return {
17684
+ ok: false,
17685
+ error: "启动 TortoiseSVN 失败:" + String(error)
17686
+ };
17687
+ }
16574
17688
  }
17689
+ },
17690
+ dispose: () => {
17691
+ updateJobs.dispose();
17692
+ updateRootCache.clear();
16575
17693
  }
16576
- } };
17694
+ };
16577
17695
  }
16578
17696
  //#endregion
16579
17697
  //#region src/shared/dap.ts
@@ -18246,10 +19364,12 @@ function apply(ctx, config) {
18246
19364
  ctx,
18247
19365
  settings: openSettings
18248
19366
  }).handlers;
18249
- const svnHandlers = createSvnRpc({
19367
+ /** SVN RPC(检测/更新/Tortoise 发射;settings 提供 svnPath/tortoisePath)。 */
19368
+ const svnRpc = createSvnRpc({
18250
19369
  ctx,
18251
19370
  settings: openSettings
18252
- }).handlers;
19371
+ });
19372
+ const svnHandlers = svnRpc.handlers;
18253
19373
  /** 调试 RPC(DAP 桥单例:spawn 扩展适配器 + 事件缓冲 + findFile 反向匹配)。 */
18254
19374
  const dapRpc = createDapRpc(ctx);
18255
19375
  const dapHandlers = dapRpc.handlers;
@@ -18279,6 +19399,7 @@ function apply(ctx, config) {
18279
19399
  disposeAllServers();
18280
19400
  });
18281
19401
  ctx.effect(() => () => fileVersions.dispose());
19402
+ ctx.effect(() => () => svnRpc.dispose());
18282
19403
  ctx.effect(() => () => dapRpc.dispose());
18283
19404
  hookExitReclaim();
18284
19405
  log.info("编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入" + (rulesInstalled ? "已接入" : "未接入") + ",技能组" + (skillsDispatched ? "装配中" : "未装配") + ")");