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