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