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/README.md +42 -3
- package/lib/client.js +1587 -301
- package/lib/client.js.map +1 -1
- package/lib/index.js +2163 -1042
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +74 -28
- package/src/client/dap/hover.ts +2 -7
- package/src/client/index.ts +25 -0
- package/src/client/luaReservedWords.ts +10 -0
- package/src/client/monaco/lsp/keywords.ts +121 -0
- package/src/client/monaco/lsp/lspClient.ts +68 -6
- package/src/client/monaco/lsp/memberScope.ts +90 -0
- package/src/client/monaco/lsp/providers.ts +14 -4
- package/src/client/settingsDocRouter.ts +52 -0
- package/src/client/sidebar/menuItems.ts +1 -0
- package/src/client/sidebar/panels/SvnPanel.ts +1 -1
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/styles/editor.css +143 -37
- package/src/client/svnActions.ts +4 -4
- package/src/client/ui/ContextMenu.ts +4 -2
- package/src/client/ui/EditorView.ts +132 -52
- package/src/client/ui/SvnLogDialog.ts +200 -69
- package/src/client/ui/SvnUpdateDialog.ts +472 -0
- package/src/client/ui/svnUpdateModel.ts +135 -0
- package/src/index.ts +2 -0
- package/src/lsp/completionCap.ts +80 -0
- package/src/lsp/manager.ts +42 -10
- package/src/lsp/rpc.ts +8 -5
- package/src/lsp/server.ts +18 -4
- package/src/rpc.ts +74 -2
- package/src/settingsDocPath.ts +30 -0
- package/src/shared/rpc.ts +12 -2
- package/src/shared/svn.ts +38 -0
- package/src/svn.ts +102 -7
- package/src/svnText.ts +22 -13
- package/src/svnUpdateJob.ts +588 -0
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
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
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:
|
|
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 = /^([
|
|
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
|
|
14408
|
-
if (
|
|
14409
|
-
|
|
14410
|
-
|
|
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
|
-
|
|
14701
|
-
|
|
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
|
-
*
|
|
14759
|
-
*
|
|
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
|
-
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
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
|
-
*
|
|
14777
|
-
* @author ddj 2026年09月
|
|
14778
|
-
* @
|
|
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
|
-
|
|
14785
|
-
|
|
14786
|
-
|
|
14787
|
-
|
|
14788
|
-
|
|
14789
|
-
|
|
14790
|
-
|
|
14791
|
-
|
|
14792
|
-
|
|
14793
|
-
|
|
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
|
-
*
|
|
14797
|
-
*
|
|
14798
|
-
* @
|
|
14799
|
-
* @param
|
|
14800
|
-
* @returns
|
|
15031
|
+
* 工作副本根作为并发键;Windows 路径大小写不敏感。
|
|
15032
|
+
* @author ddj 2026年09月24号
|
|
15033
|
+
* @param root 工作副本根
|
|
15034
|
+
* @param platform 目标平台
|
|
15035
|
+
* @returns 并发键
|
|
14801
15036
|
*/
|
|
14802
|
-
function
|
|
14803
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
14820
|
-
|
|
14821
|
-
|
|
14822
|
-
|
|
14823
|
-
|
|
14824
|
-
/**
|
|
14825
|
-
|
|
14826
|
-
|
|
14827
|
-
|
|
14828
|
-
|
|
14829
|
-
|
|
14830
|
-
|
|
14831
|
-
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
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)
|
|
15133
|
-
const
|
|
15134
|
-
|
|
15135
|
-
|
|
15136
|
-
|
|
15137
|
-
|
|
15138
|
-
|
|
15139
|
-
|
|
15140
|
-
|
|
15141
|
-
|
|
15142
|
-
|
|
15143
|
-
|
|
15144
|
-
|
|
15145
|
-
|
|
15146
|
-
|
|
15147
|
-
|
|
15148
|
-
|
|
15149
|
-
|
|
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 {
|
|
15605
|
-
|
|
15606
|
-
|
|
15607
|
-
|
|
15608
|
-
|
|
15609
|
-
|
|
15610
|
-
|
|
15611
|
-
|
|
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
|
-
|
|
15636
|
-
|
|
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
|
-
|
|
15639
|
-
return {
|
|
16808
|
+
const rel = safeRel(args.path);
|
|
16809
|
+
if (rel === null || rel === "") return {
|
|
15640
16810
|
ok: false,
|
|
15641
|
-
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
|
-
|
|
16818
|
+
const status = await statusOf(sc.cwd);
|
|
16819
|
+
if (!status.managed || !status.wcRoot) return {
|
|
15678
16820
|
ok: false,
|
|
15679
|
-
error: "
|
|
16821
|
+
error: "当前工作区不受 SVN 管理"
|
|
15680
16822
|
};
|
|
15681
|
-
|
|
15682
|
-
|
|
15683
|
-
"
|
|
15684
|
-
|
|
15685
|
-
|
|
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
|
-
|
|
15694
|
-
|
|
15695
|
-
|
|
15696
|
-
|
|
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
|
-
|
|
15701
|
-
|
|
15702
|
-
|
|
15703
|
-
|
|
15704
|
-
|
|
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
|
-
|
|
15707
|
-
return {
|
|
16962
|
+
const rel = safeRel(args.path);
|
|
16963
|
+
if (rel === null || rel === "") return {
|
|
15708
16964
|
ok: false,
|
|
15709
|
-
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:
|
|
16970
|
+
error: "当前工作区不受 SVN 管理"
|
|
15735
16971
|
};
|
|
15736
|
-
|
|
15737
|
-
|
|
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
|
-
|
|
15773
|
-
|
|
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:
|
|
17012
|
+
error: sc.err
|
|
15776
17013
|
};
|
|
15777
|
-
|
|
15778
|
-
|
|
15779
|
-
|
|
15780
|
-
|
|
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
|
-
|
|
15812
|
-
|
|
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: "
|
|
17023
|
+
error: "版本号不合法"
|
|
15815
17024
|
};
|
|
15816
|
-
|
|
15817
|
-
|
|
15818
|
-
|
|
15819
|
-
|
|
15820
|
-
|
|
15821
|
-
|
|
15822
|
-
|
|
15823
|
-
|
|
15824
|
-
|
|
15825
|
-
|
|
15826
|
-
|
|
15827
|
-
|
|
15828
|
-
|
|
15829
|
-
|
|
15830
|
-
|
|
15831
|
-
|
|
15832
|
-
|
|
15833
|
-
|
|
15834
|
-
|
|
15835
|
-
|
|
15836
|
-
|
|
15837
|
-
|
|
15838
|
-
|
|
15839
|
-
|
|
15840
|
-
|
|
15841
|
-
|
|
15842
|
-
|
|
15843
|
-
|
|
15844
|
-
|
|
15845
|
-
|
|
15846
|
-
|
|
15847
|
-
|
|
15848
|
-
|
|
15849
|
-
|
|
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
|
-
|
|
15852
|
-
|
|
17136
|
+
const left = safeRel(args.left);
|
|
17137
|
+
const right = safeRel(args.right);
|
|
17138
|
+
if (!left || !right) return {
|
|
15853
17139
|
ok: false,
|
|
15854
|
-
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
|
-
|
|
17147
|
+
const fs = ctx.get("fs");
|
|
17148
|
+
if (!fs?.resolve || !fs?.stat || !fs?.readText) return {
|
|
15896
17149
|
ok: false,
|
|
15897
|
-
error: "
|
|
17150
|
+
error: "缺少 fs"
|
|
15898
17151
|
};
|
|
15899
|
-
|
|
15900
|
-
|
|
15901
|
-
|
|
15902
|
-
|
|
15903
|
-
|
|
15904
|
-
|
|
15905
|
-
|
|
15906
|
-
|
|
15907
|
-
|
|
15908
|
-
|
|
15909
|
-
|
|
15910
|
-
|
|
15911
|
-
|
|
15912
|
-
|
|
15913
|
-
|
|
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
|
-
|
|
17186
|
+
const list = Array.isArray(args.paths) ? args.paths.slice(0, 200) : [];
|
|
17187
|
+
if (!list.length) return {
|
|
15916
17188
|
ok: true,
|
|
15917
|
-
|
|
15918
|
-
right: working,
|
|
15919
|
-
...guardSides(leftOutcome.stdout, working)
|
|
17189
|
+
sizes: []
|
|
15920
17190
|
};
|
|
15921
|
-
|
|
15922
|
-
return {
|
|
17191
|
+
const fs = ctx.get("fs");
|
|
17192
|
+
if (!fs?.resolve || !fs?.stat) return {
|
|
15923
17193
|
ok: false,
|
|
15924
|
-
error: "
|
|
17194
|
+
error: "缺少 fs"
|
|
15925
17195
|
};
|
|
15926
|
-
|
|
15927
|
-
|
|
15928
|
-
|
|
15929
|
-
|
|
15930
|
-
|
|
15931
|
-
|
|
15932
|
-
|
|
15933
|
-
|
|
15934
|
-
|
|
15935
|
-
|
|
15936
|
-
|
|
15937
|
-
|
|
15938
|
-
|
|
15939
|
-
|
|
15940
|
-
|
|
15941
|
-
|
|
15942
|
-
|
|
15943
|
-
|
|
15944
|
-
|
|
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
|
|
15966
|
-
const
|
|
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
|
-
|
|
15970
|
-
truncated: !head.startsWith("Index:") && !head.startsWith("Property changes on:"),
|
|
15971
|
-
binary: text.includes("Cannot display:")
|
|
17221
|
+
sizes
|
|
15972
17222
|
};
|
|
15973
|
-
}
|
|
15974
|
-
|
|
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:
|
|
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: "
|
|
17240
|
+
error: "当前工作区不受 SVN 管理"
|
|
16019
17241
|
};
|
|
16020
|
-
return {
|
|
16021
|
-
ok:
|
|
16022
|
-
|
|
17242
|
+
if (!status.svnCli) return {
|
|
17243
|
+
ok: false,
|
|
17244
|
+
error: "svn 命令不可用(可在设置页配置 svn 路径)"
|
|
16023
17245
|
};
|
|
16024
|
-
|
|
16025
|
-
|
|
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:
|
|
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: "
|
|
17300
|
+
error: "未选择任何路径"
|
|
16062
17301
|
};
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
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
|
-
|
|
16068
|
-
return {
|
|
17339
|
+
if (!status.svnCli) return {
|
|
16069
17340
|
ok: false,
|
|
16070
|
-
error: "
|
|
17341
|
+
error: "svn 命令不可用(可在设置页配置 svn 路径)"
|
|
16071
17342
|
};
|
|
16072
|
-
|
|
16073
|
-
|
|
16074
|
-
|
|
16075
|
-
|
|
16076
|
-
|
|
16077
|
-
|
|
16078
|
-
|
|
16079
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
16082
|
-
|
|
16083
|
-
|
|
16084
|
-
|
|
16085
|
-
|
|
16086
|
-
|
|
16087
|
-
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
|
|
16092
|
-
|
|
16093
|
-
|
|
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
|
-
|
|
16096
|
-
return {
|
|
17377
|
+
const status = await statusOf(sc.cwd);
|
|
17378
|
+
if (!status.managed || !status.wcRoot) return {
|
|
16097
17379
|
ok: false,
|
|
16098
|
-
error: "
|
|
17380
|
+
error: "当前工作区不受 SVN 管理"
|
|
16099
17381
|
};
|
|
16100
|
-
|
|
16101
|
-
|
|
16102
|
-
|
|
16103
|
-
|
|
16104
|
-
|
|
16105
|
-
|
|
16106
|
-
|
|
16107
|
-
|
|
16108
|
-
|
|
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: "
|
|
17392
|
+
error: "方案为空:无任何路径通过白名单校验(丢弃 " + dropped + " 项)"
|
|
16132
17393
|
};
|
|
16133
|
-
|
|
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: "
|
|
17407
|
+
error: "方案投递失败:" + String(error)
|
|
16136
17408
|
};
|
|
16137
|
-
sides.push(await fs.readText(resolved));
|
|
16138
17409
|
}
|
|
16139
|
-
|
|
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
|
-
|
|
16142
|
-
|
|
16143
|
-
|
|
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:
|
|
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
|
-
|
|
16172
|
-
|
|
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
|
|
16176
|
-
const
|
|
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
|
-
|
|
16179
|
-
|
|
17507
|
+
ok: true,
|
|
17508
|
+
...result
|
|
16180
17509
|
};
|
|
16181
|
-
} catch {
|
|
17510
|
+
} catch (error) {
|
|
16182
17511
|
return {
|
|
16183
|
-
|
|
16184
|
-
|
|
17512
|
+
ok: false,
|
|
17513
|
+
error: "清理失败:" + String(error)
|
|
16185
17514
|
};
|
|
16186
17515
|
}
|
|
16187
|
-
}
|
|
16188
|
-
|
|
16189
|
-
|
|
16190
|
-
|
|
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:
|
|
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
|
|
16283
|
-
if (!
|
|
17528
|
+
const status = await statusOf(sc.cwd);
|
|
17529
|
+
if (!status.managed) return {
|
|
16284
17530
|
ok: false,
|
|
16285
|
-
error: "
|
|
17531
|
+
error: "当前工作区不受 SVN 管理"
|
|
16286
17532
|
};
|
|
16287
|
-
|
|
16288
|
-
|
|
16289
|
-
|
|
16290
|
-
|
|
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:
|
|
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
|
-
|
|
16297
|
-
|
|
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:
|
|
17563
|
+
error: scope.err
|
|
16300
17564
|
};
|
|
16301
|
-
|
|
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:
|
|
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
|
-
|
|
16329
|
-
return {
|
|
17569
|
+
const rel = safeRel(args.path);
|
|
17570
|
+
if (rel === null) return {
|
|
16330
17571
|
ok: false,
|
|
16331
|
-
error: "
|
|
17572
|
+
error: "路径不合法"
|
|
16332
17573
|
};
|
|
16333
|
-
|
|
16334
|
-
|
|
16335
|
-
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
16339
|
-
|
|
16340
|
-
|
|
16341
|
-
|
|
16342
|
-
|
|
16343
|
-
|
|
16344
|
-
|
|
16345
|
-
|
|
16346
|
-
|
|
16347
|
-
|
|
16348
|
-
|
|
16349
|
-
|
|
16350
|
-
|
|
16351
|
-
|
|
16352
|
-
|
|
16353
|
-
|
|
16354
|
-
|
|
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:
|
|
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
|
-
|
|
16377
|
-
|
|
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:
|
|
17615
|
+
error: scope.err
|
|
16380
17616
|
};
|
|
16381
|
-
|
|
16382
|
-
|
|
16383
|
-
|
|
16384
|
-
|
|
16385
|
-
|
|
16386
|
-
|
|
16387
|
-
|
|
16388
|
-
|
|
16389
|
-
|
|
16390
|
-
|
|
16391
|
-
|
|
16392
|
-
|
|
16393
|
-
|
|
16394
|
-
|
|
16395
|
-
|
|
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:
|
|
17633
|
+
error: scope.err
|
|
16476
17634
|
};
|
|
16477
|
-
changesCache.delete(status.wcRoot);
|
|
16478
17635
|
return {
|
|
16479
17636
|
ok: true,
|
|
16480
|
-
|
|
17637
|
+
job: updateJobs.active(scope.root)
|
|
16481
17638
|
};
|
|
16482
|
-
}
|
|
16483
|
-
|
|
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:
|
|
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: "
|
|
17649
|
+
error: "未知动作: " + String(action)
|
|
16515
17650
|
};
|
|
16516
|
-
|
|
16517
|
-
|
|
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: "
|
|
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
|
-
|
|
16564
|
-
|
|
16565
|
-
|
|
16566
|
-
ok: true,
|
|
16567
|
-
launched: abs
|
|
17661
|
+
if (!status.tortoise) return {
|
|
17662
|
+
ok: false,
|
|
17663
|
+
error: "TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)"
|
|
16568
17664
|
};
|
|
16569
|
-
|
|
16570
|
-
return {
|
|
17665
|
+
const fs = ctx.get("fs");
|
|
17666
|
+
if (!fs?.resolve || !fs?.stat || !fs?.processPath) return {
|
|
16571
17667
|
ok: false,
|
|
16572
|
-
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
|
-
|
|
19367
|
+
/** SVN RPC(检测/更新/Tortoise 发射;settings 提供 svnPath/tortoisePath)。 */
|
|
19368
|
+
const svnRpc = createSvnRpc({
|
|
18250
19369
|
ctx,
|
|
18251
19370
|
settings: openSettings
|
|
18252
|
-
})
|
|
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 ? "装配中" : "未装配") + ")");
|