dsh-vscode-mode 0.21.4 → 0.23.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 +52 -4
- package/lib/client.js +3442 -183
- package/lib/client.js.map +2 -2
- package/lib/index.js +3510 -222
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +52 -8
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/DiffLauncher.ts +1 -1
- package/src/client/ui/EditorView.ts +10 -2
- package/src/client/ui/OfficialDiffBar.tsx +1 -12
- package/src/client/ui/OfficialReview.ts +13 -3
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +165 -0
- package/src/pending/decide.ts +213 -0
- package/src/pending/hunks.ts +241 -0
- package/src/pending/kernel.ts +233 -0
- package/src/pending/persist.ts +329 -0
- package/src/pending/store.ts +472 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +184 -0
- package/src/pendingRpc.ts +210 -0
- package/src/quickMenu.ts +269 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +66 -2
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +28 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
- package/src/store.ts +19 -0
package/lib/index.js
CHANGED
|
@@ -43,7 +43,7 @@ const RESOURCE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
43
43
|
* @param path Workspace or agent cwd.
|
|
44
44
|
* @returns Comparable path with a stable root representation.
|
|
45
45
|
*/
|
|
46
|
-
function normalizePath(path) {
|
|
46
|
+
function normalizePath$1(path) {
|
|
47
47
|
const raw = String(path ?? "").replace(/\\/g, "/");
|
|
48
48
|
const windows = /^[a-z]:/i.test(raw) || raw.startsWith("//");
|
|
49
49
|
const value = (windows ? win32.normalize(raw) : posix.normalize(raw)).replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
|
@@ -58,12 +58,12 @@ function normalizePath(path) {
|
|
|
58
58
|
*/
|
|
59
59
|
function matchWorkspace(cwd, paths) {
|
|
60
60
|
if (!cwd) return void 0;
|
|
61
|
-
const target = normalizePath(cwd);
|
|
61
|
+
const target = normalizePath$1(cwd);
|
|
62
62
|
const matches = paths.filter((path) => {
|
|
63
|
-
const root = normalizePath(path);
|
|
63
|
+
const root = normalizePath$1(path);
|
|
64
64
|
return target === root || target.startsWith(root === "/" ? root : root + "/");
|
|
65
65
|
});
|
|
66
|
-
matches.sort((a, b) => normalizePath(b).length - normalizePath(a).length);
|
|
66
|
+
matches.sort((a, b) => normalizePath$1(b).length - normalizePath$1(a).length);
|
|
67
67
|
return matches[0];
|
|
68
68
|
}
|
|
69
69
|
const isProjectEntry = isProjectEntryId;
|
|
@@ -684,6 +684,15 @@ function debugLogFile(cwd, home = dshHome()) {
|
|
|
684
684
|
function archiveDbFile(cwd, home = dshHome()) {
|
|
685
685
|
return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
|
|
686
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* 快捷菜单配置文本(用户数据,不受缓存清理预算约束,跨工作区共享一份)。
|
|
689
|
+
* @author ddj 2026年10月30号
|
|
690
|
+
* @param home DSH home(测试可注入)
|
|
691
|
+
* @returns 快捷菜单配置绝对路径
|
|
692
|
+
*/
|
|
693
|
+
function quickMenuFile(home = dshHome()) {
|
|
694
|
+
return join(home, PLUGIN_ID, "data", "quick-menu.json");
|
|
695
|
+
}
|
|
687
696
|
/** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
|
|
688
697
|
function assetsDirOf(moduleUrl) {
|
|
689
698
|
return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
|
|
@@ -2488,6 +2497,26 @@ async function resolveTargetIn(ctx, cwd, path) {
|
|
|
2488
2497
|
return ctx.get("fs").resolve(path, cwd ? { cwd } : {});
|
|
2489
2498
|
}
|
|
2490
2499
|
/**
|
|
2500
|
+
* 把解析结果还原成宿主路径字符串。
|
|
2501
|
+
* 官方 provider 的 resolve 返回不透明 FsTarget,而多数调用点按字符串使用;
|
|
2502
|
+
* 直接把对象当路径传下去会落进 SQL 绑定或 path.resolve,报出与真实原因无关的错误。
|
|
2503
|
+
* @author ddj 2026年10月30号
|
|
2504
|
+
* @param ctx DSH 上下文
|
|
2505
|
+
* @param target resolve 的返回值:宿主路径字符串或 FsTarget
|
|
2506
|
+
* @returns 宿主路径字符串;无法还原时返回空串
|
|
2507
|
+
*/
|
|
2508
|
+
function targetPathOf(ctx, target) {
|
|
2509
|
+
if (typeof target === "string") return target;
|
|
2510
|
+
const fs = ctx.get("fs");
|
|
2511
|
+
if (typeof fs?.processPath !== "function") return "";
|
|
2512
|
+
try {
|
|
2513
|
+
const resolved = fs.processPath(target);
|
|
2514
|
+
return typeof resolved === "string" && resolved !== "[object Object]" ? resolved : "";
|
|
2515
|
+
} catch {
|
|
2516
|
+
return "";
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2491
2520
|
* 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
|
|
2492
2521
|
* @author ddj 2026年09月30号
|
|
2493
2522
|
* @param ctx DSH 上下文
|
|
@@ -5041,7 +5070,7 @@ function checkTarget(dir, target) {
|
|
|
5041
5070
|
* @param path 安装入口或备份入口
|
|
5042
5071
|
* @throws 删除失败
|
|
5043
5072
|
*/
|
|
5044
|
-
function removeEntry(path) {
|
|
5073
|
+
function removeEntry$1(path) {
|
|
5045
5074
|
const info = lstatSync(path, { throwIfNoEntry: false });
|
|
5046
5075
|
if (!info) return;
|
|
5047
5076
|
if (info.isSymbolicLink()) unlinkSync(path);
|
|
@@ -5098,7 +5127,7 @@ function backupProfile(dir) {
|
|
|
5098
5127
|
function restoreProfile(saved) {
|
|
5099
5128
|
const failures = [];
|
|
5100
5129
|
try {
|
|
5101
|
-
removeEntry(saved.link);
|
|
5130
|
+
removeEntry$1(saved.link);
|
|
5102
5131
|
if (saved.hadLink) renameSync(saved.savedLink, saved.link);
|
|
5103
5132
|
} catch (error) {
|
|
5104
5133
|
failures.push(String(error));
|
|
@@ -5140,7 +5169,7 @@ async function switchProfile(ctx, dir, enabled, target) {
|
|
|
5140
5169
|
throw error;
|
|
5141
5170
|
}
|
|
5142
5171
|
try {
|
|
5143
|
-
removeEntry(saved.savedLink);
|
|
5172
|
+
removeEntry$1(saved.savedLink);
|
|
5144
5173
|
rmSync(saved.dir, {
|
|
5145
5174
|
recursive: true,
|
|
5146
5175
|
force: true
|
|
@@ -5398,7 +5427,7 @@ const SKILL_PREFIXES = ["dsh-vscodemode-"];
|
|
|
5398
5427
|
/** 技能发现来源标签(skill-explorer 归入 "System bundled" 组)。 */
|
|
5399
5428
|
const SKILL_SOURCE = "bundled";
|
|
5400
5429
|
/** 目录式技能的文件名。 */
|
|
5401
|
-
const SKILL_FILE = "SKILL.md";
|
|
5430
|
+
const SKILL_FILE$1 = "SKILL.md";
|
|
5402
5431
|
/** DSH 技能名语法(与 @deepseek-ai/dsh-skill 的 SKILL_NAME 一致:下划线非法)。 */
|
|
5403
5432
|
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5404
5433
|
/** 不受支持的遗留字段 → 规范字段(官方同样拒绝,避免写错键位却静默无效果)。 */
|
|
@@ -5576,7 +5605,7 @@ function hasGroupPrefix(name) {
|
|
|
5576
5605
|
* @returns 候选文件绝对路径;不构成技能时返回 undefined
|
|
5577
5606
|
*/
|
|
5578
5607
|
function skillFileOf(dir, entry) {
|
|
5579
|
-
if (entry.isDirectory()) return join(dir, entry.name, SKILL_FILE);
|
|
5608
|
+
if (entry.isDirectory()) return join(dir, entry.name, SKILL_FILE$1);
|
|
5580
5609
|
if (entry.isFile() && entry.name.endsWith(".md")) return join(dir, entry.name);
|
|
5581
5610
|
}
|
|
5582
5611
|
/**
|
|
@@ -7340,10 +7369,19 @@ async function captureToolResult(ctx, registry, exec, result) {
|
|
|
7340
7369
|
}
|
|
7341
7370
|
//#endregion
|
|
7342
7371
|
//#region src/shared/lineEndings.ts
|
|
7372
|
+
/** Shared logical-line comparison; never rewrites stored patch or file bytes. @author ddj 2026年10月28号 */
|
|
7373
|
+
/** @public @author ddj 2026年10月28号 @param text Decoded text. @returns Text without one leading Unicode BOM. */
|
|
7374
|
+
function stripBom(text) {
|
|
7375
|
+
return text.replace(/^\uFEFF/, "");
|
|
7376
|
+
}
|
|
7343
7377
|
/** @public @author ddj 2026年10月28号 @param line Physical line. @returns Line without one terminal CRLF, CR or LF; other whitespace stays intact. */
|
|
7344
7378
|
function logicalLine(line) {
|
|
7345
7379
|
return line.replace(/(?:\r\n|\r|\n)$/, "");
|
|
7346
7380
|
}
|
|
7381
|
+
/** @public @author ddj 2026年10月28号 @param text Decoded file text. @returns Logical lines excluding BOM, retaining the final empty line after a terminator. */
|
|
7382
|
+
function logicalLines(text) {
|
|
7383
|
+
return stripBom(text).split(/\r\n|\r|\n/);
|
|
7384
|
+
}
|
|
7347
7385
|
//#endregion
|
|
7348
7386
|
//#region src/shared/reviewRegions.ts
|
|
7349
7387
|
/** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
|
|
@@ -7551,7 +7589,7 @@ function captureState(cwd, gap, state) {
|
|
|
7551
7589
|
DO UPDATE SET state=excluded.state,error=excluded.error WHERE review_capture.state<>'complete'`).run(gap.sessionId, gap.turn, gap.seq, state, gap.error);
|
|
7552
7590
|
}
|
|
7553
7591
|
/** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
|
|
7554
|
-
function itemOf(row) {
|
|
7592
|
+
function itemOf$1(row) {
|
|
7555
7593
|
const summary = JSON.parse(String(row.summary));
|
|
7556
7594
|
const kind = row.kind ?? JSON.parse(String(row.diff)).kind;
|
|
7557
7595
|
return {
|
|
@@ -7595,7 +7633,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
|
|
|
7595
7633
|
})),
|
|
7596
7634
|
coveredPaths: covered.map((row) => String(row.identity))
|
|
7597
7635
|
},
|
|
7598
|
-
items: rows.slice(0, 50).map(itemOf),
|
|
7636
|
+
items: rows.slice(0, 50).map(itemOf$1),
|
|
7599
7637
|
pending: Number(count.n),
|
|
7600
7638
|
nextCursor: rows.length > 50 ? offset + 50 : null,
|
|
7601
7639
|
gaps: [...gaps.map((row) => ({
|
|
@@ -7613,6 +7651,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
|
|
|
7613
7651
|
}
|
|
7614
7652
|
/** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
|
|
7615
7653
|
function hasOfficial(cwd, path) {
|
|
7654
|
+
if (typeof path !== "string") return false;
|
|
7616
7655
|
const db = dbFor(cwd);
|
|
7617
7656
|
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false;
|
|
7618
7657
|
if (/^[A-Za-z]:[\\/]/.test(path)) return !!db.prepare("SELECT 1 FROM review_files WHERE lower(replace(identity,char(92),'/'))=? LIMIT 1").get(path.replace(/\\/g, "/").toLowerCase());
|
|
@@ -7630,7 +7669,7 @@ function reviewDetail(cwd, id) {
|
|
|
7630
7669
|
const pending = row.current === 1 && row.decision === "pending";
|
|
7631
7670
|
const undo = pending && !!evidence && diff.kind === "text" && diff.before && diff.after && !diff.coarse;
|
|
7632
7671
|
return {
|
|
7633
|
-
item: itemOf(row),
|
|
7672
|
+
item: itemOf$1(row),
|
|
7634
7673
|
diff,
|
|
7635
7674
|
capturedAt: String(row.captured_at),
|
|
7636
7675
|
blocks,
|
|
@@ -8095,221 +8134,2317 @@ async function readRaw(fs, target, signal) {
|
|
|
8095
8134
|
}
|
|
8096
8135
|
}
|
|
8097
8136
|
//#endregion
|
|
8098
|
-
//#region src/
|
|
8137
|
+
//#region src/pending/capture.ts
|
|
8099
8138
|
/**
|
|
8100
|
-
*
|
|
8101
|
-
* @author ddj 2026年
|
|
8102
|
-
* @param raw 原始搜索文本
|
|
8103
|
-
* @returns 用于比较和缓存的 query
|
|
8139
|
+
* 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
|
|
8140
|
+
* @author ddj 2026年10月29号
|
|
8104
8141
|
*/
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8142
|
+
/** 可被采信为待审来源的写文件工具。 */
|
|
8143
|
+
const WRITE_TOOLS = [
|
|
8144
|
+
"edit",
|
|
8145
|
+
"write",
|
|
8146
|
+
"str_replace_editor"
|
|
8147
|
+
];
|
|
8148
|
+
/** 单文件文本上限:超过即标注 oversized,不静默截断。 */
|
|
8149
|
+
const MAX_TEXT = 1048576;
|
|
8150
|
+
/**
|
|
8151
|
+
* @author ddj 2026年10月30号
|
|
8152
|
+
* @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
|
|
8153
|
+
* @param ctx Host 生命周期
|
|
8154
|
+
* @param sink 已确认写入的接收方
|
|
8155
|
+
* @returns 可释放的采集器
|
|
8156
|
+
*/
|
|
8157
|
+
function installPending(ctx, sink) {
|
|
8158
|
+
const state = {
|
|
8159
|
+
busy: /* @__PURE__ */ new Set(),
|
|
8160
|
+
stopped: false
|
|
8110
8161
|
};
|
|
8162
|
+
ctx.on("tools/execute", (exec, next) => aroundTool(ctx, state, sink, exec, next));
|
|
8163
|
+
return { dispose() {
|
|
8164
|
+
state.stopped = true;
|
|
8165
|
+
state.busy.clear();
|
|
8166
|
+
} };
|
|
8111
8167
|
}
|
|
8112
8168
|
/**
|
|
8113
|
-
*
|
|
8114
|
-
* @
|
|
8115
|
-
* @param
|
|
8116
|
-
* @
|
|
8169
|
+
* @author ddj 2026年10月30号
|
|
8170
|
+
* @description: 判定本次调用是否值得采集;不值得时返回 null,调用方直接透传原始分发。
|
|
8171
|
+
* @param ctx Host 生命周期
|
|
8172
|
+
* @param state 采集状态
|
|
8173
|
+
* @param exec 工具分发上下文
|
|
8174
|
+
* @returns 采集计划或 null
|
|
8117
8175
|
*/
|
|
8118
|
-
function
|
|
8119
|
-
|
|
8176
|
+
async function planOf(ctx, state, exec) {
|
|
8177
|
+
const session = exec.agent?.session;
|
|
8178
|
+
const requested = exec.arguments?.file_path ?? exec.arguments?.path;
|
|
8179
|
+
if (state.stopped || !session?.id || typeof requested !== "string") return null;
|
|
8180
|
+
if (!WRITE_TOOLS.includes(String(exec.name))) return null;
|
|
8181
|
+
const cwd = cwdOf(session);
|
|
8182
|
+
if (!cwd) return null;
|
|
8183
|
+
const fs = ctx.get("fs");
|
|
8184
|
+
const target = await resolveTarget(ctx, session, requested);
|
|
8185
|
+
const identity = fs.processPath(target);
|
|
8186
|
+
if (typeof identity !== "string" || !identity || state.busy.has(identity)) return null;
|
|
8187
|
+
const before = await readText(fs, target);
|
|
8188
|
+
if (before !== null && before.length > MAX_TEXT) return null;
|
|
8189
|
+
return {
|
|
8190
|
+
cwd: String(cwd),
|
|
8191
|
+
sessionId: String(session.id),
|
|
8192
|
+
path: requested,
|
|
8193
|
+
identity,
|
|
8194
|
+
before,
|
|
8195
|
+
session
|
|
8196
|
+
};
|
|
8120
8197
|
}
|
|
8121
8198
|
/**
|
|
8122
|
-
*
|
|
8123
|
-
* @
|
|
8124
|
-
* @param
|
|
8125
|
-
* @
|
|
8199
|
+
* @author ddj 2026年10月30号
|
|
8200
|
+
* @description: 写工具执行前后各读一次,内容变化才上报;任何采集异常都不改变工具结果。
|
|
8201
|
+
* @param ctx Host 生命周期
|
|
8202
|
+
* @param state 采集状态
|
|
8203
|
+
* @param sink 已确认写入的接收方
|
|
8204
|
+
* @param exec 工具分发上下文
|
|
8205
|
+
* @param next 原始分发
|
|
8206
|
+
* @returns 原始工具结果
|
|
8126
8207
|
*/
|
|
8127
|
-
function
|
|
8128
|
-
|
|
8208
|
+
async function aroundTool(ctx, state, sink, exec, next) {
|
|
8209
|
+
let plan = null;
|
|
8210
|
+
try {
|
|
8211
|
+
plan = await planOf(ctx, state, exec);
|
|
8212
|
+
} catch (error) {
|
|
8213
|
+
log.debug("待审采集跳过:" + String(error));
|
|
8214
|
+
}
|
|
8215
|
+
if (!plan) return next();
|
|
8216
|
+
state.busy.add(plan.identity);
|
|
8217
|
+
try {
|
|
8218
|
+
return await next();
|
|
8219
|
+
} finally {
|
|
8220
|
+
state.busy.delete(plan.identity);
|
|
8221
|
+
try {
|
|
8222
|
+
reportWrite(ctx, sink, plan);
|
|
8223
|
+
} catch (error) {
|
|
8224
|
+
log.debug("待审上报失败:" + String(error));
|
|
8225
|
+
}
|
|
8226
|
+
}
|
|
8129
8227
|
}
|
|
8130
8228
|
/**
|
|
8131
|
-
* @
|
|
8132
|
-
* @
|
|
8133
|
-
* @
|
|
8134
|
-
* @param
|
|
8135
|
-
* @param
|
|
8136
|
-
* @returns
|
|
8229
|
+
* @author ddj 2026年10月29号
|
|
8230
|
+
* @description: 写后读回并比较,内容一致视为无净变化不产生条目。
|
|
8231
|
+
* @param ctx Host 生命周期
|
|
8232
|
+
* @param sink 已确认写入的接收方
|
|
8233
|
+
* @param plan 采集计划
|
|
8234
|
+
* @returns 无;读回失败只记日志
|
|
8137
8235
|
*/
|
|
8138
|
-
function
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
const
|
|
8142
|
-
if (
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8236
|
+
function reportWrite(ctx, sink, plan) {
|
|
8237
|
+
const fs = ctx.get("fs");
|
|
8238
|
+
resolveTarget(ctx, plan.session, plan.path).then(async (target) => {
|
|
8239
|
+
const after = await readText(fs, target);
|
|
8240
|
+
if (after !== null && after.length > MAX_TEXT) return;
|
|
8241
|
+
if (plan.before === after) return;
|
|
8242
|
+
sink({
|
|
8243
|
+
cwd: plan.cwd,
|
|
8244
|
+
sessionId: plan.sessionId,
|
|
8245
|
+
path: plan.path,
|
|
8246
|
+
identity: plan.identity,
|
|
8247
|
+
before: plan.before,
|
|
8248
|
+
after,
|
|
8249
|
+
now: Date.now()
|
|
8250
|
+
});
|
|
8251
|
+
}).catch((error) => log.debug("待审读回失败:" + String(error)));
|
|
8146
8252
|
}
|
|
8147
8253
|
/**
|
|
8148
|
-
* @
|
|
8149
|
-
* @
|
|
8150
|
-
* @
|
|
8151
|
-
* @param
|
|
8152
|
-
* @
|
|
8153
|
-
* @returns 是否命中;空查询不命中
|
|
8254
|
+
* @author ddj 2026年10月29号
|
|
8255
|
+
* @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
|
|
8256
|
+
* @param fs 文件系统
|
|
8257
|
+
* @param target 目标
|
|
8258
|
+
* @returns 文本或 null
|
|
8154
8259
|
*/
|
|
8155
|
-
function
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8260
|
+
async function readText(fs, target) {
|
|
8261
|
+
try {
|
|
8262
|
+
return String(await fs.readText(target));
|
|
8263
|
+
} catch (error) {
|
|
8264
|
+
const code = error.code;
|
|
8265
|
+
if (code === "ENOENT" || code === "FS_NOT_FOUND" || code === "FS_TOO_LARGE") return null;
|
|
8266
|
+
throw error;
|
|
8267
|
+
}
|
|
8161
8268
|
}
|
|
8162
8269
|
//#endregion
|
|
8163
|
-
//#region src/
|
|
8164
|
-
/**
|
|
8165
|
-
* dsh-
|
|
8166
|
-
*
|
|
8167
|
-
*
|
|
8270
|
+
//#region src/pending/vcs.ts
|
|
8271
|
+
/**
|
|
8272
|
+
* 版本库导入:把工作副本相对基线的改动读成待审条目,来源对齐 dsh-diff-approval 的 vcs 内核(MIT)。
|
|
8273
|
+
* 只读命令,不修改工作副本;基线读取失败一律上报,不猜"文件不存在"。
|
|
8274
|
+
* @author ddj 2026年10月30号
|
|
8275
|
+
*/
|
|
8276
|
+
/** 命令超时:与上游一致,避免大仓 status 卡死。 */
|
|
8277
|
+
const COMMAND_TIMEOUT = 6e4;
|
|
8278
|
+
/** stdout 预算:够放一个常见源文件,超出交给上游判断为超大。 */
|
|
8279
|
+
const STDOUT_BUDGET = 8388608;
|
|
8280
|
+
/** 只有这些 git 报错才算"该版本不存在",其余一律抛出。 */
|
|
8281
|
+
const ABSENT_AT_REVISION = /does not exist in|exists on disk, but not in|not a valid object name|invalid object name|unknown revision|bad revision/i;
|
|
8282
|
+
/** 只有这些 P4 报错才算"该版本不存在",其余一律抛出。 */
|
|
8283
|
+
const P4_ABSENT_AT_REVISION = /no such file|not opened|does not exist|no file\(s\) to print|invalid file|no revisions/i;
|
|
8284
|
+
/** git 相对路径用正斜杠。 */
|
|
8285
|
+
const GIT_SEPARATOR = "/";
|
|
8286
|
+
/**
|
|
8287
|
+
* @author ddj 2026年10月30号
|
|
8288
|
+
* @description: 向上查找版本库根目录,同目录 git 优先于 svn,最后才是 Perforce 配置。
|
|
8289
|
+
* @param cwd 工作区
|
|
8290
|
+
* @returns 探测结果,未找到时 undefined
|
|
8168
8291
|
*/
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8292
|
+
async function detectVcsRoot(cwd) {
|
|
8293
|
+
let current = resolve(cwd);
|
|
8294
|
+
for (;;) {
|
|
8295
|
+
if (await exists(join(current, ".git"))) return {
|
|
8296
|
+
kind: "git",
|
|
8297
|
+
root: current
|
|
8298
|
+
};
|
|
8299
|
+
if (await exists(join(current, ".svn"))) return {
|
|
8300
|
+
kind: "svn",
|
|
8301
|
+
root: current
|
|
8302
|
+
};
|
|
8303
|
+
if (await isP4Root(current)) return {
|
|
8304
|
+
kind: "p4",
|
|
8305
|
+
root: current
|
|
8306
|
+
};
|
|
8307
|
+
const parent = dirname(current);
|
|
8308
|
+
if (parent === current) return void 0;
|
|
8309
|
+
current = parent;
|
|
8310
|
+
}
|
|
8311
|
+
}
|
|
8172
8312
|
/**
|
|
8173
|
-
*
|
|
8174
|
-
*
|
|
8175
|
-
*
|
|
8176
|
-
*
|
|
8313
|
+
* @author ddj 2026年10月30号
|
|
8314
|
+
* @description: 判断目录是否是 Perforce 工作区根(P4CONFIG 文件所在目录)。
|
|
8315
|
+
* @param dir 目录
|
|
8316
|
+
* @returns 是否 P4 根
|
|
8177
8317
|
*/
|
|
8178
|
-
|
|
8179
|
-
"
|
|
8180
|
-
|
|
8181
|
-
".tmp",
|
|
8182
|
-
".cache",
|
|
8183
|
-
"dist",
|
|
8184
|
-
"build",
|
|
8185
|
-
"vendor",
|
|
8186
|
-
"coverage",
|
|
8187
|
-
"__pycache__",
|
|
8188
|
-
".pnpm-store",
|
|
8189
|
-
".npm-cache",
|
|
8190
|
-
".codegraph",
|
|
8191
|
-
".workbuddy"
|
|
8192
|
-
];
|
|
8318
|
+
async function isP4Root(dir) {
|
|
8319
|
+
return await exists(join(dir, ".p4config")) || await exists(join(dir, ".p4config.txt"));
|
|
8320
|
+
}
|
|
8193
8321
|
/**
|
|
8194
|
-
*
|
|
8195
|
-
*
|
|
8196
|
-
*
|
|
8322
|
+
* @author ddj 2026年10月30号
|
|
8323
|
+
* @description: 读出当前工作副本相对基线的改动,只返回给定范围(含未跟踪按选项)。
|
|
8324
|
+
* @param shell shell 能力
|
|
8325
|
+
* @param cwd 工作区
|
|
8326
|
+
* @param options 导入选项
|
|
8327
|
+
* @returns 改动列表
|
|
8197
8328
|
*/
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8329
|
+
async function importVcsChanges(shell, cwd, options = {}) {
|
|
8330
|
+
const detected = await detectVcsRoot(cwd);
|
|
8331
|
+
if (!detected) return [];
|
|
8332
|
+
const changes = await readChanges(shell, detected, cwd, options);
|
|
8333
|
+
const only = options.path ? resolve(cwd, options.path) : void 0;
|
|
8334
|
+
return changes.filter((change) => !only || samePath(change.path, only));
|
|
8335
|
+
}
|
|
8204
8336
|
/**
|
|
8205
|
-
*
|
|
8206
|
-
*
|
|
8207
|
-
*
|
|
8208
|
-
*
|
|
8209
|
-
*
|
|
8210
|
-
*
|
|
8211
|
-
*
|
|
8212
|
-
* 字符类方案同时满足「查询不区分大小写」且「排除项保持大小写敏感」。
|
|
8213
|
-
* @author ddj 2026年08月24号 / 2026年09月20号
|
|
8214
|
-
* @param query 已规范化查询文本
|
|
8215
|
-
* @returns 安全 glob
|
|
8337
|
+
* @author ddj 2026年10月30号
|
|
8338
|
+
* @description: 按探测到的种类取改动,新增种类只在这里分流。
|
|
8339
|
+
* @param shell shell 能力
|
|
8340
|
+
* @param detected 版本库种类与根目录
|
|
8341
|
+
* @param cwd 工作区
|
|
8342
|
+
* @param options 导入选项
|
|
8343
|
+
* @returns 改动列表
|
|
8216
8344
|
*/
|
|
8217
|
-
function
|
|
8218
|
-
|
|
8345
|
+
async function readChanges(shell, detected, cwd, options) {
|
|
8346
|
+
if (detected.kind === "git") return gitChanges(shell, cwd, detected.root, options);
|
|
8347
|
+
if (detected.kind === "svn") return svnChanges(shell, cwd, detected.root, options);
|
|
8348
|
+
return p4Changes(shell, cwd, detected.root, options);
|
|
8219
8349
|
}
|
|
8220
8350
|
/**
|
|
8221
|
-
* @
|
|
8222
|
-
* @
|
|
8223
|
-
* @
|
|
8224
|
-
* @param
|
|
8225
|
-
* @
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8351
|
+
* @author ddj 2026年10月30号
|
|
8352
|
+
* @description: git 改动:status -z 解析状态,基线取 HEAD 版本,未跟踪可按选项导入为新建。
|
|
8353
|
+
* @param shell shell 能力
|
|
8354
|
+
* @param cwd 工作区
|
|
8355
|
+
* @param root 仓库根
|
|
8356
|
+
* @param options 导入选项
|
|
8357
|
+
* @returns 改动列表
|
|
8358
|
+
*/
|
|
8359
|
+
async function gitChanges(shell, cwd, root, options) {
|
|
8360
|
+
const status = await runCommand(shell, "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all", root);
|
|
8361
|
+
if (!status.ok) throw new Error("git status 失败:" + status.stderr.slice(0, 200));
|
|
8362
|
+
const changes = [];
|
|
8363
|
+
for (const entry of parseGitStatus(status.stdout)) {
|
|
8364
|
+
if (entry.status === "??" && !options.includeUntracked) continue;
|
|
8365
|
+
const absolute = join(root, ...entry.path.split(GIT_SEPARATOR));
|
|
8366
|
+
if (!insideRoot(root, absolute)) continue;
|
|
8367
|
+
const current = await readTextOf(absolute);
|
|
8368
|
+
if (entry.status === "??" || entry.status === "A ") {
|
|
8369
|
+
changes.push({
|
|
8370
|
+
path: absolute,
|
|
8371
|
+
earlierVersion: "none",
|
|
8372
|
+
oldText: "",
|
|
8373
|
+
newText: current
|
|
8374
|
+
});
|
|
8375
|
+
continue;
|
|
8376
|
+
}
|
|
8377
|
+
const baseline = await gitBaseline(shell, root, entry.path);
|
|
8378
|
+
changes.push({
|
|
8379
|
+
path: absolute,
|
|
8380
|
+
earlierVersion: baseline === null ? "none" : "file",
|
|
8381
|
+
oldText: baseline ?? "",
|
|
8382
|
+
newText: current
|
|
8383
|
+
});
|
|
8384
|
+
}
|
|
8385
|
+
return changes;
|
|
8232
8386
|
}
|
|
8233
8387
|
/**
|
|
8234
|
-
* @
|
|
8235
|
-
* @
|
|
8236
|
-
* @
|
|
8237
|
-
* @param
|
|
8238
|
-
* @
|
|
8388
|
+
* @author ddj 2026年10月30号
|
|
8389
|
+
* @description: svn 改动:status --xml 解析状态,基线取 BASE 版本。
|
|
8390
|
+
* @param shell shell 能力
|
|
8391
|
+
* @param cwd 工作区
|
|
8392
|
+
* @param root 工作副本根
|
|
8393
|
+
* @param options 导入选项
|
|
8394
|
+
* @returns 改动列表
|
|
8395
|
+
*/
|
|
8396
|
+
async function svnChanges(shell, cwd, root, options) {
|
|
8397
|
+
const status = await runCommand(shell, "svn status --xml", root);
|
|
8398
|
+
if (!status.ok) throw new Error("svn status 失败:" + status.stderr.slice(0, 200));
|
|
8399
|
+
const changes = [];
|
|
8400
|
+
for (const entry of parseSvnStatus(status.stdout)) {
|
|
8401
|
+
if (entry.status === "unversioned" && !options.includeUntracked) continue;
|
|
8402
|
+
const absolute = resolve(root, entry.path);
|
|
8403
|
+
if (!insideRoot(root, absolute)) continue;
|
|
8404
|
+
const current = await readTextOf(absolute);
|
|
8405
|
+
if (entry.status === "added" || entry.status === "unversioned" || entry.status === "replaced") {
|
|
8406
|
+
changes.push({
|
|
8407
|
+
path: absolute,
|
|
8408
|
+
earlierVersion: "none",
|
|
8409
|
+
oldText: "",
|
|
8410
|
+
newText: current
|
|
8411
|
+
});
|
|
8412
|
+
continue;
|
|
8413
|
+
}
|
|
8414
|
+
const baseline = await svnBaseline(shell, root, entry.path);
|
|
8415
|
+
changes.push({
|
|
8416
|
+
path: absolute,
|
|
8417
|
+
earlierVersion: "file",
|
|
8418
|
+
oldText: baseline,
|
|
8419
|
+
newText: current
|
|
8420
|
+
});
|
|
8421
|
+
}
|
|
8422
|
+
return changes;
|
|
8423
|
+
}
|
|
8424
|
+
/**
|
|
8425
|
+
* @author ddj 2026年10月30号
|
|
8426
|
+
* @description: 取 HEAD 版本内容;仅"该版本确实没有此文件"时返回 null,其余失败抛出。
|
|
8427
|
+
* @param shell shell 能力
|
|
8428
|
+
* @param root 仓库根
|
|
8429
|
+
* @param relative 仓库相对路径(正斜杠)
|
|
8430
|
+
* @returns 基线内容或 null
|
|
8239
8431
|
*/
|
|
8240
|
-
function
|
|
8241
|
-
const
|
|
8242
|
-
|
|
8243
|
-
|
|
8432
|
+
async function gitBaseline(shell, root, relative) {
|
|
8433
|
+
const size = await runCommand(shell, `git cat-file -s HEAD:${quote(relative)}`, root);
|
|
8434
|
+
const budget = size.ok ? Number(size.stdout.trim()) + 1024 : STDOUT_BUDGET;
|
|
8435
|
+
const shown = await runCommand(shell, `git show HEAD:${quote(relative)}`, root, Math.min(budget, STDOUT_BUDGET));
|
|
8436
|
+
if (shown.ok) return shown.stdout;
|
|
8437
|
+
if (ABSENT_AT_REVISION.test(shown.stderr)) return null;
|
|
8438
|
+
throw new Error("读取 git 基线失败:" + shown.stderr.slice(0, 200));
|
|
8244
8439
|
}
|
|
8245
8440
|
/**
|
|
8246
|
-
*
|
|
8247
|
-
* @
|
|
8248
|
-
* @
|
|
8441
|
+
* @author ddj 2026年10月30号
|
|
8442
|
+
* @description: 取 svn BASE 版本内容;失败一律抛出(svn 没有可靠的"不存在"判据)。
|
|
8443
|
+
* @param shell shell 能力
|
|
8444
|
+
* @param root 工作副本根
|
|
8445
|
+
* @param relative 相对路径
|
|
8446
|
+
* @returns 基线内容
|
|
8249
8447
|
*/
|
|
8250
|
-
function
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8448
|
+
async function svnBaseline(shell, root, relative) {
|
|
8449
|
+
const shown = await runCommand(shell, `svn cat -r BASE ${quote(relative)}`, root);
|
|
8450
|
+
if (!shown.ok) throw new Error("读取 svn 基线失败:" + shown.stderr.slice(0, 200));
|
|
8451
|
+
return shown.stdout;
|
|
8452
|
+
}
|
|
8453
|
+
/**
|
|
8454
|
+
* @author ddj 2026年10月30号
|
|
8455
|
+
* @description: Perforce 改动:opened(含 status 的未跟踪项)解析 depot 路径,再折成本地路径。
|
|
8456
|
+
* @param shell shell 能力
|
|
8457
|
+
* @param cwd 工作区
|
|
8458
|
+
* @param root P4 工作区根
|
|
8459
|
+
* @param options 导入选项
|
|
8460
|
+
* @returns 改动列表
|
|
8461
|
+
*/
|
|
8462
|
+
async function p4Changes(shell, cwd, root, options) {
|
|
8463
|
+
const depotPaths = /* @__PURE__ */ new Set();
|
|
8464
|
+
const opened = await runCommand(shell, "p4 opened", root);
|
|
8465
|
+
if (!opened.ok) throw new Error("p4 opened 失败:" + opened.stderr.slice(0, 200));
|
|
8466
|
+
for (const path of parseP4Opened(opened.stdout)) depotPaths.add(path);
|
|
8467
|
+
if (options.includeUntracked) {
|
|
8468
|
+
const status = await runCommand(shell, "p4 status", root);
|
|
8469
|
+
if (!status.ok) throw new Error("p4 status 失败:" + status.stderr.slice(0, 200));
|
|
8470
|
+
for (const path of parseP4Status(status.stdout)) depotPaths.add(path);
|
|
8471
|
+
}
|
|
8472
|
+
const changes = [];
|
|
8473
|
+
for (const depot of depotPaths) {
|
|
8474
|
+
const absolute = await p4LocalPath(shell, root, depot);
|
|
8475
|
+
if (!absolute || !insideRoot(root, absolute)) continue;
|
|
8476
|
+
const current = await readTextOf(absolute);
|
|
8477
|
+
const baseline = await p4Baseline(shell, root, depot);
|
|
8478
|
+
changes.push({
|
|
8479
|
+
path: absolute,
|
|
8480
|
+
earlierVersion: baseline === null ? "none" : "file",
|
|
8481
|
+
oldText: baseline ?? "",
|
|
8482
|
+
newText: current
|
|
8483
|
+
});
|
|
8257
8484
|
}
|
|
8485
|
+
return changes;
|
|
8258
8486
|
}
|
|
8259
8487
|
/**
|
|
8260
|
-
*
|
|
8261
|
-
* @
|
|
8262
|
-
* @param
|
|
8263
|
-
* @
|
|
8264
|
-
* @returns subprocess 可访问的根目录
|
|
8488
|
+
* @author ddj 2026年10月30号
|
|
8489
|
+
* @description: 解析 `p4 opened` 输出,只取 depot 路径(`//depot/x#3 - edit ...`)。
|
|
8490
|
+
* @param text 输出
|
|
8491
|
+
* @returns depot 路径列表
|
|
8265
8492
|
*/
|
|
8266
|
-
|
|
8267
|
-
const
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8272
|
-
return
|
|
8493
|
+
function parseP4Opened(text) {
|
|
8494
|
+
const paths = [];
|
|
8495
|
+
for (const line of text.split(/\r?\n/)) {
|
|
8496
|
+
const match = /^(\/\/[^#\s]+)#\d+/.exec(line.trim());
|
|
8497
|
+
if (match) paths.push(match[1]);
|
|
8498
|
+
}
|
|
8499
|
+
return paths;
|
|
8273
8500
|
}
|
|
8274
8501
|
/**
|
|
8275
|
-
*
|
|
8276
|
-
* @
|
|
8277
|
-
* @param
|
|
8278
|
-
* @
|
|
8279
|
-
* @returns 路径与完整性状态
|
|
8502
|
+
* @author ddj 2026年10月30号
|
|
8503
|
+
* @description: 解析 `p4 status` 输出;每行以本地路径或 depot 路径开头,取路径本身。
|
|
8504
|
+
* @param text 输出
|
|
8505
|
+
* @returns depot 或本地路径列表
|
|
8280
8506
|
*/
|
|
8281
|
-
function
|
|
8282
|
-
const
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
const truncated = Boolean(output.lossy) || unique.length > maxResults;
|
|
8292
|
-
return {
|
|
8293
|
-
files: unique.slice(0, maxResults).map(pathText),
|
|
8294
|
-
truncated,
|
|
8295
|
-
complete: !truncated
|
|
8296
|
-
};
|
|
8507
|
+
function parseP4Status(text) {
|
|
8508
|
+
const paths = [];
|
|
8509
|
+
for (const line of text.split(/\r?\n/)) {
|
|
8510
|
+
const trimmed = line.trim();
|
|
8511
|
+
if (!trimmed || trimmed.startsWith("-")) continue;
|
|
8512
|
+
const head = trimmed.split(/\s+-\s+/)[0]?.trim();
|
|
8513
|
+
if (!head) continue;
|
|
8514
|
+
paths.push(head);
|
|
8515
|
+
}
|
|
8516
|
+
return paths;
|
|
8297
8517
|
}
|
|
8298
8518
|
/**
|
|
8299
|
-
*
|
|
8300
|
-
* @
|
|
8301
|
-
* @param
|
|
8302
|
-
* @
|
|
8519
|
+
* @author ddj 2026年10月30号
|
|
8520
|
+
* @description: `p4 where` 把小写 depot 路径折成本地绝对路径。
|
|
8521
|
+
* @param shell shell 能力
|
|
8522
|
+
* @param root P4 工作区根
|
|
8523
|
+
* @param depot depot 路径
|
|
8524
|
+
* @returns 本地路径,失败时 undefined
|
|
8303
8525
|
*/
|
|
8304
|
-
function
|
|
8305
|
-
|
|
8306
|
-
|
|
8526
|
+
async function p4LocalPath(shell, root, depot) {
|
|
8527
|
+
if (depot.startsWith("//")) {
|
|
8528
|
+
const where = await runCommand(shell, `p4 where ${quote(depot)}`, root);
|
|
8529
|
+
if (!where.ok) return void 0;
|
|
8530
|
+
const tokens = where.stdout.trim().split(/\s+/);
|
|
8531
|
+
return tokens[tokens.length - 1] ?? void 0;
|
|
8532
|
+
}
|
|
8533
|
+
return resolve(root, depot);
|
|
8307
8534
|
}
|
|
8308
8535
|
/**
|
|
8309
|
-
*
|
|
8310
|
-
* @
|
|
8311
|
-
* @param
|
|
8312
|
-
* @
|
|
8536
|
+
* @author ddj 2026年10月30号
|
|
8537
|
+
* @description: 取 `#have` 版本内容;只有明确"没有该版本"时返回 null,其余失败抛出。
|
|
8538
|
+
* @param shell shell 能力
|
|
8539
|
+
* @param root P4 工作区根
|
|
8540
|
+
* @param depot depot 路径
|
|
8541
|
+
* @returns 基线内容或 null
|
|
8542
|
+
*/
|
|
8543
|
+
async function p4Baseline(shell, root, depot) {
|
|
8544
|
+
if (!depot.startsWith("//")) return null;
|
|
8545
|
+
const printed = await runCommand(shell, `p4 print -q ${quote(depot + "#have")}`, root);
|
|
8546
|
+
if (printed.ok) return printed.stdout;
|
|
8547
|
+
if (P4_ABSENT_AT_REVISION.test(printed.stderr)) return null;
|
|
8548
|
+
throw new Error("读取 p4 基线失败:" + printed.stderr.slice(0, 200));
|
|
8549
|
+
}
|
|
8550
|
+
/**
|
|
8551
|
+
* @author ddj 2026年10月30号
|
|
8552
|
+
* @description: 解析 git status -z 输出;重命名/复制记录按目标路径处理。
|
|
8553
|
+
* @param text -z 输出
|
|
8554
|
+
* @returns 状态与仓库相对路径
|
|
8555
|
+
*/
|
|
8556
|
+
function parseGitStatus(text) {
|
|
8557
|
+
const parts = text.split("\0").filter((part) => part.length > 2);
|
|
8558
|
+
const entries = [];
|
|
8559
|
+
for (let index = 0; index < parts.length; index++) {
|
|
8560
|
+
const part = parts[index];
|
|
8561
|
+
const status = part.slice(0, 2);
|
|
8562
|
+
const path = part.slice(3);
|
|
8563
|
+
if (status.includes("R") || status.includes("C")) index++;
|
|
8564
|
+
entries.push({
|
|
8565
|
+
status,
|
|
8566
|
+
path
|
|
8567
|
+
});
|
|
8568
|
+
}
|
|
8569
|
+
return entries;
|
|
8570
|
+
}
|
|
8571
|
+
/**
|
|
8572
|
+
* @author ddj 2026年10月30号
|
|
8573
|
+
* @description: 解析 svn status --xml 输出。
|
|
8574
|
+
* @param xml xml 文本
|
|
8575
|
+
* @returns 条目状态与相对路径
|
|
8576
|
+
*/
|
|
8577
|
+
function parseSvnStatus(xml) {
|
|
8578
|
+
const entries = [];
|
|
8579
|
+
for (const match of xml.matchAll(/<entry\s+path="([^"]+)"([\s\S]*?)<\/entry>/g)) {
|
|
8580
|
+
const status = /<wc-status[^>]*\sitem="([^"]+)"/.exec(match[2] ?? "")?.[1];
|
|
8581
|
+
if (status) entries.push({
|
|
8582
|
+
status,
|
|
8583
|
+
path: decodeXml(match[1])
|
|
8584
|
+
});
|
|
8585
|
+
}
|
|
8586
|
+
return entries;
|
|
8587
|
+
}
|
|
8588
|
+
/**
|
|
8589
|
+
* @author ddj 2026年10月30号
|
|
8590
|
+
* @description: 执行一条只读命令并收集输出;超时/取消/非零退出都回 ok=false,不抛出。
|
|
8591
|
+
* @param shell shell 能力
|
|
8592
|
+
* @param command 命令
|
|
8593
|
+
* @param workdir 工作目录
|
|
8594
|
+
* @param stdoutMaxBytes stdout 预算
|
|
8595
|
+
* @returns 输出与状态
|
|
8596
|
+
*/
|
|
8597
|
+
async function runCommand(shell, command, workdir, stdoutMaxBytes = STDOUT_BUDGET) {
|
|
8598
|
+
try {
|
|
8599
|
+
const spec = shell.resolve({
|
|
8600
|
+
command,
|
|
8601
|
+
workdir,
|
|
8602
|
+
timeoutMs: COMMAND_TIMEOUT,
|
|
8603
|
+
stdoutMaxBytes
|
|
8604
|
+
});
|
|
8605
|
+
const result = await (await shell.execute(spec)).result();
|
|
8606
|
+
return {
|
|
8607
|
+
ok: result.exitCode === 0 && !result.timedOut && !result.aborted,
|
|
8608
|
+
stdout: result.stdout.text,
|
|
8609
|
+
stderr: result.stderr.text
|
|
8610
|
+
};
|
|
8611
|
+
} catch (error) {
|
|
8612
|
+
return {
|
|
8613
|
+
ok: false,
|
|
8614
|
+
stdout: "",
|
|
8615
|
+
stderr: String(error)
|
|
8616
|
+
};
|
|
8617
|
+
}
|
|
8618
|
+
}
|
|
8619
|
+
/**
|
|
8620
|
+
* @author ddj 2026年10月30号
|
|
8621
|
+
* @description: 读单元格文本,不存在时返回 null。
|
|
8622
|
+
* @param path 文件路径
|
|
8623
|
+
* @returns 内容或 null
|
|
8624
|
+
*/
|
|
8625
|
+
async function readTextOf(path) {
|
|
8626
|
+
try {
|
|
8627
|
+
if (!(await stat(path)).isFile()) return null;
|
|
8628
|
+
return await readFile(path, "utf8");
|
|
8629
|
+
} catch {
|
|
8630
|
+
return null;
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
8633
|
+
/**
|
|
8634
|
+
* @author ddj 2026年10月30号
|
|
8635
|
+
* @description: 判断路径是否存在。
|
|
8636
|
+
* @param path 路径
|
|
8637
|
+
* @returns 是否存在
|
|
8638
|
+
*/
|
|
8639
|
+
async function exists(path) {
|
|
8640
|
+
try {
|
|
8641
|
+
await stat(path);
|
|
8642
|
+
return true;
|
|
8643
|
+
} catch {
|
|
8644
|
+
return false;
|
|
8645
|
+
}
|
|
8646
|
+
}
|
|
8647
|
+
/**
|
|
8648
|
+
* @author ddj 2026年10月30号
|
|
8649
|
+
* @description: 判断目标是否位于仓库根之内(Windows 下按大小写不敏感比较)。
|
|
8650
|
+
* @param root 仓库根
|
|
8651
|
+
* @param target 目标绝对路径
|
|
8652
|
+
* @returns 是否在根内
|
|
8653
|
+
*/
|
|
8654
|
+
function insideRoot(root, target) {
|
|
8655
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
8656
|
+
const rootKey = normalize(resolve(root)).replace(/[\\/]+$/, "") + sep;
|
|
8657
|
+
return normalize(resolve(target)).startsWith(rootKey);
|
|
8658
|
+
}
|
|
8659
|
+
/**
|
|
8660
|
+
* @author ddj 2026年10月30号
|
|
8661
|
+
* @description: 比较两个路径是否同一文件(Windows 下大小写不敏感)。
|
|
8662
|
+
* @param left 路径
|
|
8663
|
+
* @param right 路径
|
|
8664
|
+
* @returns 是否相同
|
|
8665
|
+
*/
|
|
8666
|
+
function samePath(left, right) {
|
|
8667
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
8668
|
+
return normalize(resolve(left)) === normalize(resolve(right));
|
|
8669
|
+
}
|
|
8670
|
+
/**
|
|
8671
|
+
* @author ddj 2026年10月30号
|
|
8672
|
+
* @description: 为 shell 命令参数加引号,避免空格与特殊字符改变命令含义。
|
|
8673
|
+
* @param value 参数
|
|
8674
|
+
* @returns 加引号后的参数
|
|
8675
|
+
*/
|
|
8676
|
+
function quote(value) {
|
|
8677
|
+
return "\"" + value.replace(/"/g, "\\\"") + "\"";
|
|
8678
|
+
}
|
|
8679
|
+
/**
|
|
8680
|
+
* @author ddj 2026年10月30号
|
|
8681
|
+
* @description: 还原 xml 实体(svn status 会转义非 ASCII 与空格)。
|
|
8682
|
+
* @param value 属性值
|
|
8683
|
+
* @returns 解码后的路径
|
|
8684
|
+
*/
|
|
8685
|
+
function decodeXml(value) {
|
|
8686
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&");
|
|
8687
|
+
}
|
|
8688
|
+
//#endregion
|
|
8689
|
+
//#region src/pending/hunks.ts
|
|
8690
|
+
/** 每个 hunk 的上下文行数。 */
|
|
8691
|
+
const CONTEXT = 3;
|
|
8692
|
+
/** LCS 单元格上限:超过后退化为整段替换,避免大文件卡死。 */
|
|
8693
|
+
const CELL_LIMIT = 1e6;
|
|
8694
|
+
/**
|
|
8695
|
+
* @author ddj 2026年10月29号
|
|
8696
|
+
* @description: 构造可直接交给官方渲染器的差异;两侧完全相同时返回空 hunk 列表。
|
|
8697
|
+
* @param path 相对路径
|
|
8698
|
+
* @param display 展示名
|
|
8699
|
+
* @param oldText 基线全文
|
|
8700
|
+
* @param newText 当前全文
|
|
8701
|
+
* @returns 文本差异
|
|
8702
|
+
*/
|
|
8703
|
+
function textDiff(path, display, oldText, newText) {
|
|
8704
|
+
const hunks = buildHunks(logicalLines(oldText), logicalLines(newText));
|
|
8705
|
+
return {
|
|
8706
|
+
kind: "text",
|
|
8707
|
+
path,
|
|
8708
|
+
display,
|
|
8709
|
+
before: true,
|
|
8710
|
+
after: true,
|
|
8711
|
+
hunks,
|
|
8712
|
+
coarse: hunks.length > 0 && isCoarse(oldText, newText)
|
|
8713
|
+
};
|
|
8714
|
+
}
|
|
8715
|
+
/**
|
|
8716
|
+
* @author ddj 2026年10月29号
|
|
8717
|
+
* @description: 块级 Keep:把该块的新内容写进基线,返回推进后的基线。
|
|
8718
|
+
* @param oldText 当前基线
|
|
8719
|
+
* @param block 目标块
|
|
8720
|
+
* @returns 新基线;块在当前基线中无法唯一定位时返回 null
|
|
8721
|
+
*/
|
|
8722
|
+
function keepBlock(oldText, block) {
|
|
8723
|
+
return spliceBlock(oldText, block.oldLines, block.newLines, block);
|
|
8724
|
+
}
|
|
8725
|
+
/**
|
|
8726
|
+
* @author ddj 2026年10月29号
|
|
8727
|
+
* @description: 块级 Revert:把该块在最新内容里换回基线内容,返回换回后的内容。
|
|
8728
|
+
* @param newText 当前内容
|
|
8729
|
+
* @param block 目标块
|
|
8730
|
+
* @returns 新内容;块无法唯一定位时返回 null
|
|
8731
|
+
*/
|
|
8732
|
+
function revertBlock(newText, block) {
|
|
8733
|
+
return spliceBlock(newText, block.newLines, block.oldLines, block);
|
|
8734
|
+
}
|
|
8735
|
+
/**
|
|
8736
|
+
* @author ddj 2026年10月29号
|
|
8737
|
+
* @description: 按"上下文 + 目标行"唯一定位后做一次整块替换,定位不唯一就不动。
|
|
8738
|
+
* @param content 目标全文
|
|
8739
|
+
* @param from 要被替换的块内行
|
|
8740
|
+
* @param to 替换成的块内行
|
|
8741
|
+
* @param block 来源块,提供上下文;缺上下文时退化为只看块内行
|
|
8742
|
+
* @returns 新全文或 null
|
|
8743
|
+
*/
|
|
8744
|
+
function spliceBlock(content, from, to, block) {
|
|
8745
|
+
if (from.length === 0 && to.length === 0) return null;
|
|
8746
|
+
const lines = logicalLines(content);
|
|
8747
|
+
const withContext = locateSpan(lines, [
|
|
8748
|
+
...block.contextBefore,
|
|
8749
|
+
...from,
|
|
8750
|
+
...block.contextAfter
|
|
8751
|
+
]);
|
|
8752
|
+
if (withContext !== null) return spliceAt(lines, withContext + block.contextBefore.length, from.length, to);
|
|
8753
|
+
const bare = locateSpan(lines, from);
|
|
8754
|
+
return bare === null ? null : spliceAt(lines, bare, from.length, to);
|
|
8755
|
+
}
|
|
8756
|
+
/**
|
|
8757
|
+
* @author ddj 2026年10月29号
|
|
8758
|
+
* @description: 在指定偏移处把连续若干行换成另一组行。
|
|
8759
|
+
* @param lines 目标行集合
|
|
8760
|
+
* @param start 起始偏移
|
|
8761
|
+
* @param count 被替换行数
|
|
8762
|
+
* @param to 替换成的行
|
|
8763
|
+
* @returns 新全文
|
|
8764
|
+
*/
|
|
8765
|
+
function spliceAt(lines, start, count, to) {
|
|
8766
|
+
return [
|
|
8767
|
+
...lines.slice(0, start),
|
|
8768
|
+
...to,
|
|
8769
|
+
...lines.slice(start + count)
|
|
8770
|
+
].join("\n");
|
|
8771
|
+
}
|
|
8772
|
+
/**
|
|
8773
|
+
* @author ddj 2026年10月29号
|
|
8774
|
+
* @description: 唯一定位一段行;出现多处命中即视为不可定位。
|
|
8775
|
+
* @param lines 目标行集合
|
|
8776
|
+
* @param pattern 待定位行
|
|
8777
|
+
* @returns 唯一偏移或 null
|
|
8778
|
+
*/
|
|
8779
|
+
function locateSpan(lines, pattern) {
|
|
8780
|
+
if (pattern.length === 0) return null;
|
|
8781
|
+
let at = -1;
|
|
8782
|
+
for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
|
|
8783
|
+
if (!pattern.every((text, index) => lines[offset + index] === text)) continue;
|
|
8784
|
+
if (at >= 0) return null;
|
|
8785
|
+
at = offset;
|
|
8786
|
+
}
|
|
8787
|
+
return at >= 0 ? at : null;
|
|
8788
|
+
}
|
|
8789
|
+
/**
|
|
8790
|
+
* @author ddj 2026年10月29号
|
|
8791
|
+
* @description: 生成带上下文的标准 hunk 列表。
|
|
8792
|
+
* @param oldLines 基线行
|
|
8793
|
+
* @param newLines 当前行
|
|
8794
|
+
* @returns hunk 列表
|
|
8795
|
+
*/
|
|
8796
|
+
function buildHunks(oldLines, newLines) {
|
|
8797
|
+
const ops = diffLines(oldLines, newLines);
|
|
8798
|
+
const hunks = [];
|
|
8799
|
+
let index = 0;
|
|
8800
|
+
let oldLine = 0;
|
|
8801
|
+
let newLine = 0;
|
|
8802
|
+
while (index < ops.length) {
|
|
8803
|
+
if (ops[index].kind === " ") {
|
|
8804
|
+
oldLine++;
|
|
8805
|
+
newLine++;
|
|
8806
|
+
index++;
|
|
8807
|
+
continue;
|
|
8808
|
+
}
|
|
8809
|
+
const head = ops.slice(Math.max(0, index - CONTEXT), index);
|
|
8810
|
+
const collected = [];
|
|
8811
|
+
let probe = index;
|
|
8812
|
+
while (probe < ops.length) {
|
|
8813
|
+
const op = ops[probe];
|
|
8814
|
+
if (op.kind !== " ") {
|
|
8815
|
+
collected.push(op);
|
|
8816
|
+
probe++;
|
|
8817
|
+
continue;
|
|
8818
|
+
}
|
|
8819
|
+
if (ops.slice(probe, probe + 6 + 1).some((item) => item.kind !== " ")) {
|
|
8820
|
+
collected.push(op);
|
|
8821
|
+
probe++;
|
|
8822
|
+
continue;
|
|
8823
|
+
}
|
|
8824
|
+
break;
|
|
8825
|
+
}
|
|
8826
|
+
const tail = ops.slice(probe, probe + CONTEXT);
|
|
8827
|
+
const oldStartLine = oldLine - head.length + 1;
|
|
8828
|
+
const newStartLine = newLine - head.length + 1;
|
|
8829
|
+
hunks.push(makeHunk([
|
|
8830
|
+
...head,
|
|
8831
|
+
...collected,
|
|
8832
|
+
...tail
|
|
8833
|
+
], oldStartLine, newStartLine));
|
|
8834
|
+
for (const op of collected) {
|
|
8835
|
+
if (op.kind !== "+") oldLine++;
|
|
8836
|
+
if (op.kind !== "-") newLine++;
|
|
8837
|
+
}
|
|
8838
|
+
oldLine += head.length + tail.length;
|
|
8839
|
+
newLine += head.length + tail.length;
|
|
8840
|
+
index = probe + tail.length;
|
|
8841
|
+
}
|
|
8842
|
+
return hunks;
|
|
8843
|
+
}
|
|
8844
|
+
/**
|
|
8845
|
+
* @author ddj 2026年10月29号
|
|
8846
|
+
* @description: 由一段操作装配 hunk。
|
|
8847
|
+
* @param ops 该 hunk 的操作
|
|
8848
|
+
* @param oldStart 基线起始行
|
|
8849
|
+
* @param newStart 当前起始行
|
|
8850
|
+
* @returns hunk
|
|
8851
|
+
*/
|
|
8852
|
+
function makeHunk(ops, oldStart, newStart) {
|
|
8853
|
+
return {
|
|
8854
|
+
oldStart,
|
|
8855
|
+
oldLines: ops.filter((op) => op.kind !== "+").length,
|
|
8856
|
+
newStart,
|
|
8857
|
+
newLines: ops.filter((op) => op.kind !== "-").length,
|
|
8858
|
+
lines: ops.map((op) => op.kind + op.text)
|
|
8859
|
+
};
|
|
8860
|
+
}
|
|
8861
|
+
/**
|
|
8862
|
+
* @author ddj 2026年10月29号
|
|
8863
|
+
* @description: 行级差异:先剪公共前后缀,剩余部分在限额内做 LCS,超限按整段替换。
|
|
8864
|
+
* @param oldLines 基线行
|
|
8865
|
+
* @param newLines 当前行
|
|
8866
|
+
* @returns 顺序完整的操作列表
|
|
8867
|
+
*/
|
|
8868
|
+
function diffLines(oldLines, newLines) {
|
|
8869
|
+
let head = 0;
|
|
8870
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head++;
|
|
8871
|
+
let tail = 0;
|
|
8872
|
+
while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail++;
|
|
8873
|
+
const left = oldLines.slice(head, oldLines.length - tail);
|
|
8874
|
+
const right = newLines.slice(head, newLines.length - tail);
|
|
8875
|
+
const middle = left.length * right.length > CELL_LIMIT ? coarseOps(left, right) : lcsOps(left, right);
|
|
8876
|
+
return [
|
|
8877
|
+
...oldLines.slice(0, head).map((text) => ({
|
|
8878
|
+
kind: " ",
|
|
8879
|
+
text
|
|
8880
|
+
})),
|
|
8881
|
+
...middle,
|
|
8882
|
+
...oldLines.slice(oldLines.length - tail).map((text) => ({
|
|
8883
|
+
kind: " ",
|
|
8884
|
+
text
|
|
8885
|
+
}))
|
|
8886
|
+
];
|
|
8887
|
+
}
|
|
8888
|
+
/**
|
|
8889
|
+
* @author ddj 2026年10月29号
|
|
8890
|
+
* @description: 超限时的退化口径:整段删除再整段新增,绝不谎报逐行对应。
|
|
8891
|
+
* @param left 基线剩余行
|
|
8892
|
+
* @param right 当前剩余行
|
|
8893
|
+
* @returns 操作列表
|
|
8894
|
+
*/
|
|
8895
|
+
function coarseOps(left, right) {
|
|
8896
|
+
return [...left.map((text) => ({
|
|
8897
|
+
kind: "-",
|
|
8898
|
+
text
|
|
8899
|
+
})), ...right.map((text) => ({
|
|
8900
|
+
kind: "+",
|
|
8901
|
+
text
|
|
8902
|
+
}))];
|
|
8903
|
+
}
|
|
8904
|
+
/**
|
|
8905
|
+
* @author ddj 2026年10月29号
|
|
8906
|
+
* @description: 限额内的 LCS 行差异。
|
|
8907
|
+
* @param left 基线剩余行
|
|
8908
|
+
* @param right 当前剩余行
|
|
8909
|
+
* @returns 操作列表
|
|
8910
|
+
*/
|
|
8911
|
+
function lcsOps(left, right) {
|
|
8912
|
+
const rows = left.length + 1;
|
|
8913
|
+
const cols = right.length + 1;
|
|
8914
|
+
const table = new Uint32Array(rows * cols);
|
|
8915
|
+
for (let i = left.length - 1; i >= 0; i--) for (let j = right.length - 1; j >= 0; j--) table[i * cols + j] = left[i] === right[j] ? table[(i + 1) * cols + j + 1] + 1 : Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1]);
|
|
8916
|
+
const ops = [];
|
|
8917
|
+
let i = 0;
|
|
8918
|
+
let j = 0;
|
|
8919
|
+
while (i < left.length && j < right.length) {
|
|
8920
|
+
if (left[i] === right[j]) {
|
|
8921
|
+
ops.push({
|
|
8922
|
+
kind: " ",
|
|
8923
|
+
text: left[i]
|
|
8924
|
+
});
|
|
8925
|
+
i++;
|
|
8926
|
+
j++;
|
|
8927
|
+
continue;
|
|
8928
|
+
}
|
|
8929
|
+
if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
|
|
8930
|
+
ops.push({
|
|
8931
|
+
kind: "-",
|
|
8932
|
+
text: left[i]
|
|
8933
|
+
});
|
|
8934
|
+
i++;
|
|
8935
|
+
} else {
|
|
8936
|
+
ops.push({
|
|
8937
|
+
kind: "+",
|
|
8938
|
+
text: right[j]
|
|
8939
|
+
});
|
|
8940
|
+
j++;
|
|
8941
|
+
}
|
|
8942
|
+
}
|
|
8943
|
+
while (i < left.length) ops.push({
|
|
8944
|
+
kind: "-",
|
|
8945
|
+
text: left[i++]
|
|
8946
|
+
});
|
|
8947
|
+
while (j < right.length) ops.push({
|
|
8948
|
+
kind: "+",
|
|
8949
|
+
text: right[j++]
|
|
8950
|
+
});
|
|
8951
|
+
return ops;
|
|
8952
|
+
}
|
|
8953
|
+
/**
|
|
8954
|
+
* @author ddj 2026年10月29号
|
|
8955
|
+
* @description: 判断这次差异是否触发了大文件退化口径。
|
|
8956
|
+
* @param oldText 基线全文
|
|
8957
|
+
* @param newText 当前全文
|
|
8958
|
+
* @returns 是否粗粒度
|
|
8959
|
+
*/
|
|
8960
|
+
function isCoarse(oldText, newText) {
|
|
8961
|
+
return logicalLines(oldText).length * logicalLines(newText).length > CELL_LIMIT;
|
|
8962
|
+
}
|
|
8963
|
+
//#endregion
|
|
8964
|
+
//#region src/pending/kernel.ts
|
|
8965
|
+
/**
|
|
8966
|
+
* @author ddj 2026年10月29号
|
|
8967
|
+
* @description: 严格比较两侧文本:不做 BOM/行尾归一化,归一化只允许用于展示。
|
|
8968
|
+
* @param left 一侧全文
|
|
8969
|
+
* @param right 另一侧全文
|
|
8970
|
+
* @returns 两者是否逐码元相等
|
|
8971
|
+
*/
|
|
8972
|
+
function sameEntryText(left, right) {
|
|
8973
|
+
return left === right;
|
|
8974
|
+
}
|
|
8975
|
+
/**
|
|
8976
|
+
* @author ddj 2026年10月29号
|
|
8977
|
+
* @description: 净零判定:基线等于当前内容即为已解决,但条目仍留在列表等待移除。
|
|
8978
|
+
* @param entry 待审条目
|
|
8979
|
+
* @returns 是否已无净差异
|
|
8980
|
+
*/
|
|
8981
|
+
function isSettled(entry) {
|
|
8982
|
+
return sameEntryText(entry.oldText, entry.newText);
|
|
8983
|
+
}
|
|
8984
|
+
/**
|
|
8985
|
+
* @author ddj 2026年10月29号
|
|
8986
|
+
* @description: 合并同一身份的两条条目:基线取较早来源,最新内容取较晚来源,会话取并集。
|
|
8987
|
+
* @param earlier 较早记录
|
|
8988
|
+
* @param later 较晚记录
|
|
8989
|
+
* @returns 合并后的新条目
|
|
8990
|
+
*/
|
|
8991
|
+
function mergeEntries(earlier, later) {
|
|
8992
|
+
const merged = {
|
|
8993
|
+
...later,
|
|
8994
|
+
updatedAt: later.updatedAt,
|
|
8995
|
+
sessionIds: unionSessions(earlier, later)
|
|
8996
|
+
};
|
|
8997
|
+
if (later.lineage === void 0 && earlier.lineage !== void 0) merged.lineage = earlier.lineage;
|
|
8998
|
+
if (earlier.earlierVersion === "none" && later.earlierVersion === "none") {
|
|
8999
|
+
merged.earlierVersion = "none";
|
|
9000
|
+
merged.oldText = earlier.oldText;
|
|
9001
|
+
return merged;
|
|
9002
|
+
}
|
|
9003
|
+
merged.earlierVersion = "file";
|
|
9004
|
+
merged.oldText = earlier.earlierVersion === "none" ? earlier.newText : earlier.oldText;
|
|
9005
|
+
return merged;
|
|
9006
|
+
}
|
|
9007
|
+
/**
|
|
9008
|
+
* @author ddj 2026年10月29号
|
|
9009
|
+
* @description: 由新建入参构造条目,并把首个会话写进会话集合。
|
|
9010
|
+
* @param input 新建入参
|
|
9011
|
+
* @returns 新条目
|
|
9012
|
+
*/
|
|
9013
|
+
function entryFromNew(input) {
|
|
9014
|
+
const entry = {
|
|
9015
|
+
id: input.id,
|
|
9016
|
+
path: input.path,
|
|
9017
|
+
earlierVersion: input.earlierVersion,
|
|
9018
|
+
oldText: input.oldText,
|
|
9019
|
+
newText: input.newText,
|
|
9020
|
+
updatedAt: input.updatedAt,
|
|
9021
|
+
sessionId: input.sessionId,
|
|
9022
|
+
sessionIds: [input.sessionId]
|
|
9023
|
+
};
|
|
9024
|
+
if (input.lineage !== void 0) entry.lineage = input.lineage;
|
|
9025
|
+
return entry;
|
|
9026
|
+
}
|
|
9027
|
+
/**
|
|
9028
|
+
* @author ddj 2026年10月29号
|
|
9029
|
+
* @description: 应用一次决定带来的局部推进:块级 Keep 推进基线,块级 Revert 推进当前内容。
|
|
9030
|
+
* @param entry 待审条目
|
|
9031
|
+
* @param patch 要推进的一侧
|
|
9032
|
+
* @param now 本次更新时间
|
|
9033
|
+
* @returns 新条目;基线被推进时"创建"身份结束
|
|
9034
|
+
*/
|
|
9035
|
+
function applyUpdate(entry, patch, now) {
|
|
9036
|
+
const next = {
|
|
9037
|
+
...entry,
|
|
9038
|
+
updatedAt: now
|
|
9039
|
+
};
|
|
9040
|
+
if (patch.oldText !== void 0) {
|
|
9041
|
+
next.oldText = patch.oldText;
|
|
9042
|
+
next.earlierVersion = "file";
|
|
9043
|
+
}
|
|
9044
|
+
if (patch.newText !== void 0) next.newText = patch.newText;
|
|
9045
|
+
delete next.unseen;
|
|
9046
|
+
return next;
|
|
9047
|
+
}
|
|
9048
|
+
/**
|
|
9049
|
+
* @author ddj 2026年10月29号
|
|
9050
|
+
* @description: 合并两条条目的会话集合并去重,且保证首会话在列。
|
|
9051
|
+
* @param earlier 较早记录
|
|
9052
|
+
* @param later 较晚记录
|
|
9053
|
+
* @returns 去重后的会话集合
|
|
9054
|
+
*/
|
|
9055
|
+
function unionSessions(earlier, later) {
|
|
9056
|
+
const merged = [];
|
|
9057
|
+
for (const sessionId of [
|
|
9058
|
+
earlier.sessionId,
|
|
9059
|
+
...earlier.sessionIds,
|
|
9060
|
+
later.sessionId,
|
|
9061
|
+
...later.sessionIds
|
|
9062
|
+
]) if (sessionId && !merged.includes(sessionId)) merged.push(sessionId);
|
|
9063
|
+
return merged;
|
|
9064
|
+
}
|
|
9065
|
+
//#endregion
|
|
9066
|
+
//#region src/pending/decide.ts
|
|
9067
|
+
/**
|
|
9068
|
+
* @author ddj 2026年10月29号
|
|
9069
|
+
* @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
|
|
9070
|
+
* @param input 判定输入
|
|
9071
|
+
* @returns 判定结果
|
|
9072
|
+
*/
|
|
9073
|
+
async function decide(input) {
|
|
9074
|
+
const failed = (reason) => ({
|
|
9075
|
+
ok: false,
|
|
9076
|
+
reason,
|
|
9077
|
+
entry: input.entry,
|
|
9078
|
+
flags: input.flags,
|
|
9079
|
+
blocks: input.blocks,
|
|
9080
|
+
wrote: false
|
|
9081
|
+
});
|
|
9082
|
+
if (input.request.revision !== input.flags.revision) return failed("revision-mismatch");
|
|
9083
|
+
if (input.flags.oversized) return failed("oversized");
|
|
9084
|
+
if (input.entry.oldText === input.entry.newText) return failed("settled");
|
|
9085
|
+
const targets = pickBlocks(reviewBlocks(textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)), input);
|
|
9086
|
+
if (targets === null) return failed("unknown-block");
|
|
9087
|
+
if (input.request.scope !== "file" && input.request.scope !== "all" && targets.length === 0) return failed("no-block");
|
|
9088
|
+
const isKeep = input.request.decision === "accepted";
|
|
9089
|
+
const whole = input.request.scope === "file" || input.request.scope === "all";
|
|
9090
|
+
return isKeep ? keep(input, targets, whole) : revert(input, targets, whole);
|
|
9091
|
+
}
|
|
9092
|
+
/**
|
|
9093
|
+
* @author ddj 2026年10月29号
|
|
9094
|
+
* @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
|
|
9095
|
+
* @param input 判定输入
|
|
9096
|
+
* @param targets 目标块(整文件时为空)
|
|
9097
|
+
* @param whole 是否整文件
|
|
9098
|
+
* @returns 判定结果
|
|
9099
|
+
*/
|
|
9100
|
+
function keep(input, targets, whole) {
|
|
9101
|
+
if (whole) return {
|
|
9102
|
+
ok: true,
|
|
9103
|
+
reason: "",
|
|
9104
|
+
entry: applyUpdate(input.entry, { oldText: input.entry.newText }, input.now),
|
|
9105
|
+
wrote: false,
|
|
9106
|
+
flags: {
|
|
9107
|
+
...input.flags,
|
|
9108
|
+
revision: input.flags.revision + 1
|
|
9109
|
+
},
|
|
9110
|
+
blocks: markAll(input.blocks, targets, "accepted")
|
|
9111
|
+
};
|
|
9112
|
+
let oldText = input.entry.oldText;
|
|
9113
|
+
for (const block of targets) {
|
|
9114
|
+
const next = keepBlock(oldText, block);
|
|
9115
|
+
if (next === null) return {
|
|
9116
|
+
ok: false,
|
|
9117
|
+
reason: "ambiguous-block",
|
|
9118
|
+
entry: input.entry,
|
|
9119
|
+
flags: input.flags,
|
|
9120
|
+
blocks: input.blocks,
|
|
9121
|
+
wrote: false
|
|
9122
|
+
};
|
|
9123
|
+
oldText = next;
|
|
9124
|
+
}
|
|
9125
|
+
return {
|
|
9126
|
+
ok: true,
|
|
9127
|
+
reason: "",
|
|
9128
|
+
entry: applyUpdate(input.entry, { oldText }, input.now),
|
|
9129
|
+
wrote: false,
|
|
9130
|
+
flags: {
|
|
9131
|
+
...input.flags,
|
|
9132
|
+
revision: input.flags.revision + 1
|
|
9133
|
+
},
|
|
9134
|
+
blocks: markAll(input.blocks, targets, "accepted")
|
|
9135
|
+
};
|
|
9136
|
+
}
|
|
9137
|
+
/**
|
|
9138
|
+
* @author ddj 2026年10月29号
|
|
9139
|
+
* @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
|
|
9140
|
+
* @param input 判定输入
|
|
9141
|
+
* @param targets 目标块(整文件时为空)
|
|
9142
|
+
* @param whole 是否整文件
|
|
9143
|
+
* @returns 判定结果
|
|
9144
|
+
*/
|
|
9145
|
+
async function revert(input, targets, whole) {
|
|
9146
|
+
const failed = (reason) => ({
|
|
9147
|
+
ok: false,
|
|
9148
|
+
reason,
|
|
9149
|
+
entry: input.entry,
|
|
9150
|
+
flags: input.flags,
|
|
9151
|
+
blocks: input.blocks,
|
|
9152
|
+
wrote: false
|
|
9153
|
+
});
|
|
9154
|
+
const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets);
|
|
9155
|
+
if (next === void 0) return failed("ambiguous-block");
|
|
9156
|
+
const written = await input.write(next);
|
|
9157
|
+
if (!written.ok) return failed(written.reason);
|
|
9158
|
+
return {
|
|
9159
|
+
ok: true,
|
|
9160
|
+
reason: "",
|
|
9161
|
+
entry: applyUpdate(input.entry, { newText: written.content ?? "" }, input.now),
|
|
9162
|
+
wrote: true,
|
|
9163
|
+
flags: {
|
|
9164
|
+
...input.flags,
|
|
9165
|
+
revision: input.flags.revision + 1,
|
|
9166
|
+
missing: written.content === null
|
|
9167
|
+
},
|
|
9168
|
+
blocks: markAll(input.blocks, targets, "rejected")
|
|
9169
|
+
};
|
|
9170
|
+
}
|
|
9171
|
+
/**
|
|
9172
|
+
* @author ddj 2026年10月29号
|
|
9173
|
+
* @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
|
|
9174
|
+
* @param entry 待审条目
|
|
9175
|
+
* @returns 目标内容,null 表示删除
|
|
9176
|
+
*/
|
|
9177
|
+
function wholeRevert(entry) {
|
|
9178
|
+
return entry.earlierVersion === "none" ? null : entry.oldText;
|
|
9179
|
+
}
|
|
9180
|
+
/**
|
|
9181
|
+
* @author ddj 2026年10月29号
|
|
9182
|
+
* @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
|
|
9183
|
+
* @param entry 待审条目
|
|
9184
|
+
* @param targets 目标块
|
|
9185
|
+
* @returns 新内容或 undefined
|
|
9186
|
+
*/
|
|
9187
|
+
function blockRevert(entry, targets) {
|
|
9188
|
+
let newText = entry.newText;
|
|
9189
|
+
for (const block of targets) {
|
|
9190
|
+
const next = revertBlock(newText, block);
|
|
9191
|
+
if (next === null) return void 0;
|
|
9192
|
+
newText = next;
|
|
9193
|
+
}
|
|
9194
|
+
return newText;
|
|
9195
|
+
}
|
|
9196
|
+
/**
|
|
9197
|
+
* @author ddj 2026年10月29号
|
|
9198
|
+
* @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
|
|
9199
|
+
* @param blocks 当前差异块
|
|
9200
|
+
* @param input 判定输入
|
|
9201
|
+
* @returns 目标块,未知块 ID 时为 null
|
|
9202
|
+
*/
|
|
9203
|
+
function pickBlocks(blocks, input) {
|
|
9204
|
+
const scope = input.request.scope;
|
|
9205
|
+
if (scope === "file" || scope === "all") return [];
|
|
9206
|
+
if (scope === "block") {
|
|
9207
|
+
const block = blocks.find((item) => item.id === input.request.blockId);
|
|
9208
|
+
return block ? [block] : null;
|
|
9209
|
+
}
|
|
9210
|
+
const start = input.request.startLine ?? 0;
|
|
9211
|
+
const end = input.request.endLine ?? start;
|
|
9212
|
+
return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start);
|
|
9213
|
+
}
|
|
9214
|
+
/**
|
|
9215
|
+
* @author ddj 2026年10月29号
|
|
9216
|
+
* @description: 把目标块标记为已决定;整文件时把全部块一起标记。
|
|
9217
|
+
* @param states 已存块决定
|
|
9218
|
+
* @param targets 目标块
|
|
9219
|
+
* @param decision 决定
|
|
9220
|
+
* @returns 新的块决定列表
|
|
9221
|
+
*/
|
|
9222
|
+
function markAll(states, targets, decision) {
|
|
9223
|
+
const next = states.slice();
|
|
9224
|
+
for (const block of targets) {
|
|
9225
|
+
const at = next.findIndex((state) => state.id === block.id);
|
|
9226
|
+
if (at >= 0) next[at] = {
|
|
9227
|
+
id: block.id,
|
|
9228
|
+
decision
|
|
9229
|
+
};
|
|
9230
|
+
else next.push({
|
|
9231
|
+
id: block.id,
|
|
9232
|
+
decision
|
|
9233
|
+
});
|
|
9234
|
+
}
|
|
9235
|
+
return next;
|
|
9236
|
+
}
|
|
9237
|
+
/**
|
|
9238
|
+
* @author ddj 2026年10月29号
|
|
9239
|
+
* @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
|
|
9240
|
+
* @param entry 待审条目
|
|
9241
|
+
* @param flags 持久标记
|
|
9242
|
+
* @returns 能力与原因
|
|
9243
|
+
*/
|
|
9244
|
+
function capabilityOf(entry, flags) {
|
|
9245
|
+
if (isSettled(entry)) return {
|
|
9246
|
+
keep: false,
|
|
9247
|
+
revert: false,
|
|
9248
|
+
reason: "settled"
|
|
9249
|
+
};
|
|
9250
|
+
if (flags.oversized) return {
|
|
9251
|
+
keep: false,
|
|
9252
|
+
revert: false,
|
|
9253
|
+
reason: "oversized"
|
|
9254
|
+
};
|
|
9255
|
+
if (entry.earlierVersion === "none" && entry.newText.length === 0) return {
|
|
9256
|
+
keep: true,
|
|
9257
|
+
revert: false,
|
|
9258
|
+
reason: "empty-create"
|
|
9259
|
+
};
|
|
9260
|
+
return {
|
|
9261
|
+
keep: true,
|
|
9262
|
+
revert: true,
|
|
9263
|
+
reason: ""
|
|
9264
|
+
};
|
|
9265
|
+
}
|
|
9266
|
+
/**
|
|
9267
|
+
* @author ddj 2026年10月29号
|
|
9268
|
+
* @description: 供详情接口复用的差异构造。
|
|
9269
|
+
* @param entry 待审条目
|
|
9270
|
+
* @returns 官方差异
|
|
9271
|
+
*/
|
|
9272
|
+
function diffOf(entry) {
|
|
9273
|
+
return textDiff(entry.path, entry.path, entry.oldText, entry.newText);
|
|
9274
|
+
}
|
|
9275
|
+
//#endregion
|
|
9276
|
+
//#region src/pending/persist.ts
|
|
9277
|
+
/** 待审库版本号,写进 metadata 便于日后迁移。v3 给评论表加了引文与上下文列。 */
|
|
9278
|
+
const SCHEMA_VERSION = "3";
|
|
9279
|
+
/**
|
|
9280
|
+
* @author ddj 2026年10月29号
|
|
9281
|
+
* @description: 打开工作区库并确保待审表存在;DDL 与版本标记在同一事务里提交。
|
|
9282
|
+
* @param cwd 已迁移工作区
|
|
9283
|
+
* @returns 可读写的连接
|
|
9284
|
+
* @throws 未完成活跃库迁移时拒绝
|
|
9285
|
+
*/
|
|
9286
|
+
function pendingDb(cwd) {
|
|
9287
|
+
if (!activeReady(cwd)) throw new Error("待审投影要求先完成活跃库迁移");
|
|
9288
|
+
const db = dbFor(cwd);
|
|
9289
|
+
if (db.prepare("SELECT value FROM metadata WHERE name='pending_schema'").get()?.value === SCHEMA_VERSION) return db;
|
|
9290
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9291
|
+
try {
|
|
9292
|
+
db.exec(DDL);
|
|
9293
|
+
migrateHistory(db);
|
|
9294
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('pending_schema',?) ON CONFLICT(name) DO UPDATE SET value=excluded.value").run(SCHEMA_VERSION);
|
|
9295
|
+
db.exec("COMMIT");
|
|
9296
|
+
} catch (error) {
|
|
9297
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9298
|
+
throw error;
|
|
9299
|
+
}
|
|
9300
|
+
return db;
|
|
9301
|
+
}
|
|
9302
|
+
/** 待审表结构:条目、块决定、幂等操作、撤销栈。 */
|
|
9303
|
+
const DDL = `
|
|
9304
|
+
CREATE TABLE IF NOT EXISTS pending_entries (
|
|
9305
|
+
id TEXT PRIMARY KEY, path TEXT NOT NULL, earlier TEXT NOT NULL,
|
|
9306
|
+
old_text TEXT NOT NULL, new_text TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
|
9307
|
+
unseen INTEGER NOT NULL DEFAULT 0, missing INTEGER NOT NULL DEFAULT 0,
|
|
9308
|
+
diverged INTEGER NOT NULL DEFAULT 0, oversized INTEGER NOT NULL DEFAULT 0,
|
|
9309
|
+
revision INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
|
|
9310
|
+
session_ids TEXT NOT NULL, lineage TEXT);
|
|
9311
|
+
CREATE TABLE IF NOT EXISTS pending_blocks (
|
|
9312
|
+
entry_id TEXT NOT NULL, block_id TEXT NOT NULL, decision TEXT NOT NULL,
|
|
9313
|
+
PRIMARY KEY(entry_id,block_id));
|
|
9314
|
+
CREATE TABLE IF NOT EXISTS pending_ops (
|
|
9315
|
+
operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL,
|
|
9316
|
+
result TEXT, undo TEXT);
|
|
9317
|
+
CREATE TABLE IF NOT EXISTS pending_history (
|
|
9318
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, entry_id TEXT,
|
|
9319
|
+
snapshot TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
9320
|
+
state TEXT NOT NULL DEFAULT 'applied');
|
|
9321
|
+
`;
|
|
9322
|
+
/**
|
|
9323
|
+
* @author ddj 2026年10月29号
|
|
9324
|
+
* @description: v1 历史表补 state 列(rebuild 成本高于一次 ALTER)。
|
|
9325
|
+
* @param db 已打开的工作区库
|
|
9326
|
+
* @returns 无
|
|
9327
|
+
*/
|
|
9328
|
+
function migrateHistory(db) {
|
|
9329
|
+
if (db.prepare("PRAGMA table_info(pending_history)").all().some((column) => column.name === "state")) return;
|
|
9330
|
+
db.exec("ALTER TABLE pending_history ADD COLUMN state TEXT NOT NULL DEFAULT 'applied'");
|
|
9331
|
+
}
|
|
9332
|
+
/**
|
|
9333
|
+
* @author ddj 2026年10月29号
|
|
9334
|
+
* @description: 读出全部待审条目,按更新时间升序。
|
|
9335
|
+
* @param cwd 工作区
|
|
9336
|
+
* @returns 条目与标记
|
|
9337
|
+
*/
|
|
9338
|
+
function loadEntries(cwd) {
|
|
9339
|
+
return pendingDb(cwd).prepare("SELECT * FROM pending_entries ORDER BY updated_at ASC").all().map((row) => ({
|
|
9340
|
+
entry: entryOfRow(row),
|
|
9341
|
+
flags: flagsOfRow(row)
|
|
9342
|
+
}));
|
|
9343
|
+
}
|
|
9344
|
+
/**
|
|
9345
|
+
* @author ddj 2026年10月29号
|
|
9346
|
+
* @description: 写入或更新一条条目(含标记),单条原子完成。
|
|
9347
|
+
* @param cwd 工作区
|
|
9348
|
+
* @param stored 条目与标记
|
|
9349
|
+
* @returns 无
|
|
9350
|
+
*/
|
|
9351
|
+
function saveEntry(cwd, stored) {
|
|
9352
|
+
const { entry, flags } = stored;
|
|
9353
|
+
pendingDb(cwd).prepare(`INSERT INTO pending_entries
|
|
9354
|
+
(id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
|
|
9355
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
9356
|
+
ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
|
|
9357
|
+
new_text=excluded.new_text,updated_at=excluded.updated_at,unseen=excluded.unseen,missing=excluded.missing,
|
|
9358
|
+
diverged=excluded.diverged,oversized=excluded.oversized,revision=excluded.revision,
|
|
9359
|
+
session_id=excluded.session_id,session_ids=excluded.session_ids,lineage=excluded.lineage`).run(entry.id, entry.path, entry.earlierVersion, entry.oldText, entry.newText, entry.updatedAt, entry.unseen === true ? 1 : 0, flags.missing ? 1 : 0, flags.diverged ? 1 : 0, flags.oversized ? 1 : 0, flags.revision, entry.sessionId, JSON.stringify(entry.sessionIds), entry.lineage ? JSON.stringify(entry.lineage) : null);
|
|
9360
|
+
}
|
|
9361
|
+
/**
|
|
9362
|
+
* @author ddj 2026年10月29号
|
|
9363
|
+
* @description: 删除条目及其块决定,用于"已解决条目移除"。
|
|
9364
|
+
* @param cwd 工作区
|
|
9365
|
+
* @param id 条目身份
|
|
9366
|
+
* @returns 无
|
|
9367
|
+
*/
|
|
9368
|
+
function dropEntry(cwd, id) {
|
|
9369
|
+
const db = pendingDb(cwd);
|
|
9370
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9371
|
+
try {
|
|
9372
|
+
db.prepare("DELETE FROM pending_entries WHERE id=?").run(id);
|
|
9373
|
+
db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(id);
|
|
9374
|
+
db.exec("COMMIT");
|
|
9375
|
+
} catch (error) {
|
|
9376
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9377
|
+
throw error;
|
|
9378
|
+
}
|
|
9379
|
+
}
|
|
9380
|
+
/**
|
|
9381
|
+
* @author ddj 2026年10月29号
|
|
9382
|
+
* @description: 覆盖某条目的块决定集合。
|
|
9383
|
+
* @param cwd 工作区
|
|
9384
|
+
* @param entryId 条目身份
|
|
9385
|
+
* @param blocks 块决定
|
|
9386
|
+
* @returns 无
|
|
9387
|
+
*/
|
|
9388
|
+
function saveBlocks(cwd, entryId, blocks) {
|
|
9389
|
+
const db = pendingDb(cwd);
|
|
9390
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9391
|
+
try {
|
|
9392
|
+
db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(entryId);
|
|
9393
|
+
const insert = db.prepare("INSERT INTO pending_blocks VALUES(?,?,?)");
|
|
9394
|
+
for (const block of blocks) insert.run(entryId, block.id, block.decision);
|
|
9395
|
+
db.exec("COMMIT");
|
|
9396
|
+
} catch (error) {
|
|
9397
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9398
|
+
throw error;
|
|
9399
|
+
}
|
|
9400
|
+
}
|
|
9401
|
+
/**
|
|
9402
|
+
* @author ddj 2026年10月29号
|
|
9403
|
+
* @description: 读取幂等操作记录;同 operationId 的重复请求必须复用结果。
|
|
9404
|
+
* @param cwd 工作区
|
|
9405
|
+
* @param operationId 操作身份
|
|
9406
|
+
* @returns 请求原文、状态与结果
|
|
9407
|
+
*/
|
|
9408
|
+
function readOp(cwd, operationId) {
|
|
9409
|
+
const row = pendingDb(cwd).prepare("SELECT request,state,result FROM pending_ops WHERE operation_id=?").get(operationId);
|
|
9410
|
+
return row ? {
|
|
9411
|
+
request: String(row.request),
|
|
9412
|
+
state: String(row.state),
|
|
9413
|
+
result: row.result === null ? null : String(row.result)
|
|
9414
|
+
} : void 0;
|
|
9415
|
+
}
|
|
9416
|
+
/**
|
|
9417
|
+
* @author ddj 2026年10月29号
|
|
9418
|
+
* @description: 记录一次操作的阶段与撤销快照。
|
|
9419
|
+
* @param cwd 工作区
|
|
9420
|
+
* @param operationId 操作身份
|
|
9421
|
+
* @param request 请求原文
|
|
9422
|
+
* @param state prepared / committed
|
|
9423
|
+
* @param payload 结果与撤销快照
|
|
9424
|
+
* @returns 无
|
|
9425
|
+
*/
|
|
9426
|
+
function writeOp(cwd, operationId, request, state, payload = {}) {
|
|
9427
|
+
pendingDb(cwd).prepare(`INSERT INTO pending_ops VALUES(?,?,?,?,?)
|
|
9428
|
+
ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,
|
|
9429
|
+
result=COALESCE(excluded.result,pending_ops.result), undo=COALESCE(excluded.undo,pending_ops.undo)`).run(operationId, request, state, payload.result ?? null, payload.undo ?? null);
|
|
9430
|
+
}
|
|
9431
|
+
/**
|
|
9432
|
+
* @author ddj 2026年10月29号
|
|
9433
|
+
* @description: 追加一条撤销栈记录,并截断重做分支(新决定之后旧的"已撤销"记录不能再重做)。
|
|
9434
|
+
* @param cwd 工作区
|
|
9435
|
+
* @param kind 操作种类
|
|
9436
|
+
* @param entryId 涉及的条目
|
|
9437
|
+
* @param snapshot 可重放快照
|
|
9438
|
+
* @param now 时间
|
|
9439
|
+
* @returns 无
|
|
9440
|
+
*/
|
|
9441
|
+
function pushHistory(cwd, kind, entryId, snapshot, now) {
|
|
9442
|
+
const db = pendingDb(cwd);
|
|
9443
|
+
db.prepare("DELETE FROM pending_history WHERE state='undone'").run();
|
|
9444
|
+
db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')").run(kind, entryId, snapshot, now);
|
|
9445
|
+
}
|
|
9446
|
+
/**
|
|
9447
|
+
* @author ddj 2026年10月30号
|
|
9448
|
+
* @description: 取最近一条仍生效的历史记录,撤销就作用在它上面。
|
|
9449
|
+
* @param cwd 工作区
|
|
9450
|
+
* @returns 记录或 undefined
|
|
9451
|
+
*/
|
|
9452
|
+
function latestApplied(cwd) {
|
|
9453
|
+
const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='applied' ORDER BY id DESC LIMIT 1").get();
|
|
9454
|
+
return row ? rowOfHistory(row) : void 0;
|
|
9455
|
+
}
|
|
9456
|
+
/**
|
|
9457
|
+
* @author ddj 2026年10月30号
|
|
9458
|
+
* @description: 取最早一条被撤销的记录,重做按"撤销的逆序"重新生效。
|
|
9459
|
+
* @param cwd 工作区
|
|
9460
|
+
* @returns 记录或 undefined
|
|
9461
|
+
*/
|
|
9462
|
+
function earliestUndone(cwd) {
|
|
9463
|
+
const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='undone' ORDER BY id ASC LIMIT 1").get();
|
|
9464
|
+
return row ? rowOfHistory(row) : void 0;
|
|
9465
|
+
}
|
|
9466
|
+
/**
|
|
9467
|
+
* @author ddj 2026年10月30号
|
|
9468
|
+
* @description: 翻转一条历史记录的生效状态。
|
|
9469
|
+
* @param cwd 工作区
|
|
9470
|
+
* @param id 记录主键
|
|
9471
|
+
* @param state applied / undone
|
|
9472
|
+
* @returns 无
|
|
9473
|
+
*/
|
|
9474
|
+
function setHistoryState(cwd, id, state) {
|
|
9475
|
+
pendingDb(cwd).prepare("UPDATE pending_history SET state=? WHERE id=?").run(state, id);
|
|
9476
|
+
}
|
|
9477
|
+
/**
|
|
9478
|
+
* @author ddj 2026年10月30号
|
|
9479
|
+
* @description: 把数据库行还原成历史记录。
|
|
9480
|
+
* @param row 数据库行
|
|
9481
|
+
* @returns 记录
|
|
9482
|
+
*/
|
|
9483
|
+
function rowOfHistory(row) {
|
|
9484
|
+
return {
|
|
9485
|
+
id: Number(row.id),
|
|
9486
|
+
kind: String(row.kind),
|
|
9487
|
+
entryId: String(row.entry_id),
|
|
9488
|
+
snapshot: String(row.snapshot)
|
|
9489
|
+
};
|
|
9490
|
+
}
|
|
9491
|
+
/**
|
|
9492
|
+
* @author ddj 2026年10月29号
|
|
9493
|
+
* @description: 由数据库行还原条目。
|
|
9494
|
+
* @param row 数据库行
|
|
9495
|
+
* @returns 条目
|
|
9496
|
+
*/
|
|
9497
|
+
function entryOfRow(row) {
|
|
9498
|
+
const lineage = row.lineage === null ? void 0 : JSON.parse(String(row.lineage));
|
|
9499
|
+
const entry = {
|
|
9500
|
+
id: String(row.id),
|
|
9501
|
+
path: String(row.path),
|
|
9502
|
+
earlierVersion: String(row.earlier) === "none" ? "none" : "file",
|
|
9503
|
+
oldText: String(row.old_text),
|
|
9504
|
+
newText: String(row.new_text),
|
|
9505
|
+
updatedAt: Number(row.updated_at),
|
|
9506
|
+
sessionId: String(row.session_id),
|
|
9507
|
+
sessionIds: JSON.parse(String(row.session_ids))
|
|
9508
|
+
};
|
|
9509
|
+
if (Number(row.unseen) === 1) entry.unseen = true;
|
|
9510
|
+
if (lineage !== void 0) entry.lineage = lineage;
|
|
9511
|
+
return entry;
|
|
9512
|
+
}
|
|
9513
|
+
/**
|
|
9514
|
+
* @author ddj 2026年10月29号
|
|
9515
|
+
* @description: 由数据库行还原标记。
|
|
9516
|
+
* @param row 数据库行
|
|
9517
|
+
* @returns 标记
|
|
9518
|
+
*/
|
|
9519
|
+
function flagsOfRow(row) {
|
|
9520
|
+
return {
|
|
9521
|
+
missing: Number(row.missing) === 1,
|
|
9522
|
+
diverged: Number(row.diverged) === 1,
|
|
9523
|
+
oversized: Number(row.oversized) === 1,
|
|
9524
|
+
revision: Number(row.revision)
|
|
9525
|
+
};
|
|
9526
|
+
}
|
|
9527
|
+
//#endregion
|
|
9528
|
+
//#region src/pending/store.ts
|
|
9529
|
+
/**
|
|
9530
|
+
* @author ddj 2026年10月29号
|
|
9531
|
+
* @description: 统计一次差异的增删行数;非文本差异统一记 0/0。
|
|
9532
|
+
* @param entry 待审条目
|
|
9533
|
+
* @returns 增删行数
|
|
9534
|
+
*/
|
|
9535
|
+
function countsOf(entry) {
|
|
9536
|
+
const diff = diffOf(entry);
|
|
9537
|
+
if (diff.kind !== "text") return {
|
|
9538
|
+
added: 0,
|
|
9539
|
+
deleted: 0
|
|
9540
|
+
};
|
|
9541
|
+
let added = 0;
|
|
9542
|
+
let deleted = 0;
|
|
9543
|
+
for (const hunk of diff.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added++;
|
|
9544
|
+
else if (line.startsWith("-")) deleted++;
|
|
9545
|
+
return {
|
|
9546
|
+
added,
|
|
9547
|
+
deleted
|
|
9548
|
+
};
|
|
9549
|
+
}
|
|
9550
|
+
/**
|
|
9551
|
+
* @author ddj 2026年10月30号
|
|
9552
|
+
* @description: 由当前累计条目现场推导块身份;块是差异的纯函数,落库的只是决定状态。
|
|
9553
|
+
* @param entry 累计条目
|
|
9554
|
+
* @returns 块状态列表(决定一律 pending,判定推进后由下一次推导自然收窄)
|
|
9555
|
+
*/
|
|
9556
|
+
function blockStatesOf(entry) {
|
|
9557
|
+
const diff = diffOf(entry);
|
|
9558
|
+
if (diff.kind !== "text") return [];
|
|
9559
|
+
return reviewBlocks(diff).map((block) => ({
|
|
9560
|
+
id: block.id,
|
|
9561
|
+
decision: "pending"
|
|
9562
|
+
}));
|
|
9563
|
+
}
|
|
9564
|
+
/**
|
|
9565
|
+
* @author ddj 2026年10月29号
|
|
9566
|
+
* @description: 把一次已确认写入折进累计条目:同一身份只保留一条,基线留最早、内容取最新。
|
|
9567
|
+
* @param stored 已有条目(可为空)
|
|
9568
|
+
* @param write 采集到的写入
|
|
9569
|
+
* @returns 折叠后的条目与标记
|
|
9570
|
+
*/
|
|
9571
|
+
function foldWrite(stored, write) {
|
|
9572
|
+
const fresh = entryFromNew({
|
|
9573
|
+
id: write.identity,
|
|
9574
|
+
path: write.path,
|
|
9575
|
+
sessionId: write.sessionId,
|
|
9576
|
+
earlierVersion: write.before === null ? "none" : "file",
|
|
9577
|
+
oldText: write.before ?? "",
|
|
9578
|
+
newText: write.after ?? "",
|
|
9579
|
+
updatedAt: write.now
|
|
9580
|
+
});
|
|
9581
|
+
if (!stored) return {
|
|
9582
|
+
entry: fresh,
|
|
9583
|
+
flags: {
|
|
9584
|
+
missing: write.after === null,
|
|
9585
|
+
diverged: false,
|
|
9586
|
+
oversized: false,
|
|
9587
|
+
revision: 0
|
|
9588
|
+
}
|
|
9589
|
+
};
|
|
9590
|
+
const merged = mergeEntries(stored.entry, fresh);
|
|
9591
|
+
merged.path = write.path;
|
|
9592
|
+
return {
|
|
9593
|
+
entry: merged,
|
|
9594
|
+
flags: {
|
|
9595
|
+
missing: write.after === null,
|
|
9596
|
+
diverged: false,
|
|
9597
|
+
oversized: stored.flags.oversized,
|
|
9598
|
+
revision: stored.flags.revision + 1
|
|
9599
|
+
}
|
|
9600
|
+
};
|
|
9601
|
+
}
|
|
9602
|
+
/**
|
|
9603
|
+
* @author ddj 2026年10月29号
|
|
9604
|
+
* @description: 条目转列表投影,已解决条目仍保留但不可再决定。
|
|
9605
|
+
* @param stored 条目与标记
|
|
9606
|
+
* @returns 列表项
|
|
9607
|
+
*/
|
|
9608
|
+
function itemOf(stored) {
|
|
9609
|
+
const { entry, flags } = stored;
|
|
9610
|
+
const counts = countsOf(entry);
|
|
9611
|
+
const item = {
|
|
9612
|
+
id: entry.id,
|
|
9613
|
+
path: entry.path,
|
|
9614
|
+
earlierVersion: entry.earlierVersion,
|
|
9615
|
+
added: counts.added,
|
|
9616
|
+
deleted: counts.deleted,
|
|
9617
|
+
settled: isSettled(entry),
|
|
9618
|
+
unseen: entry.unseen === true,
|
|
9619
|
+
missing: flags.missing,
|
|
9620
|
+
diverged: flags.diverged,
|
|
9621
|
+
oversized: flags.oversized,
|
|
9622
|
+
revision: flags.revision,
|
|
9623
|
+
updatedAt: entry.updatedAt,
|
|
9624
|
+
sessionId: entry.sessionId,
|
|
9625
|
+
sessionIds: entry.sessionIds
|
|
9626
|
+
};
|
|
9627
|
+
if (entry.lineage !== void 0) item.lineage = entry.lineage;
|
|
9628
|
+
return item;
|
|
9629
|
+
}
|
|
9630
|
+
/**
|
|
9631
|
+
* @author ddj 2026年10月29号
|
|
9632
|
+
* @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
|
|
9633
|
+
* @param cwd 工作区
|
|
9634
|
+
* @param sessionId 当前会话
|
|
9635
|
+
* @returns 列表
|
|
9636
|
+
*/
|
|
9637
|
+
function loadPage(cwd, sessionId) {
|
|
9638
|
+
const all = loadEntries(cwd);
|
|
9639
|
+
const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all;
|
|
9640
|
+
const items = visible.map(itemOf);
|
|
9641
|
+
const gaps = [];
|
|
9642
|
+
for (const stored of visible) if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`);
|
|
9643
|
+
return {
|
|
9644
|
+
items,
|
|
9645
|
+
pending: items.filter((item) => !item.settled).length,
|
|
9646
|
+
nextCursor: null,
|
|
9647
|
+
gaps
|
|
9648
|
+
};
|
|
9649
|
+
}
|
|
9650
|
+
/**
|
|
9651
|
+
* @author ddj 2026年10月29号
|
|
9652
|
+
* @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
|
|
9653
|
+
* @param cwd 工作区
|
|
9654
|
+
* @param id 条目身份
|
|
9655
|
+
* @returns 详情,条目不存在时 null
|
|
9656
|
+
*/
|
|
9657
|
+
function loadDetail(cwd, id) {
|
|
9658
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === id);
|
|
9659
|
+
if (!stored) return null;
|
|
9660
|
+
const diff = diffOf(stored.entry);
|
|
9661
|
+
return {
|
|
9662
|
+
item: itemOf(stored),
|
|
9663
|
+
diff,
|
|
9664
|
+
blocks: blockStatesOf(stored.entry),
|
|
9665
|
+
capability: capabilityOf(stored.entry, stored.flags)
|
|
9666
|
+
};
|
|
9667
|
+
}
|
|
9668
|
+
/**
|
|
9669
|
+
* @author ddj 2026年10月29号
|
|
9670
|
+
* @description: 采集入口:折叠写入并落库。
|
|
9671
|
+
* @param write 已确认写入
|
|
9672
|
+
* @returns 无
|
|
9673
|
+
*/
|
|
9674
|
+
function recordWrite(write) {
|
|
9675
|
+
const stored = loadEntries(write.cwd).find((item) => item.entry.id === write.identity);
|
|
9676
|
+
if (stored && stored.entry.oldText === stored.entry.newText && stored.entry.newText === (write.after ?? "")) return;
|
|
9677
|
+
saveEntry(write.cwd, foldWrite(stored, write));
|
|
9678
|
+
}
|
|
9679
|
+
/**
|
|
9680
|
+
* @author ddj 2026年10月29号
|
|
9681
|
+
* @description: 外部改动吸收:当前内容与条目最新内容不同时采纳并标分歧,基线不动。
|
|
9682
|
+
* @param stored 条目
|
|
9683
|
+
* @param content 磁盘当前内容
|
|
9684
|
+
* @param now 时间
|
|
9685
|
+
* @returns 新条目与标记
|
|
9686
|
+
*/
|
|
9687
|
+
function absorbed(stored, content, now) {
|
|
9688
|
+
return {
|
|
9689
|
+
entry: {
|
|
9690
|
+
...stored.entry,
|
|
9691
|
+
newText: content,
|
|
9692
|
+
updatedAt: now,
|
|
9693
|
+
unseen: true
|
|
9694
|
+
},
|
|
9695
|
+
flags: {
|
|
9696
|
+
...stored.flags,
|
|
9697
|
+
diverged: true,
|
|
9698
|
+
missing: false,
|
|
9699
|
+
revision: stored.flags.revision + 1
|
|
9700
|
+
}
|
|
9701
|
+
};
|
|
9702
|
+
}
|
|
9703
|
+
/**
|
|
9704
|
+
* @author ddj 2026年10月29号
|
|
9705
|
+
* @description: 读回磁盘并把可读的外部改动吸收进条目;缺失只标标记不删条目。
|
|
9706
|
+
* @param stored 条目
|
|
9707
|
+
* @param content 磁盘当前内容,null 表示缺失或不可读
|
|
9708
|
+
* @param now 时间
|
|
9709
|
+
* @returns 需要落库的新状态,无需改动时为 null
|
|
9710
|
+
*/
|
|
9711
|
+
function refreshed(stored, content, now) {
|
|
9712
|
+
if (content === null) {
|
|
9713
|
+
if (stored.flags.missing) return null;
|
|
9714
|
+
return {
|
|
9715
|
+
entry: stored.entry,
|
|
9716
|
+
flags: {
|
|
9717
|
+
...stored.flags,
|
|
9718
|
+
missing: true,
|
|
9719
|
+
revision: stored.flags.revision + 1
|
|
9720
|
+
}
|
|
9721
|
+
};
|
|
9722
|
+
}
|
|
9723
|
+
if (content === stored.entry.newText) {
|
|
9724
|
+
if (!stored.flags.missing) return null;
|
|
9725
|
+
return {
|
|
9726
|
+
entry: stored.entry,
|
|
9727
|
+
flags: {
|
|
9728
|
+
...stored.flags,
|
|
9729
|
+
missing: false,
|
|
9730
|
+
revision: stored.flags.revision + 1
|
|
9731
|
+
}
|
|
9732
|
+
};
|
|
9733
|
+
}
|
|
9734
|
+
return absorbed(stored, content, now);
|
|
9735
|
+
}
|
|
9736
|
+
/**
|
|
9737
|
+
* @author ddj 2026年10月29号
|
|
9738
|
+
* @description: 构造带版本守卫与读回校验的写盘函数;内容已被外部改动时直接拒绝。
|
|
9739
|
+
* @param fs 文件系统
|
|
9740
|
+
* @param cwd 工作区
|
|
9741
|
+
* @param identity 目标身份
|
|
9742
|
+
* @param expected 写前应当具备的内容
|
|
9743
|
+
* @param signal 取消信号
|
|
9744
|
+
* @param policy 沙箱策略
|
|
9745
|
+
* @param allowCreate 文件已被删除时是否允许按快照重建(仅撤销/重做路径使用)
|
|
9746
|
+
* @returns 写盘函数
|
|
9747
|
+
*/
|
|
9748
|
+
function writerFor(fs, cwd, identity, expected, signal, policy, allowCreate = false) {
|
|
9749
|
+
return async (content) => {
|
|
9750
|
+
let target;
|
|
9751
|
+
try {
|
|
9752
|
+
target = await fs.resolve(identity, {
|
|
9753
|
+
cwd,
|
|
9754
|
+
signal
|
|
9755
|
+
});
|
|
9756
|
+
} catch {
|
|
9757
|
+
return {
|
|
9758
|
+
ok: false,
|
|
9759
|
+
reason: "resolve-failed"
|
|
9760
|
+
};
|
|
9761
|
+
}
|
|
9762
|
+
const current = await readText(fs, target).catch(() => void 0);
|
|
9763
|
+
if (current === void 0) return {
|
|
9764
|
+
ok: false,
|
|
9765
|
+
reason: "read-failed"
|
|
9766
|
+
};
|
|
9767
|
+
if (!(allowCreate && current === null) && current !== expected) return {
|
|
9768
|
+
ok: false,
|
|
9769
|
+
reason: "diverged"
|
|
9770
|
+
};
|
|
9771
|
+
const currentStat = await fs.stat(target, signal).catch(() => void 0);
|
|
9772
|
+
try {
|
|
9773
|
+
await fs.writeText(target, content ?? "", currentStat ? {
|
|
9774
|
+
kind: "replaceIfVersion",
|
|
9775
|
+
version: currentStat.version
|
|
9776
|
+
} : void 0, signal, policy);
|
|
9777
|
+
const readBack = await readText(fs, target);
|
|
9778
|
+
if ((content ?? "") !== (readBack ?? "")) return {
|
|
9779
|
+
ok: false,
|
|
9780
|
+
reason: "read-back-mismatch"
|
|
9781
|
+
};
|
|
9782
|
+
if (content === null && readBack !== null) return {
|
|
9783
|
+
ok: false,
|
|
9784
|
+
reason: "delete-failed"
|
|
9785
|
+
};
|
|
9786
|
+
return {
|
|
9787
|
+
ok: true,
|
|
9788
|
+
content
|
|
9789
|
+
};
|
|
9790
|
+
} catch (error) {
|
|
9791
|
+
return {
|
|
9792
|
+
ok: false,
|
|
9793
|
+
reason: reasonOf(error)
|
|
9794
|
+
};
|
|
9795
|
+
}
|
|
9796
|
+
};
|
|
9797
|
+
}
|
|
9798
|
+
/**
|
|
9799
|
+
* @author ddj 2026年10月29号
|
|
9800
|
+
* @description: 执行一次 Keep/Revert 并把结果落库;失败只返回原因,不写任何状态。
|
|
9801
|
+
* @param fs 文件系统
|
|
9802
|
+
* @param cwd 工作区
|
|
9803
|
+
* @param request 判定请求
|
|
9804
|
+
* @param signal 取消信号
|
|
9805
|
+
* @returns 判定结果
|
|
9806
|
+
*/
|
|
9807
|
+
async function applyDecide(fs, cwd, request, signal, policy) {
|
|
9808
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === request.id);
|
|
9809
|
+
if (!stored) return {
|
|
9810
|
+
ok: false,
|
|
9811
|
+
reason: "unknown-item"
|
|
9812
|
+
};
|
|
9813
|
+
const beforeBlocks = blockStatesOf(stored.entry);
|
|
9814
|
+
const outcome = await decide({
|
|
9815
|
+
request,
|
|
9816
|
+
entry: stored.entry,
|
|
9817
|
+
flags: stored.flags,
|
|
9818
|
+
blocks: beforeBlocks,
|
|
9819
|
+
now: Date.now(),
|
|
9820
|
+
write: (content) => {
|
|
9821
|
+
if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal);
|
|
9822
|
+
return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content);
|
|
9823
|
+
}
|
|
9824
|
+
});
|
|
9825
|
+
if (!outcome.ok) return {
|
|
9826
|
+
ok: false,
|
|
9827
|
+
reason: outcome.reason
|
|
9828
|
+
};
|
|
9829
|
+
saveEntry(cwd, {
|
|
9830
|
+
entry: outcome.entry,
|
|
9831
|
+
flags: outcome.flags
|
|
9832
|
+
});
|
|
9833
|
+
saveBlocks(cwd, request.id, outcome.blocks);
|
|
9834
|
+
pushHistory(cwd, "decide", request.id, JSON.stringify({
|
|
9835
|
+
before: {
|
|
9836
|
+
entry: stored.entry,
|
|
9837
|
+
flags: stored.flags,
|
|
9838
|
+
blocks: beforeBlocks
|
|
9839
|
+
},
|
|
9840
|
+
after: {
|
|
9841
|
+
entry: outcome.entry,
|
|
9842
|
+
flags: outcome.flags,
|
|
9843
|
+
blocks: outcome.blocks
|
|
9844
|
+
},
|
|
9845
|
+
wrote: outcome.wrote
|
|
9846
|
+
}), Date.now());
|
|
9847
|
+
return outcome;
|
|
9848
|
+
}
|
|
9849
|
+
/**
|
|
9850
|
+
* @author ddj 2026年10月30号
|
|
9851
|
+
* @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
|
|
9852
|
+
* @param shell shell 能力
|
|
9853
|
+
* @param cwd 工作区
|
|
9854
|
+
* @param sessionId 触发导入的会话
|
|
9855
|
+
* @param options 导入选项
|
|
9856
|
+
* @returns 导入条数与探测到的改动数
|
|
9857
|
+
*/
|
|
9858
|
+
async function importFromVcs(shell, cwd, sessionId, options = {}) {
|
|
9859
|
+
const changes = await importVcsChanges(shell, cwd, options);
|
|
9860
|
+
const now = Date.now();
|
|
9861
|
+
for (const change of changes) recordWrite({
|
|
9862
|
+
cwd,
|
|
9863
|
+
sessionId,
|
|
9864
|
+
path: change.path,
|
|
9865
|
+
identity: change.path,
|
|
9866
|
+
before: change.earlierVersion === "none" ? null : change.oldText,
|
|
9867
|
+
after: change.newText,
|
|
9868
|
+
now
|
|
9869
|
+
});
|
|
9870
|
+
return {
|
|
9871
|
+
imported: changes.length,
|
|
9872
|
+
changes: changes.length
|
|
9873
|
+
};
|
|
9874
|
+
}
|
|
9875
|
+
/**
|
|
9876
|
+
* @author ddj 2026年10月30号
|
|
9877
|
+
* @description: 撤销或重做最近一次判定。先按 operationId 判幂等,再据栈顶记录判定方向是否合法,
|
|
9878
|
+
* 只有写盘过的判定才反向写盘,写盘失败时条目状态保持原样。
|
|
9879
|
+
* @param fs 文件系统
|
|
9880
|
+
* @param cwd 工作区
|
|
9881
|
+
* @param move 撤销/重做请求
|
|
9882
|
+
* @param signal 取消信号
|
|
9883
|
+
* @returns 结果条目与是否写盘
|
|
9884
|
+
*/
|
|
9885
|
+
async function undoDecide(fs, cwd, move, signal, policy) {
|
|
9886
|
+
const replay = readOp(cwd, move.operationId);
|
|
9887
|
+
if (replay && replay.state === "committed" && replay.result) return JSON.parse(replay.result);
|
|
9888
|
+
const record = move.direction === "undo" ? latestApplied(cwd) : earliestUndone(cwd);
|
|
9889
|
+
if (!record) return {
|
|
9890
|
+
ok: false,
|
|
9891
|
+
reason: move.direction === "undo" ? "no-undo" : "no-redo"
|
|
9892
|
+
};
|
|
9893
|
+
const snapshot = parseSnapshot(record.snapshot);
|
|
9894
|
+
if (!snapshot) return {
|
|
9895
|
+
ok: false,
|
|
9896
|
+
reason: "bad-history"
|
|
9897
|
+
};
|
|
9898
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === record.entryId);
|
|
9899
|
+
if (!stored) return {
|
|
9900
|
+
ok: false,
|
|
9901
|
+
reason: "unknown-item"
|
|
9902
|
+
};
|
|
9903
|
+
const target = move.direction === "undo" ? snapshot.before : snapshot.after;
|
|
9904
|
+
writeOp(cwd, move.operationId, JSON.stringify(move), "prepared");
|
|
9905
|
+
let wrote = false;
|
|
9906
|
+
if (snapshot.wrote) {
|
|
9907
|
+
const result = await writeBack(fs, cwd, stored, target, signal, policy);
|
|
9908
|
+
if (!result.ok) return {
|
|
9909
|
+
ok: false,
|
|
9910
|
+
reason: result.reason
|
|
9911
|
+
};
|
|
9912
|
+
wrote = true;
|
|
9913
|
+
}
|
|
9914
|
+
saveEntry(cwd, {
|
|
9915
|
+
entry: target.entry,
|
|
9916
|
+
flags: target.flags
|
|
9917
|
+
});
|
|
9918
|
+
saveBlocks(cwd, record.entryId, target.blocks);
|
|
9919
|
+
setHistoryState(cwd, record.id, move.direction === "undo" ? "undone" : "applied");
|
|
9920
|
+
const outcome = {
|
|
9921
|
+
ok: true,
|
|
9922
|
+
item: itemOf({
|
|
9923
|
+
entry: target.entry,
|
|
9924
|
+
flags: target.flags
|
|
9925
|
+
}),
|
|
9926
|
+
wrote
|
|
9927
|
+
};
|
|
9928
|
+
writeOp(cwd, move.operationId, JSON.stringify(move), "committed", { result: JSON.stringify(outcome) });
|
|
9929
|
+
return outcome;
|
|
9930
|
+
}
|
|
9931
|
+
/**
|
|
9932
|
+
* @author ddj 2026年10月30号
|
|
9933
|
+
* @description: 把文件内容恢复到目标快照:目标为缺失则删除,否则按版本守卫写回并读回校验。
|
|
9934
|
+
* @param fs 文件系统
|
|
9935
|
+
* @param cwd 工作区
|
|
9936
|
+
* @param stored 当前条目
|
|
9937
|
+
* @param target 目标快照
|
|
9938
|
+
* @param signal 取消信号
|
|
9939
|
+
* @returns 写盘结果
|
|
9940
|
+
*/
|
|
9941
|
+
async function writeBack(fs, cwd, stored, target, signal, policy) {
|
|
9942
|
+
if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal);
|
|
9943
|
+
return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)(target.entry.newText);
|
|
9944
|
+
}
|
|
9945
|
+
/**
|
|
9946
|
+
* @author ddj 2026年10月30号
|
|
9947
|
+
* @description: 解析历史快照;损坏时返回 null,不猜测内容。
|
|
9948
|
+
* @param text 快照文本
|
|
9949
|
+
* @returns 快照或 null
|
|
9950
|
+
*/
|
|
9951
|
+
function parseSnapshot(text) {
|
|
9952
|
+
try {
|
|
9953
|
+
const parsed = JSON.parse(text);
|
|
9954
|
+
if (!parsed?.before?.entry || !parsed?.after?.entry) return null;
|
|
9955
|
+
return parsed;
|
|
9956
|
+
} catch {
|
|
9957
|
+
return null;
|
|
9958
|
+
}
|
|
9959
|
+
}
|
|
9960
|
+
/**
|
|
9961
|
+
* @author ddj 2026年10月29号
|
|
9962
|
+
* @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
|
|
9963
|
+
* @param fs 文件系统
|
|
9964
|
+
* @param cwd 工作区
|
|
9965
|
+
* @param identity 目标身份
|
|
9966
|
+
* @param signal 取消信号
|
|
9967
|
+
* @returns 删除结果
|
|
9968
|
+
*/
|
|
9969
|
+
async function deleteTarget(fs, cwd, identity, signal) {
|
|
9970
|
+
let target;
|
|
9971
|
+
try {
|
|
9972
|
+
target = await fs.resolve(identity, {
|
|
9973
|
+
cwd,
|
|
9974
|
+
signal
|
|
9975
|
+
});
|
|
9976
|
+
} catch {
|
|
9977
|
+
return {
|
|
9978
|
+
ok: false,
|
|
9979
|
+
reason: "resolve-failed"
|
|
9980
|
+
};
|
|
9981
|
+
}
|
|
9982
|
+
if (!await fs.stat(target, signal).catch(() => void 0)) return {
|
|
9983
|
+
ok: true,
|
|
9984
|
+
content: null
|
|
9985
|
+
};
|
|
9986
|
+
const absolute = String(fs.processPath(target));
|
|
9987
|
+
if (!insideWorkspace(cwd, absolute)) return {
|
|
9988
|
+
ok: false,
|
|
9989
|
+
reason: "outside-workspace"
|
|
9990
|
+
};
|
|
9991
|
+
try {
|
|
9992
|
+
await unlink(absolute);
|
|
9993
|
+
} catch (error) {
|
|
9994
|
+
return {
|
|
9995
|
+
ok: false,
|
|
9996
|
+
reason: reasonOf(error)
|
|
9997
|
+
};
|
|
9998
|
+
}
|
|
9999
|
+
return await fs.stat(target, signal).catch(() => void 0) ? {
|
|
10000
|
+
ok: false,
|
|
10001
|
+
reason: "delete-failed"
|
|
10002
|
+
} : {
|
|
10003
|
+
ok: true,
|
|
10004
|
+
content: null
|
|
10005
|
+
};
|
|
10006
|
+
}
|
|
10007
|
+
/**
|
|
10008
|
+
* @author ddj 2026年10月29号
|
|
10009
|
+
* @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
|
|
10010
|
+
* @param cwd 工作区
|
|
10011
|
+
* @param absolute 目标绝对路径
|
|
10012
|
+
* @returns 是否在工作区内
|
|
10013
|
+
*/
|
|
10014
|
+
function insideWorkspace(cwd, absolute) {
|
|
10015
|
+
const root = resolve(cwd);
|
|
10016
|
+
const candidate = resolve(absolute);
|
|
10017
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
10018
|
+
const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep;
|
|
10019
|
+
return normalize(candidate).startsWith(rootKey);
|
|
10020
|
+
}
|
|
10021
|
+
/**
|
|
10022
|
+
* @author ddj 2026年10月29号
|
|
10023
|
+
* @description: 从列表移除已解决条目;未解决条目拒绝移除,避免表里看不到却被删掉。
|
|
10024
|
+
* @param cwd 工作区
|
|
10025
|
+
* @param id 条目身份
|
|
10026
|
+
* @param revision 并发校验版本
|
|
10027
|
+
* @returns 是否移除
|
|
10028
|
+
*/
|
|
10029
|
+
function removeEntry(cwd, id, revision) {
|
|
10030
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === id);
|
|
10031
|
+
if (!stored || stored.flags.revision !== revision || !isSettled(stored.entry)) return false;
|
|
10032
|
+
dropEntry(cwd, id);
|
|
10033
|
+
return true;
|
|
10034
|
+
}
|
|
10035
|
+
/**
|
|
10036
|
+
* @author ddj 2026年10月29号
|
|
10037
|
+
* @description: 取错误码用于拒绝原因,没有则回落到消息文本。
|
|
10038
|
+
* @param error 抛出的错误
|
|
10039
|
+
* @returns 原因字符串
|
|
10040
|
+
*/
|
|
10041
|
+
function reasonOf(error) {
|
|
10042
|
+
const code = error.code;
|
|
10043
|
+
return typeof code === "string" ? code : String(error);
|
|
10044
|
+
}
|
|
10045
|
+
//#endregion
|
|
10046
|
+
//#region src/shared/pathKey.ts
|
|
10047
|
+
/**
|
|
10048
|
+
* 路径比较口径:Host 与客户端共用同一套「远端 / 盘符 / 相对路径」判定,只在平台折叠上分工。
|
|
10049
|
+
* @author ddj 2026年10月30号
|
|
10050
|
+
*/
|
|
10051
|
+
/** 远端仓库身份:`scheme://...` 区分大小写,绝不能折叠。 */
|
|
10052
|
+
const REMOTE = /^[a-z]+:\/\//i;
|
|
10053
|
+
/** 盘符绝对路径:大小写不敏感与宿主平台无关,两边都必须折叠。 */
|
|
10054
|
+
const DRIVE = /^[a-z]:\//i;
|
|
10055
|
+
/**
|
|
10056
|
+
* @author ddj 2026年10月30号
|
|
10057
|
+
* @description: 统一分隔符为 `/`,其余字符原样保留。
|
|
10058
|
+
* @param path 原始路径
|
|
10059
|
+
* @returns 反斜杠已归一化的路径
|
|
10060
|
+
*/
|
|
10061
|
+
function unifiedPath(path) {
|
|
10062
|
+
return path.replace(/\\/g, "/");
|
|
10063
|
+
}
|
|
10064
|
+
/**
|
|
10065
|
+
* @author ddj 2026年10月30号
|
|
10066
|
+
* @description: 生成路径比较键。盘符路径恒折叠大小写;其余路径是否折叠由调用方按运行平台决定
|
|
10067
|
+
* (Host 在 Windows 上折叠,客户端不在宿主进程里,取 false)。
|
|
10068
|
+
* @param path 原始路径
|
|
10069
|
+
* @param foldCase 非盘符路径是否折叠大小写
|
|
10070
|
+
* @returns 比较键
|
|
10071
|
+
*/
|
|
10072
|
+
function pathKeyFor(path, foldCase) {
|
|
10073
|
+
const unified = unifiedPath(path);
|
|
10074
|
+
if (REMOTE.test(unified)) return unified;
|
|
10075
|
+
if (DRIVE.test(unified)) return unified.toLowerCase();
|
|
10076
|
+
return foldCase ? unified.toLowerCase() : unified;
|
|
10077
|
+
}
|
|
10078
|
+
//#endregion
|
|
10079
|
+
//#region src/shared/pendingReview.ts
|
|
10080
|
+
/** 待审条目在面板列表里的 id 前缀;客户端与 Host 共用这一份口径。 */
|
|
10081
|
+
const PENDING_PREFIX = "pending:";
|
|
10082
|
+
//#endregion
|
|
10083
|
+
//#region src/pendingCompat.ts
|
|
10084
|
+
/** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
|
|
10085
|
+
const coveredPaths = /* @__PURE__ */ new Set();
|
|
10086
|
+
/**
|
|
10087
|
+
* @author ddj 2026年10月29号
|
|
10088
|
+
* @description: 由条目 id 派生稳定序号,保证轮询期间面板锚点不跳。
|
|
10089
|
+
* @param id 待审条目身份
|
|
10090
|
+
* @returns 正整数序号
|
|
10091
|
+
*/
|
|
10092
|
+
function stableSeq(id) {
|
|
10093
|
+
let hash = 2166136261;
|
|
10094
|
+
for (let index = 0; index < id.length; index++) {
|
|
10095
|
+
hash ^= id.charCodeAt(index);
|
|
10096
|
+
hash = Math.imul(hash, 16777619);
|
|
10097
|
+
}
|
|
10098
|
+
return Math.abs(hash) % 2147483647 + 1;
|
|
10099
|
+
}
|
|
10100
|
+
/**
|
|
10101
|
+
* @author ddj 2026年10月29号
|
|
10102
|
+
* @description: Host 侧的路径比较口径:远端身份保留大小写,其余按运行平台折叠。
|
|
10103
|
+
* @param path 路径
|
|
10104
|
+
* @returns 比较键
|
|
10105
|
+
*/
|
|
10106
|
+
function pathKey$1(path) {
|
|
10107
|
+
return pathKeyFor(path, process.platform === "win32");
|
|
10108
|
+
}
|
|
10109
|
+
/**
|
|
10110
|
+
* @author ddj 2026年10月30号
|
|
10111
|
+
* @description: 判断两个路径是否指同一文件:比较键相同,或一方是另一方的工作区相对写法。
|
|
10112
|
+
* 面板传下来的路径可能是相对路径,而条目里存的是绝对路径。
|
|
10113
|
+
* @param left 路径
|
|
10114
|
+
* @param right 路径
|
|
10115
|
+
* @returns 是否同一文件
|
|
10116
|
+
*/
|
|
10117
|
+
function pendingSamePath(left, right) {
|
|
10118
|
+
const a = pathKey$1(left);
|
|
10119
|
+
const b = pathKey$1(right);
|
|
10120
|
+
if (!a || !b) return false;
|
|
10121
|
+
return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
|
|
10122
|
+
}
|
|
10123
|
+
/**
|
|
10124
|
+
* @author ddj 2026年10月30号
|
|
10125
|
+
* @description: 列表里的条目 id 带待审前缀用于区分通道,内核按原始身份存取,这里统一还原。
|
|
10126
|
+
* @param id 请求里的条目 id
|
|
10127
|
+
* @returns 待审内核使用的原始 id
|
|
10128
|
+
*/
|
|
10129
|
+
function pendingRawId(id) {
|
|
10130
|
+
return id.startsWith("pending:") ? id.slice(8) : id;
|
|
10131
|
+
}
|
|
10132
|
+
/**
|
|
10133
|
+
* @author ddj 2026年10月30号
|
|
10134
|
+
* @description: 列表显示用的短路径:工作区内显示相对路径,区外保留原文。
|
|
10135
|
+
* @param cwd 工作区
|
|
10136
|
+
* @param path 条目路径
|
|
10137
|
+
* @returns 显示用路径
|
|
10138
|
+
*/
|
|
10139
|
+
function pendingDisplay(cwd, path) {
|
|
10140
|
+
const root = cwd.replace(/[\\/]+$/, "");
|
|
10141
|
+
const unified = path.replace(/\\/g, "/");
|
|
10142
|
+
const base = root.replace(/\\/g, "/");
|
|
10143
|
+
if (base && (unified === base || unified.toLowerCase().startsWith(base.toLowerCase() + "/"))) return unified.slice(base.length + 1);
|
|
10144
|
+
return path;
|
|
10145
|
+
}
|
|
10146
|
+
/**
|
|
10147
|
+
* @author ddj 2026年10月29号
|
|
10148
|
+
* @description: 待审条目转面板列表项。
|
|
10149
|
+
* @param item 待审条目
|
|
10150
|
+
* @param cwd 工作区,用于显示相对路径;缺省显示原始路径
|
|
10151
|
+
* @returns 面板列表项
|
|
10152
|
+
*/
|
|
10153
|
+
function pendingItemOf(item, cwd) {
|
|
10154
|
+
return {
|
|
10155
|
+
id: PENDING_PREFIX + item.id,
|
|
10156
|
+
sessionId: item.sessionId,
|
|
10157
|
+
turn: 0,
|
|
10158
|
+
seq: stableSeq(item.id),
|
|
10159
|
+
path: item.path,
|
|
10160
|
+
currentPath: item.path,
|
|
10161
|
+
display: cwd ? pendingDisplay(cwd, item.path) : item.path,
|
|
10162
|
+
added: item.added,
|
|
10163
|
+
deleted: item.deleted,
|
|
10164
|
+
kind: item.oversized ? "oversized" : "text",
|
|
10165
|
+
decision: item.settled ? "accepted" : "pending",
|
|
10166
|
+
revision: item.revision,
|
|
10167
|
+
current: true
|
|
10168
|
+
};
|
|
10169
|
+
}
|
|
10170
|
+
/**
|
|
10171
|
+
* @author ddj 2026年10月29号
|
|
10172
|
+
* @description: 待审详情转面板详情;Revert 映射到面板的 Undo 能力位。
|
|
10173
|
+
* @param detail 待审详情
|
|
10174
|
+
* @param capturedAt 采集时间文案
|
|
10175
|
+
* @returns 面板详情
|
|
10176
|
+
*/
|
|
10177
|
+
function pendingDetailOf(detail, capturedAt, cwd) {
|
|
10178
|
+
return {
|
|
10179
|
+
item: pendingItemOf(detail.item, cwd),
|
|
10180
|
+
diff: detail.diff,
|
|
10181
|
+
capturedAt,
|
|
10182
|
+
blocks: detail.blocks,
|
|
10183
|
+
capability: {
|
|
10184
|
+
keep: detail.capability.keep,
|
|
10185
|
+
undo: detail.capability.revert,
|
|
10186
|
+
reason: detail.capability.reason
|
|
10187
|
+
}
|
|
10188
|
+
};
|
|
10189
|
+
}
|
|
10190
|
+
/**
|
|
10191
|
+
* @author ddj 2026年10月29号
|
|
10192
|
+
* @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
|
|
10193
|
+
* @param cwd 工作区
|
|
10194
|
+
* @param path 路径
|
|
10195
|
+
* @param covered 是否接管
|
|
10196
|
+
* @returns 无
|
|
10197
|
+
*/
|
|
10198
|
+
function trackPendingCover(cwd, path, covered) {
|
|
10199
|
+
const key = `${pathKey$1(cwd)}|${pathKey$1(path)}`;
|
|
10200
|
+
if (covered) coveredPaths.add(key);
|
|
10201
|
+
else coveredPaths.delete(key);
|
|
10202
|
+
}
|
|
10203
|
+
/**
|
|
10204
|
+
* @author ddj 2026年10月29号
|
|
10205
|
+
* @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
|
|
10206
|
+
* @param cwd 工作区
|
|
10207
|
+
* @param path 请求路径
|
|
10208
|
+
* @returns 是否已被接管
|
|
10209
|
+
*/
|
|
10210
|
+
function pendingCovers(cwd, path) {
|
|
10211
|
+
return coveredPaths.has(`${pathKey$1(cwd)}|${pathKey$1(path)}`);
|
|
10212
|
+
}
|
|
10213
|
+
/**
|
|
10214
|
+
* @author ddj 2026年10月29号
|
|
10215
|
+
* @description: 合并官方页与待审页:待审覆盖的路径从官方结果中剔除,其余原样保留。
|
|
10216
|
+
* @param official 官方页
|
|
10217
|
+
* @param pending 待审页
|
|
10218
|
+
* @param cwd 工作区,用于把待审行显示成相对路径
|
|
10219
|
+
* @returns 合并后的页
|
|
10220
|
+
*/
|
|
10221
|
+
function mergedPage(official, pending, cwd) {
|
|
10222
|
+
const covered = new Set(pending.items.map((item) => pathKey$1(item.path)));
|
|
10223
|
+
const kept = official.items.filter((item) => !covered.has(pathKey$1(item.currentPath)) && !covered.has(pathKey$1(item.path)));
|
|
10224
|
+
return {
|
|
10225
|
+
items: [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept],
|
|
10226
|
+
pending: pending.pending + kept.filter((item) => item.decision === "pending").length,
|
|
10227
|
+
nextCursor: official.nextCursor,
|
|
10228
|
+
gaps: official.gaps,
|
|
10229
|
+
...official.overview ? { overview: official.overview } : {}
|
|
10230
|
+
};
|
|
10231
|
+
}
|
|
10232
|
+
//#endregion
|
|
10233
|
+
//#region src/search/query.ts
|
|
10234
|
+
/**
|
|
10235
|
+
* 规范化用户搜索文本。
|
|
10236
|
+
* @author ddj 2026年08月24号
|
|
10237
|
+
* @param raw 原始搜索文本
|
|
10238
|
+
* @returns 用于比较和缓存的 query
|
|
10239
|
+
*/
|
|
10240
|
+
function prepareQuery(raw) {
|
|
10241
|
+
const value = String(raw ?? "").trim();
|
|
10242
|
+
return {
|
|
10243
|
+
raw: value,
|
|
10244
|
+
text: value.replaceAll("\\", "/").toLocaleLowerCase("en-US")
|
|
10245
|
+
};
|
|
10246
|
+
}
|
|
10247
|
+
/**
|
|
10248
|
+
* 归一化路径分隔符并移除无意义前缀。
|
|
10249
|
+
* @author ddj 2026年08月24号
|
|
10250
|
+
* @param value 原始路径
|
|
10251
|
+
* @returns 比较用路径
|
|
10252
|
+
*/
|
|
10253
|
+
function pathText(value) {
|
|
10254
|
+
return value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
10255
|
+
}
|
|
10256
|
+
/**
|
|
10257
|
+
* 生成不区分大小写的路径 key。
|
|
10258
|
+
* @author ddj 2026年08月24号
|
|
10259
|
+
* @param value 原始路径
|
|
10260
|
+
* @returns 去重 key
|
|
10261
|
+
*/
|
|
10262
|
+
function pathKey(value) {
|
|
10263
|
+
return pathText(value).toLocaleLowerCase("en-US");
|
|
10264
|
+
}
|
|
10265
|
+
/**
|
|
10266
|
+
* @public
|
|
10267
|
+
* @author ddj 2026年10月14号
|
|
10268
|
+
* @description 按顺序匹配不连续字符;每个位置最多消费一次。
|
|
10269
|
+
* @param value 已归一化候选文本
|
|
10270
|
+
* @param text 已归一化查询文本
|
|
10271
|
+
* @returns 是否完整匹配所有查询字符
|
|
10272
|
+
*/
|
|
10273
|
+
function subsequence(value, text) {
|
|
10274
|
+
let offset = 0;
|
|
10275
|
+
for (const char of text) {
|
|
10276
|
+
const index = value.indexOf(char, offset);
|
|
10277
|
+
if (index < 0) return false;
|
|
10278
|
+
offset = index + char.length;
|
|
10279
|
+
}
|
|
10280
|
+
return true;
|
|
10281
|
+
}
|
|
10282
|
+
/**
|
|
10283
|
+
* @public
|
|
10284
|
+
* @author ddj 2026年10月14号
|
|
10285
|
+
* @description 连续匹配相对路径,或对无目录查询模糊匹配文件名。
|
|
10286
|
+
* @param value 文件路径(调用方移除工作区根前缀)
|
|
10287
|
+
* @param query 已规范化 query
|
|
10288
|
+
* @returns 是否命中;空查询不命中
|
|
10289
|
+
*/
|
|
10290
|
+
function pathMatch(value, query) {
|
|
10291
|
+
if (!query.text) return false;
|
|
10292
|
+
const path = pathText(value).toLocaleLowerCase("en-US");
|
|
10293
|
+
if (path.includes(query.text)) return true;
|
|
10294
|
+
if (query.text.includes("/")) return false;
|
|
10295
|
+
return subsequence(path.slice(path.lastIndexOf("/") + 1), query.text);
|
|
10296
|
+
}
|
|
10297
|
+
//#endregion
|
|
10298
|
+
//#region src/search/ripgrep.ts
|
|
10299
|
+
/**
|
|
10300
|
+
* dsh-vscode-mode host — ripgrep 文件发现 provider。
|
|
10301
|
+
* 通过独立 argv 调用打包 rg,避免先构建巨型全量文件数组。
|
|
10302
|
+
* 作者 ddj 2026年08月24号
|
|
10303
|
+
*/
|
|
10304
|
+
const STDOUT_CAP$1 = 4 << 20;
|
|
10305
|
+
const STDERR_CAP$2 = 65536;
|
|
10306
|
+
const GRACE_MS$1 = 2e4;
|
|
10307
|
+
/**
|
|
10308
|
+
* 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。
|
|
10309
|
+
* 语义为**大小写敏感**的路径段匹配:`build` 只排除小写构建产物目录,
|
|
10310
|
+
* 不得用 `--glob-case-insensitive` 之类的全局开关,「Module/Build」这类
|
|
10311
|
+
* 源码目录(如 IslandSplash 的 Module/Build/Logic)必须保持可搜索。
|
|
10312
|
+
*/
|
|
10313
|
+
const EXCLUDES = [
|
|
10314
|
+
"node_modules",
|
|
10315
|
+
".git",
|
|
10316
|
+
".tmp",
|
|
10317
|
+
".cache",
|
|
10318
|
+
"dist",
|
|
10319
|
+
"build",
|
|
10320
|
+
"vendor",
|
|
10321
|
+
"coverage",
|
|
10322
|
+
"__pycache__",
|
|
10323
|
+
".pnpm-store",
|
|
10324
|
+
".npm-cache",
|
|
10325
|
+
".codegraph",
|
|
10326
|
+
".workbuddy"
|
|
10327
|
+
];
|
|
10328
|
+
/**
|
|
10329
|
+
* 默认排除文件(glob 模式,文件/内容搜索共用)。
|
|
10330
|
+
* 插件自身 sidecar(内容搜索会命中其历史编辑文本,且单行巨型 JSON 会撑爆输出上限);
|
|
10331
|
+
* *.map 源映射(纯构建产物,内容搜索无意义且常为单行巨文件)。
|
|
10332
|
+
*/
|
|
10333
|
+
const FILE_EXCLUDES = [
|
|
10334
|
+
"**/.dsh-edit-review.json",
|
|
10335
|
+
"**/.dsh-edit-review-archive.json",
|
|
10336
|
+
"**/.dsh-edit-review-debug.log",
|
|
10337
|
+
"**/*.map"
|
|
10338
|
+
];
|
|
10339
|
+
/**
|
|
10340
|
+
* 将查询文本转成大小写不敏感的 rg 正选 glob。
|
|
10341
|
+
* 逐字符把有大小写的字母展开成 `[aA]` 字符类,**不用**大小写不敏感开关:
|
|
10342
|
+
* ① 全局 `--glob-case-insensitive` 会把 `-g/--glob` 全部当成 `--iglob`,
|
|
10343
|
+
* 连带让 `!**\/build/**` 大小写不敏感,从而排除 `Module/Build/` 这类源码目录
|
|
10344
|
+
* (Ctrl+P 搜 BuildLogic 恒「无匹配文件」);
|
|
10345
|
+
* ② 把查询词改用 `--iglob` 也不行——rg 的正选 glob 会覆盖全部黑名单 glob,
|
|
10346
|
+
* node_modules/.git 等排除项集体失效。
|
|
10347
|
+
* 字符类方案同时满足「查询不区分大小写」且「排除项保持大小写敏感」。
|
|
10348
|
+
* @author ddj 2026年08月24号 / 2026年09月20号
|
|
10349
|
+
* @param query 已规范化查询文本
|
|
10350
|
+
* @returns 安全 glob
|
|
10351
|
+
*/
|
|
10352
|
+
function queryGlob(query) {
|
|
10353
|
+
return "**/*" + Array.from(pathText(query), globChar).join("") + "*";
|
|
10354
|
+
}
|
|
10355
|
+
/**
|
|
10356
|
+
* @private
|
|
10357
|
+
* @author ddj 2026年10月14号
|
|
10358
|
+
* @description 将字面字符转为安全且大小写不敏感的 glob 片段。
|
|
10359
|
+
* @param char 单个 Unicode 字符
|
|
10360
|
+
* @returns 转义字符或大小写字符类
|
|
10361
|
+
*/
|
|
10362
|
+
function globChar(char) {
|
|
10363
|
+
const lower = char.toLowerCase();
|
|
10364
|
+
const upper = char.toUpperCase();
|
|
10365
|
+
if (lower !== upper && lower.length === 1 && upper.length === 1) return "[" + lower + upper + "]";
|
|
10366
|
+
return /[\*?\[\]{}!]/.test(char) ? "\\" + char : char;
|
|
10367
|
+
}
|
|
10368
|
+
/**
|
|
10369
|
+
* @public
|
|
10370
|
+
* @author ddj 2026年10月14号
|
|
10371
|
+
* @description 构造仅匹配文件名的有序子序列 glob,不让星号跨目录。
|
|
10372
|
+
* @param query 无目录的字面查询文本
|
|
10373
|
+
* @returns 模糊 glob;空查询或带目录时返回 null
|
|
10374
|
+
*/
|
|
10375
|
+
function fuzzyGlob(query) {
|
|
10376
|
+
const value = pathText(query);
|
|
10377
|
+
if (!value || value.includes("/")) return null;
|
|
10378
|
+
return "**/*" + Array.from(value, globChar).join("*") + "*";
|
|
10379
|
+
}
|
|
10380
|
+
/**
|
|
10381
|
+
* 返回当前平台的 rg 可执行文件路径,不让可选依赖影响模块加载。
|
|
10382
|
+
* @author ddj 2026年08月24号
|
|
10383
|
+
* @returns rg 路径或 null
|
|
10384
|
+
*/
|
|
10385
|
+
function ripgrepPath() {
|
|
10386
|
+
try {
|
|
10387
|
+
const ripgrep = createRequire(import.meta.url)("@vscode/ripgrep");
|
|
10388
|
+
if (typeof ripgrep.rgPath !== "string" || !existsSync(ripgrep.rgPath)) return null;
|
|
10389
|
+
return join(ripgrep.rgPath);
|
|
10390
|
+
} catch (error) {
|
|
10391
|
+
return null;
|
|
10392
|
+
}
|
|
10393
|
+
}
|
|
10394
|
+
/**
|
|
10395
|
+
* 解析会话搜索根目录,并转换到 subprocess 执行世界。
|
|
10396
|
+
* @author ddj 2026年08月24号
|
|
10397
|
+
* @param ctx DSH 上下文
|
|
10398
|
+
* @param session 当前会话
|
|
10399
|
+
* @returns subprocess 可访问的根目录
|
|
10400
|
+
*/
|
|
10401
|
+
async function searchRoot(ctx, session) {
|
|
10402
|
+
const fs = ctx.get("fs");
|
|
10403
|
+
if (!fs) throw new Error("缺少 fs");
|
|
10404
|
+
const policy = policyOf(ctx, session);
|
|
10405
|
+
const cwd = cwdOfSession(ctx, session);
|
|
10406
|
+
const rootTarget = await fs.resolve(policy?.workspaceRoot ?? ".", cwd ? { cwd } : {});
|
|
10407
|
+
return fs.processPath(rootTarget);
|
|
10408
|
+
}
|
|
10409
|
+
/**
|
|
10410
|
+
* 读取收集输出并解析为路径候选。
|
|
10411
|
+
* @author ddj 2026年08月24号
|
|
10412
|
+
* @param handle subprocess handle
|
|
10413
|
+
* @param maxResults 候选上限
|
|
10414
|
+
* @returns 路径与完整性状态
|
|
10415
|
+
*/
|
|
10416
|
+
function parseOutput(handle, maxResults) {
|
|
10417
|
+
const reader = handle.collected?.stdout;
|
|
10418
|
+
if (!reader) return {
|
|
10419
|
+
files: [],
|
|
10420
|
+
truncated: false,
|
|
10421
|
+
complete: false
|
|
10422
|
+
};
|
|
10423
|
+
const output = reader.readFrom(0);
|
|
10424
|
+
const values = output.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
10425
|
+
const unique = [...new Set(values)];
|
|
10426
|
+
const truncated = Boolean(output.lossy) || unique.length > maxResults;
|
|
10427
|
+
return {
|
|
10428
|
+
files: unique.slice(0, maxResults).map(pathText),
|
|
10429
|
+
truncated,
|
|
10430
|
+
complete: !truncated
|
|
10431
|
+
};
|
|
10432
|
+
}
|
|
10433
|
+
/**
|
|
10434
|
+
* 读取已收集的 stderr(无收集器 → 空文本)。
|
|
10435
|
+
* @author ddj 2026年08月27号
|
|
10436
|
+
* @param handle subprocess handle
|
|
10437
|
+
* @returns stderr 文本
|
|
10438
|
+
*/
|
|
10439
|
+
function readStderr(handle) {
|
|
10440
|
+
const reader = handle.collected?.stderr;
|
|
10441
|
+
return reader ? reader.readFrom(0) : { text: "" };
|
|
10442
|
+
}
|
|
10443
|
+
/**
|
|
10444
|
+
* 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
|
|
10445
|
+
* @author ddj 2026年08月27号
|
|
10446
|
+
* @param text stderr 全文
|
|
10447
|
+
* @returns 首条诊断行(无则空串)
|
|
8313
10448
|
*/
|
|
8314
10449
|
function firstStderrLine(text) {
|
|
8315
10450
|
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
@@ -10132,6 +12267,131 @@ function installRulesSection(ctx, dirs = userRuleDirs()) {
|
|
|
10132
12267
|
return true;
|
|
10133
12268
|
}
|
|
10134
12269
|
//#endregion
|
|
12270
|
+
//#region src/skillCatalog.ts
|
|
12271
|
+
/**
|
|
12272
|
+
* dsh-vscode-mode host — 技能候选枚举(全局技能 + 当前工作区技能)。
|
|
12273
|
+
*
|
|
12274
|
+
* 为什么要另起一条通道:本插件原有的 `skills.ts` 只管「随包分发的技能组」
|
|
12275
|
+
* (<包根>/skills,按 SKILL_PREFIXES 前缀白名单过滤);而快捷菜单「绑定技能」需要的是
|
|
12276
|
+
* 用户能自由增删的两类技能目录,与 DSH 文件系统 skill provider 的两个根一致:
|
|
12277
|
+
* - 全局技能:`$DSH_HOME/skills`(provider rank 400 / user-dsh)
|
|
12278
|
+
* - 工作区技能:`<projectRoot>/.dsh/skills`(provider rank 100 / project-dsh)
|
|
12279
|
+
* 只读、不写盘、不缓存:每次打开配置弹窗重扫一遍(目录数量级很小)。
|
|
12280
|
+
*
|
|
12281
|
+
* 作者 ddj 2026年10月30号
|
|
12282
|
+
*/
|
|
12283
|
+
/** 单个根目录最多枚举的技能数(防失控目录拖垮弹窗)。 */
|
|
12284
|
+
const SKILL_CATALOG_CAP = 200;
|
|
12285
|
+
/** 向上找项目根的层数上限(防异常路径下的长链遍历)。 */
|
|
12286
|
+
const ROOT_DEPTH_CAP = 8;
|
|
12287
|
+
/** 目录式技能的清单文件名。 */
|
|
12288
|
+
const SKILL_FILE = "SKILL.md";
|
|
12289
|
+
/**
|
|
12290
|
+
* 从 cwd 向上找项目根(含 .git 的最近祖先;找不到回落 cwd)。
|
|
12291
|
+
* @author ddj 2026年10月30号
|
|
12292
|
+
* @param cwd 起始目录
|
|
12293
|
+
* @returns 项目根绝对路径;cwd 缺失时 undefined
|
|
12294
|
+
*/
|
|
12295
|
+
function projectRootOf(cwd) {
|
|
12296
|
+
if (cwd === void 0 || cwd === "") return void 0;
|
|
12297
|
+
let current = cwd;
|
|
12298
|
+
for (let depth = 0; depth < ROOT_DEPTH_CAP; depth += 1) {
|
|
12299
|
+
const parent = join(current, "..");
|
|
12300
|
+
if (entryExists(join(current, ".git"))) return current;
|
|
12301
|
+
if (parent === current) break;
|
|
12302
|
+
current = parent;
|
|
12303
|
+
}
|
|
12304
|
+
return cwd;
|
|
12305
|
+
}
|
|
12306
|
+
/**
|
|
12307
|
+
* 探测路径是否存在(同步存在性判断,避免为一次 up-walk 引入异步瀑布)。
|
|
12308
|
+
* @author ddj 2026年10月30号
|
|
12309
|
+
* @param path 绝对路径
|
|
12310
|
+
* @returns 存在为 true
|
|
12311
|
+
*/
|
|
12312
|
+
function entryExists(path) {
|
|
12313
|
+
try {
|
|
12314
|
+
statSync(path);
|
|
12315
|
+
return true;
|
|
12316
|
+
} catch {
|
|
12317
|
+
return false;
|
|
12318
|
+
}
|
|
12319
|
+
}
|
|
12320
|
+
/**
|
|
12321
|
+
* 技能根目录(全局 + 当前工作区)。
|
|
12322
|
+
* @author ddj 2026年10月30号
|
|
12323
|
+
* @param cwd 当前工作区
|
|
12324
|
+
* @returns 两个根的绝对路径
|
|
12325
|
+
*/
|
|
12326
|
+
function skillRootsOf(cwd) {
|
|
12327
|
+
const projectRoot = projectRootOf(cwd);
|
|
12328
|
+
const project = projectRoot === void 0 ? "" : join(projectRoot, ".dsh", "skills");
|
|
12329
|
+
return {
|
|
12330
|
+
user: join(dshHome(), "skills"),
|
|
12331
|
+
project,
|
|
12332
|
+
projectRoot
|
|
12333
|
+
};
|
|
12334
|
+
}
|
|
12335
|
+
/**
|
|
12336
|
+
* 扫描单个技能根(深度 1:<root>/<名>/SKILL.md 或 <root>/<名>.md)。
|
|
12337
|
+
* 目录缺失/读失败/frontmatter 非法 → 跳过该条(不抛、不返回半成品)。
|
|
12338
|
+
* @author ddj 2026年10月30号
|
|
12339
|
+
* @param dir 技能根目录
|
|
12340
|
+
* @param source 来源标记
|
|
12341
|
+
* @returns 通过校验的技能(按名字排序,受 SKILL_CATALOG_CAP 约束)
|
|
12342
|
+
*/
|
|
12343
|
+
async function listRootSkills(dir, source) {
|
|
12344
|
+
if (dir === "") return [];
|
|
12345
|
+
const ordered = [...await readdir(dir, { withFileTypes: true }).catch(() => [])].sort((left, right) => left.name.localeCompare(right.name)).slice(0, SKILL_CATALOG_CAP);
|
|
12346
|
+
const found = [];
|
|
12347
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12348
|
+
for (const entry of ordered) {
|
|
12349
|
+
const info = await readSkillInfo(entry.name.endsWith(".md") ? join(dir, entry.name) : join(dir, entry.name, SKILL_FILE), source).catch(() => void 0);
|
|
12350
|
+
if (info === void 0 || seen.has(info.name)) continue;
|
|
12351
|
+
seen.add(info.name);
|
|
12352
|
+
found.push(info);
|
|
12353
|
+
}
|
|
12354
|
+
return found.sort((left, right) => left.name.localeCompare(right.name));
|
|
12355
|
+
}
|
|
12356
|
+
/**
|
|
12357
|
+
* 读取并解析单个技能文件。
|
|
12358
|
+
* @author ddj 2026年10月30号
|
|
12359
|
+
* @param file 技能文件绝对路径
|
|
12360
|
+
* @param source 来源标记
|
|
12361
|
+
* @returns 技能元信息;文件缺失/不可读/frontmatter 非法时 undefined
|
|
12362
|
+
*/
|
|
12363
|
+
async function readSkillInfo(file, source) {
|
|
12364
|
+
const text = await readFile(file, "utf8").catch(() => void 0);
|
|
12365
|
+
if (text === void 0) return void 0;
|
|
12366
|
+
const parsed = parseSkillMd(text);
|
|
12367
|
+
if (!isParsedSkill(parsed)) return void 0;
|
|
12368
|
+
return {
|
|
12369
|
+
name: parsed.name,
|
|
12370
|
+
description: parsed.description,
|
|
12371
|
+
path: file,
|
|
12372
|
+
source
|
|
12373
|
+
};
|
|
12374
|
+
}
|
|
12375
|
+
/**
|
|
12376
|
+
* 枚举技能候选(全局 + 当前工作区;只读,永不抛)。
|
|
12377
|
+
* @author ddj 2026年10月30号
|
|
12378
|
+
* @param cwd 当前工作区(缺失时只枚举全局根)
|
|
12379
|
+
* @returns 技能候选快照
|
|
12380
|
+
*/
|
|
12381
|
+
async function listSkillCatalog(cwd) {
|
|
12382
|
+
const roots = skillRootsOf(cwd);
|
|
12383
|
+
const user = await listRootSkills(roots.user, "user");
|
|
12384
|
+
const project = await listRootSkills(roots.project, "project");
|
|
12385
|
+
return roots.projectRoot === void 0 ? {
|
|
12386
|
+
user,
|
|
12387
|
+
project
|
|
12388
|
+
} : {
|
|
12389
|
+
user,
|
|
12390
|
+
project,
|
|
12391
|
+
projectRoot: roots.projectRoot
|
|
12392
|
+
};
|
|
12393
|
+
}
|
|
12394
|
+
//#endregion
|
|
10135
12395
|
//#region src/snippets.ts
|
|
10136
12396
|
/**
|
|
10137
12397
|
* dsh-vscode-mode host — 代码片段管理(VS Code 兼容 .code-snippets 文件)。
|
|
@@ -10464,29 +12724,610 @@ async function snippetsRemove(ctx, ref) {
|
|
|
10464
12724
|
await rm(join(dir, ref.file), { force: true });
|
|
10465
12725
|
}
|
|
10466
12726
|
/**
|
|
10467
|
-
* 补全 provider 载荷:全局片段 + 指定工作区项目片段的全部条目。
|
|
10468
|
-
* 项目条目排在全局之后(同 key 时后出现的项目条目在 client 侧优先)。
|
|
10469
|
-
* @author ddj 2026年09月10号
|
|
10470
|
-
* @param ctx DSH 上下文
|
|
10471
|
-
* @param workspacePath 当前会话工作区(可选;未注册则仅全局片段)
|
|
10472
|
-
* @returns snippets.entries 载荷
|
|
10473
|
-
*/
|
|
10474
|
-
async function snippetsEntries(ctx, workspacePath) {
|
|
10475
|
-
const loaded = [];
|
|
10476
|
-
const load = async (dir, scope) => {
|
|
10477
|
-
for (const info of await listSnippetsDir(dir, scope)) {
|
|
10478
|
-
const parsed = parseSnippetsJson(await readFile(info.absPath, "utf8").catch(() => ""));
|
|
10479
|
-
if (parsed.error) continue;
|
|
10480
|
-
loaded.push({
|
|
10481
|
-
info,
|
|
10482
|
-
parsed
|
|
10483
|
-
});
|
|
12727
|
+
* 补全 provider 载荷:全局片段 + 指定工作区项目片段的全部条目。
|
|
12728
|
+
* 项目条目排在全局之后(同 key 时后出现的项目条目在 client 侧优先)。
|
|
12729
|
+
* @author ddj 2026年09月10号
|
|
12730
|
+
* @param ctx DSH 上下文
|
|
12731
|
+
* @param workspacePath 当前会话工作区(可选;未注册则仅全局片段)
|
|
12732
|
+
* @returns snippets.entries 载荷
|
|
12733
|
+
*/
|
|
12734
|
+
async function snippetsEntries(ctx, workspacePath) {
|
|
12735
|
+
const loaded = [];
|
|
12736
|
+
const load = async (dir, scope) => {
|
|
12737
|
+
for (const info of await listSnippetsDir(dir, scope)) {
|
|
12738
|
+
const parsed = parseSnippetsJson(await readFile(info.absPath, "utf8").catch(() => ""));
|
|
12739
|
+
if (parsed.error) continue;
|
|
12740
|
+
loaded.push({
|
|
12741
|
+
info,
|
|
12742
|
+
parsed
|
|
12743
|
+
});
|
|
12744
|
+
}
|
|
12745
|
+
};
|
|
12746
|
+
await load(userSnippetsDir(), "user");
|
|
12747
|
+
const registered = workspaceList(ctx).some((item) => item.path === workspacePath);
|
|
12748
|
+
if (workspacePath && registered) await load(projectSnippetsDir(workspacePath), "project");
|
|
12749
|
+
return { entries: flattenSnippetEntries(loaded) };
|
|
12750
|
+
}
|
|
12751
|
+
/** 提示词长度上限。 */
|
|
12752
|
+
const QUICK_MENU_PROMPT_CAP = 8192;
|
|
12753
|
+
/** 技能名合法形态。 */
|
|
12754
|
+
const QUICK_MENU_SKILL_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12755
|
+
/** 未命名项兜底名。 */
|
|
12756
|
+
const FALLBACK_NAME = "未命名";
|
|
12757
|
+
/**
|
|
12758
|
+
* 数值夹取(NaN/非有限回落到 fallback)。
|
|
12759
|
+
* @author ddj 2026年10月30号
|
|
12760
|
+
* @param value 原始值
|
|
12761
|
+
* @param min 下限
|
|
12762
|
+
* @param max 上限
|
|
12763
|
+
* @param fallback 非法值兜底
|
|
12764
|
+
* @returns 夹取后的数值
|
|
12765
|
+
*/
|
|
12766
|
+
function clampNumber(value, min, max, fallback) {
|
|
12767
|
+
const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
12768
|
+
if (number < min) return min;
|
|
12769
|
+
if (number > max) return max;
|
|
12770
|
+
return number;
|
|
12771
|
+
}
|
|
12772
|
+
/**
|
|
12773
|
+
* 取对象形态(非对象/数组返回 null)。
|
|
12774
|
+
* @author ddj 2026年10月30号
|
|
12775
|
+
* @param value 原始值
|
|
12776
|
+
* @returns 普通对象或 null
|
|
12777
|
+
*/
|
|
12778
|
+
function asRecord$1(value) {
|
|
12779
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
12780
|
+
return value;
|
|
12781
|
+
}
|
|
12782
|
+
/**
|
|
12783
|
+
* 取字符串(非字符串回落空串)。
|
|
12784
|
+
* @author ddj 2026年10月30号
|
|
12785
|
+
* @param value 原始值
|
|
12786
|
+
* @returns 字符串
|
|
12787
|
+
*/
|
|
12788
|
+
function asString$1(value) {
|
|
12789
|
+
return typeof value === "string" ? value : "";
|
|
12790
|
+
}
|
|
12791
|
+
/**
|
|
12792
|
+
* 文本截断(超长补省略号)。
|
|
12793
|
+
* @author ddj 2026年10月30号
|
|
12794
|
+
* @param text 原文
|
|
12795
|
+
* @param cap 上限字符数
|
|
12796
|
+
* @returns 截断后文本
|
|
12797
|
+
*/
|
|
12798
|
+
function cutText(text, cap) {
|
|
12799
|
+
if (text.length <= cap) return text;
|
|
12800
|
+
return text.slice(0, Math.max(0, cap - 1)) + "…";
|
|
12801
|
+
}
|
|
12802
|
+
/**
|
|
12803
|
+
* 归一化工作区路径(统一分隔符、去尾分隔符、win32 转小写),用于比较而非展示。
|
|
12804
|
+
* @author ddj 2026年10月30号
|
|
12805
|
+
* @param path 原始路径
|
|
12806
|
+
* @returns 归一化路径
|
|
12807
|
+
*/
|
|
12808
|
+
function normalizePath(path) {
|
|
12809
|
+
const unified = path.trim().replace(/\//g, "\\").replace(/\\+$/, "");
|
|
12810
|
+
if (unified === "") return "";
|
|
12811
|
+
return unified.toLowerCase();
|
|
12812
|
+
}
|
|
12813
|
+
/**
|
|
12814
|
+
* 默认文档(首次打开菜单时的初始状态)。
|
|
12815
|
+
* @author ddj 2026年10月30号
|
|
12816
|
+
* @returns 默认文档
|
|
12817
|
+
*/
|
|
12818
|
+
function defaultDoc() {
|
|
12819
|
+
return {
|
|
12820
|
+
version: 1,
|
|
12821
|
+
revision: 0,
|
|
12822
|
+
updatedAt: "",
|
|
12823
|
+
enabled: true,
|
|
12824
|
+
clickAction: "send",
|
|
12825
|
+
maxDepth: 3,
|
|
12826
|
+
launcher: {
|
|
12827
|
+
x: null,
|
|
12828
|
+
y: null
|
|
12829
|
+
},
|
|
12830
|
+
items: []
|
|
12831
|
+
};
|
|
12832
|
+
}
|
|
12833
|
+
/**
|
|
12834
|
+
* 归一化限定工作区列表(去空、去重、归一化;空结果回落到 null=全部)。
|
|
12835
|
+
* @author ddj 2026年10月30号
|
|
12836
|
+
* @param raw 原始值
|
|
12837
|
+
* @returns 限定列表或 null
|
|
12838
|
+
*/
|
|
12839
|
+
function normalizeWorkspaces(raw) {
|
|
12840
|
+
if (!Array.isArray(raw)) return null;
|
|
12841
|
+
const out = [];
|
|
12842
|
+
for (const entry of raw) {
|
|
12843
|
+
if (typeof entry !== "string") continue;
|
|
12844
|
+
const normalized = normalizePath(entry);
|
|
12845
|
+
if (normalized === "") continue;
|
|
12846
|
+
if (out.includes(normalized)) continue;
|
|
12847
|
+
out.push(normalized);
|
|
12848
|
+
}
|
|
12849
|
+
return out.length > 0 ? out : null;
|
|
12850
|
+
}
|
|
12851
|
+
/**
|
|
12852
|
+
* 绑定规则的稳定键(校验、去重、内联正文映射共用)。
|
|
12853
|
+
* @author ddj 2026年10月30号
|
|
12854
|
+
* @param binding 规则绑定
|
|
12855
|
+
* @returns 键
|
|
12856
|
+
*/
|
|
12857
|
+
function bindingKey(binding) {
|
|
12858
|
+
const workspace = normalizePath(binding.workspacePath ?? "");
|
|
12859
|
+
return binding.scope + "|" + workspace + "|" + binding.file;
|
|
12860
|
+
}
|
|
12861
|
+
/**
|
|
12862
|
+
* 归一化单条规则绑定(scope/file 必填,项目规则必须带工作区)。
|
|
12863
|
+
* @author ddj 2026年10月30号
|
|
12864
|
+
* @param raw 原始值
|
|
12865
|
+
* @returns 绑定或 null
|
|
12866
|
+
*/
|
|
12867
|
+
function normalizeRule(raw) {
|
|
12868
|
+
const record = asRecord$1(raw);
|
|
12869
|
+
if (!record) return null;
|
|
12870
|
+
const file = asString$1(record.file).trim();
|
|
12871
|
+
if (file === "") return null;
|
|
12872
|
+
const scope = record.scope === "project" ? "project" : "user";
|
|
12873
|
+
const binding = {
|
|
12874
|
+
scope,
|
|
12875
|
+
file
|
|
12876
|
+
};
|
|
12877
|
+
if (scope === "project") {
|
|
12878
|
+
const workspacePath = asString$1(record.workspacePath).trim();
|
|
12879
|
+
if (workspacePath === "") return null;
|
|
12880
|
+
binding.workspacePath = workspacePath;
|
|
12881
|
+
}
|
|
12882
|
+
if (record.origin === "current" || record.origin === "legacy") binding.origin = record.origin;
|
|
12883
|
+
return binding;
|
|
12884
|
+
}
|
|
12885
|
+
/**
|
|
12886
|
+
* 归一化规则绑定列表(去重 + 上限)。
|
|
12887
|
+
* @author ddj 2026年10月30号
|
|
12888
|
+
* @param raw 原始值
|
|
12889
|
+
* @returns 绑定列表
|
|
12890
|
+
*/
|
|
12891
|
+
function normalizeRules(raw) {
|
|
12892
|
+
if (!Array.isArray(raw)) return [];
|
|
12893
|
+
const out = [];
|
|
12894
|
+
for (const entry of raw) {
|
|
12895
|
+
const binding = normalizeRule(entry);
|
|
12896
|
+
if (!binding) continue;
|
|
12897
|
+
if (out.some((existing) => bindingKey(existing) === bindingKey(binding))) continue;
|
|
12898
|
+
out.push(binding);
|
|
12899
|
+
}
|
|
12900
|
+
return out.slice(0, 24);
|
|
12901
|
+
}
|
|
12902
|
+
/**
|
|
12903
|
+
* 归一化技能名列表(只保留 kebab-case,去重 + 上限)。
|
|
12904
|
+
* @author ddj 2026年10月30号
|
|
12905
|
+
* @param raw 原始值
|
|
12906
|
+
* @returns 技能名列表
|
|
12907
|
+
*/
|
|
12908
|
+
function normalizeSkills(raw) {
|
|
12909
|
+
if (!Array.isArray(raw)) return [];
|
|
12910
|
+
const out = [];
|
|
12911
|
+
for (const entry of raw) {
|
|
12912
|
+
if (typeof entry !== "string") continue;
|
|
12913
|
+
const name = entry.trim();
|
|
12914
|
+
if (name === "" || !QUICK_MENU_SKILL_RE.test(name)) continue;
|
|
12915
|
+
if (out.includes(name)) continue;
|
|
12916
|
+
out.push(name);
|
|
12917
|
+
}
|
|
12918
|
+
return out.slice(0, 24);
|
|
12919
|
+
}
|
|
12920
|
+
/**
|
|
12921
|
+
* 归一化单个菜单项(非法类型/缺 id 返回 null)。
|
|
12922
|
+
* @author ddj 2026年10月30号
|
|
12923
|
+
* @param raw 原始值
|
|
12924
|
+
* @returns 菜单项或 null
|
|
12925
|
+
*/
|
|
12926
|
+
function normalizeItem(raw) {
|
|
12927
|
+
const record = asRecord$1(raw);
|
|
12928
|
+
if (!record) return null;
|
|
12929
|
+
const type = record.type === "group" ? "group" : "button";
|
|
12930
|
+
const id = asString$1(record.id).trim();
|
|
12931
|
+
if (id === "") return null;
|
|
12932
|
+
const name = cutText(asString$1(record.name).trim() || FALLBACK_NAME, 40);
|
|
12933
|
+
const order = clampNumber(record.order, 0, 999, 100);
|
|
12934
|
+
const rawParent = record.parentId;
|
|
12935
|
+
const base = {
|
|
12936
|
+
id,
|
|
12937
|
+
type,
|
|
12938
|
+
name,
|
|
12939
|
+
order,
|
|
12940
|
+
parentId: typeof rawParent === "string" && rawParent.trim() !== "" ? rawParent.trim() : null,
|
|
12941
|
+
workspaces: normalizeWorkspaces(record.workspaces)
|
|
12942
|
+
};
|
|
12943
|
+
if (type === "group") return {
|
|
12944
|
+
...base,
|
|
12945
|
+
type: "group"
|
|
12946
|
+
};
|
|
12947
|
+
const button = {
|
|
12948
|
+
...base,
|
|
12949
|
+
type: "button",
|
|
12950
|
+
prompt: cutText(asString$1(record.prompt), QUICK_MENU_PROMPT_CAP),
|
|
12951
|
+
skills: normalizeSkills(record.skills),
|
|
12952
|
+
rules: normalizeRules(record.rules),
|
|
12953
|
+
ruleMode: record.ruleMode === "inline" ? "inline" : "reference"
|
|
12954
|
+
};
|
|
12955
|
+
if (record.clickAction === "send" || record.clickAction === "fill") button.clickAction = record.clickAction;
|
|
12956
|
+
return button;
|
|
12957
|
+
}
|
|
12958
|
+
/**
|
|
12959
|
+
* 判断从起点上溯的父链是否会回到目标项(环检测;超深链也按非法处理)。
|
|
12960
|
+
* @author ddj 2026年10月30号
|
|
12961
|
+
* @param byId id → 项
|
|
12962
|
+
* @param startId 起点父 id
|
|
12963
|
+
* @param targetId 目标项 id
|
|
12964
|
+
* @returns 是否成环
|
|
12965
|
+
*/
|
|
12966
|
+
function chainHas(byId, startId, targetId) {
|
|
12967
|
+
let current = startId;
|
|
12968
|
+
let guard = 0;
|
|
12969
|
+
while (current !== null) {
|
|
12970
|
+
if (current === targetId) return true;
|
|
12971
|
+
if (guard > 7) return true;
|
|
12972
|
+
const parent = byId.get(current);
|
|
12973
|
+
if (!parent) return false;
|
|
12974
|
+
current = parent.parentId;
|
|
12975
|
+
guard += 1;
|
|
12976
|
+
}
|
|
12977
|
+
return false;
|
|
12978
|
+
}
|
|
12979
|
+
/**
|
|
12980
|
+
* 修补父链问题:父不存在/父是按钮/自指/形成环时把子项上抬到最近合法祖组或顶层。
|
|
12981
|
+
* @author ddj 2026年10月30号
|
|
12982
|
+
* @param items 归一化后的项
|
|
12983
|
+
* @returns 修补后的项
|
|
12984
|
+
*/
|
|
12985
|
+
function repairParents(items) {
|
|
12986
|
+
const byId = /* @__PURE__ */ new Map();
|
|
12987
|
+
for (const item of items) byId.set(item.id, item);
|
|
12988
|
+
for (const item of items) {
|
|
12989
|
+
let resolved = item.parentId;
|
|
12990
|
+
let guard = 0;
|
|
12991
|
+
while (resolved !== null && guard <= 7) {
|
|
12992
|
+
const parent = byId.get(resolved);
|
|
12993
|
+
if (!parent) {
|
|
12994
|
+
resolved = null;
|
|
12995
|
+
break;
|
|
12996
|
+
}
|
|
12997
|
+
if (parent.type === "group") break;
|
|
12998
|
+
resolved = parent.parentId;
|
|
12999
|
+
guard += 1;
|
|
13000
|
+
}
|
|
13001
|
+
if (resolved !== null && chainHas(byId, resolved, item.id)) resolved = null;
|
|
13002
|
+
item.parentId = resolved;
|
|
13003
|
+
}
|
|
13004
|
+
return items;
|
|
13005
|
+
}
|
|
13006
|
+
/**
|
|
13007
|
+
* 按父级分组重排优先级(order 归一为 10/20/30…,保证展示稳定且无并列)。
|
|
13008
|
+
* @author ddj 2026年10月30号
|
|
13009
|
+
* @param items 菜单项
|
|
13010
|
+
* @returns 同一数组(就地写入 order)
|
|
13011
|
+
*/
|
|
13012
|
+
function normalizeOrder(items) {
|
|
13013
|
+
const groups = /* @__PURE__ */ new Map();
|
|
13014
|
+
for (const item of items) {
|
|
13015
|
+
const key = item.parentId ?? "";
|
|
13016
|
+
const bucket = groups.get(key);
|
|
13017
|
+
if (bucket) bucket.push(item);
|
|
13018
|
+
else groups.set(key, [item]);
|
|
13019
|
+
}
|
|
13020
|
+
for (const bucket of groups.values()) {
|
|
13021
|
+
bucket.sort((left, right) => {
|
|
13022
|
+
if (left.order !== right.order) return left.order - right.order;
|
|
13023
|
+
if (left.name !== right.name) return left.name < right.name ? -1 : 1;
|
|
13024
|
+
return left.id < right.id ? -1 : 1;
|
|
13025
|
+
});
|
|
13026
|
+
bucket.forEach((item, index) => {
|
|
13027
|
+
item.order = (index + 1) * 10;
|
|
13028
|
+
});
|
|
13029
|
+
}
|
|
13030
|
+
return items;
|
|
13031
|
+
}
|
|
13032
|
+
/**
|
|
13033
|
+
* 归一化整份文档(任何脏数据都收敛到可用形态,绝不抛错)。
|
|
13034
|
+
* @author ddj 2026年10月30号
|
|
13035
|
+
* @param raw 原始值(JSON.parse 结果或任意 unknown)
|
|
13036
|
+
* @returns 合法文档
|
|
13037
|
+
*/
|
|
13038
|
+
function normalizeDoc(raw) {
|
|
13039
|
+
const fallback = defaultDoc();
|
|
13040
|
+
const record = asRecord$1(raw);
|
|
13041
|
+
if (!record) return fallback;
|
|
13042
|
+
const rawItems = Array.isArray(record.items) ? record.items : [];
|
|
13043
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13044
|
+
const items = [];
|
|
13045
|
+
for (const entry of rawItems) {
|
|
13046
|
+
const item = normalizeItem(entry);
|
|
13047
|
+
if (!item || seen.has(item.id)) continue;
|
|
13048
|
+
seen.add(item.id);
|
|
13049
|
+
items.push(item);
|
|
13050
|
+
if (items.length >= 200) break;
|
|
13051
|
+
}
|
|
13052
|
+
const x = typeof record.launcher === "object" && record.launcher !== null ? asRecord$1(record.launcher) : null;
|
|
13053
|
+
return {
|
|
13054
|
+
version: 1,
|
|
13055
|
+
revision: Math.floor(clampNumber(record.revision, 0, Number.MAX_SAFE_INTEGER, 0)),
|
|
13056
|
+
updatedAt: cutText(asString$1(record.updatedAt), 40),
|
|
13057
|
+
enabled: typeof record.enabled === "boolean" ? record.enabled : true,
|
|
13058
|
+
clickAction: record.clickAction === "fill" ? "fill" : "send",
|
|
13059
|
+
maxDepth: Math.round(clampNumber(record.maxDepth, 1, 5, 3)),
|
|
13060
|
+
launcher: {
|
|
13061
|
+
x: x && typeof x.x === "number" && Number.isFinite(x.x) ? x.x : null,
|
|
13062
|
+
y: x && typeof x.y === "number" && Number.isFinite(x.y) ? x.y : null
|
|
13063
|
+
},
|
|
13064
|
+
items: normalizeOrder(repairParents(items))
|
|
13065
|
+
};
|
|
13066
|
+
}
|
|
13067
|
+
/**
|
|
13068
|
+
* 结构迁移入口(当前只有 v1;后续版本在此收敛旧结构)。
|
|
13069
|
+
* @author ddj 2026年10月30号
|
|
13070
|
+
* @param raw 原始值
|
|
13071
|
+
* @returns 合法文档
|
|
13072
|
+
*/
|
|
13073
|
+
function migrateDoc(raw) {
|
|
13074
|
+
return normalizeDoc(raw);
|
|
13075
|
+
}
|
|
13076
|
+
/**
|
|
13077
|
+
* 序列化文档(host 落盘与导入导出共用)。
|
|
13078
|
+
* @author ddj 2026年10月30号
|
|
13079
|
+
* @param doc 文档
|
|
13080
|
+
* @returns JSON 文本
|
|
13081
|
+
*/
|
|
13082
|
+
function serializeDoc(doc) {
|
|
13083
|
+
return JSON.stringify(normalizeDoc(doc), null, 2);
|
|
13084
|
+
}
|
|
13085
|
+
/**
|
|
13086
|
+
* 解析文档文本(解析失败返回默认文档并给出告警文案)。
|
|
13087
|
+
* @author ddj 2026年10月30号
|
|
13088
|
+
* @param text 文件内容
|
|
13089
|
+
* @returns 文档 + 可选告警
|
|
13090
|
+
*/
|
|
13091
|
+
function parseDoc(text) {
|
|
13092
|
+
try {
|
|
13093
|
+
return { doc: migrateDoc(JSON.parse(text)) };
|
|
13094
|
+
} catch (error) {
|
|
13095
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13096
|
+
return {
|
|
13097
|
+
doc: defaultDoc(),
|
|
13098
|
+
warning: "快捷菜单配置解析失败(" + reason + "),已回落默认配置"
|
|
13099
|
+
};
|
|
13100
|
+
}
|
|
13101
|
+
}
|
|
13102
|
+
//#endregion
|
|
13103
|
+
//#region src/quickMenu.ts
|
|
13104
|
+
/**
|
|
13105
|
+
* dsh-vscode-mode host — 快捷菜单配置的读写(~/.dsh/dsh-vscode-mode/data/quick-menu.json)。
|
|
13106
|
+
*
|
|
13107
|
+
* 为什么单独成模块:菜单项是用户数据而不是界面镜像——不能落在 cache/(会被 sweep、
|
|
13108
|
+
* 且与 uiState 共享 512KB 预算),也不适合塞进 Config schema(嵌套 JSON 不参与校验)。
|
|
13109
|
+
* 写入约定:revision 乐观并发 + 同文件串行队列 + tmp 原子替换 + 单代 .bak 备份,
|
|
13110
|
+
* 解析失败时把原文件另存 .bad-<ts>.json 再回落默认文档,绝不让坏文件阻塞功能。
|
|
13111
|
+
* 作者 ddj 2026年10月30号
|
|
13112
|
+
*/
|
|
13113
|
+
/** mtime 缓存(文件未变则复用解析结果,避免每次打开菜单都读盘)。 */
|
|
13114
|
+
const stateCache = /* @__PURE__ */ new Map();
|
|
13115
|
+
/** 同文件写队列尾(串行化并发写入;队列清空即移除条目,与 uiState 同套路)。 */
|
|
13116
|
+
const writeChains$1 = /* @__PURE__ */ new Map();
|
|
13117
|
+
/**
|
|
13118
|
+
* 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
|
|
13119
|
+
* @author ddj 2026年10月30号
|
|
13120
|
+
* @param file 目标文件绝对路径
|
|
13121
|
+
* @param run 实际写操作
|
|
13122
|
+
* @returns 本次写操作结果
|
|
13123
|
+
*/
|
|
13124
|
+
function chainWrite$1(file, run) {
|
|
13125
|
+
const next = (writeChains$1.get(file) ?? Promise.resolve()).then(run, run);
|
|
13126
|
+
const tail = next.then(() => void 0, () => void 0);
|
|
13127
|
+
writeChains$1.set(file, tail);
|
|
13128
|
+
tail.then(() => {
|
|
13129
|
+
if (writeChains$1.get(file) === tail) writeChains$1.delete(file);
|
|
13130
|
+
});
|
|
13131
|
+
return next;
|
|
13132
|
+
}
|
|
13133
|
+
/**
|
|
13134
|
+
* 读取文件修改时间(缺失/不可读返回 0)。
|
|
13135
|
+
* @author ddj 2026年10月30号
|
|
13136
|
+
* @param file 文件路径
|
|
13137
|
+
* @returns mtime 毫秒
|
|
13138
|
+
*/
|
|
13139
|
+
async function mtimeOf(file) {
|
|
13140
|
+
try {
|
|
13141
|
+
return (await stat(file)).mtimeMs;
|
|
13142
|
+
} catch {
|
|
13143
|
+
return 0;
|
|
13144
|
+
}
|
|
13145
|
+
}
|
|
13146
|
+
/**
|
|
13147
|
+
* 原子写文本(先写 .tmp 再改名,缺目录自动建;失败时尽量清掉临时文件)。
|
|
13148
|
+
* @author ddj 2026年10月30号
|
|
13149
|
+
* @param file 目标文件
|
|
13150
|
+
* @param text 文本
|
|
13151
|
+
*/
|
|
13152
|
+
async function writeAtomic(file, text) {
|
|
13153
|
+
await mkdir(dirname(file), { recursive: true });
|
|
13154
|
+
const temporary = file + ".tmp";
|
|
13155
|
+
await writeFile(temporary, text, "utf8");
|
|
13156
|
+
await rename(temporary, file);
|
|
13157
|
+
}
|
|
13158
|
+
/**
|
|
13159
|
+
* 把坏文件另存为 .bad-<ts>.json(尽力而为,失败不影响主流程)。
|
|
13160
|
+
* @author ddj 2026年10月30号
|
|
13161
|
+
* @param file 源文件
|
|
13162
|
+
* @returns 备份路径(失败为 null)
|
|
13163
|
+
*/
|
|
13164
|
+
async function backupCorrupt(file) {
|
|
13165
|
+
const target = file + ".bad-" + Date.now() + ".json";
|
|
13166
|
+
try {
|
|
13167
|
+
await copyFile(file, target);
|
|
13168
|
+
return target;
|
|
13169
|
+
} catch {
|
|
13170
|
+
return null;
|
|
13171
|
+
}
|
|
13172
|
+
}
|
|
13173
|
+
/**
|
|
13174
|
+
* 把上一次内容写成单代备份(quick-menu.bak.json)。
|
|
13175
|
+
* @author ddj 2026年10月30号
|
|
13176
|
+
* @param file 源文件
|
|
13177
|
+
*/
|
|
13178
|
+
async function backupPrevious(file) {
|
|
13179
|
+
try {
|
|
13180
|
+
await copyFile(file, file.replace(/\.json$/, "") + ".bak.json");
|
|
13181
|
+
} catch {}
|
|
13182
|
+
}
|
|
13183
|
+
/**
|
|
13184
|
+
* 读盘并归一(绕过缓存;文件缺失按默认文档处理)。
|
|
13185
|
+
* @author ddj 2026年10月30号
|
|
13186
|
+
* @param file 文件路径
|
|
13187
|
+
* @returns 文档 + 告警 + mtime
|
|
13188
|
+
*/
|
|
13189
|
+
async function loadFile(file) {
|
|
13190
|
+
const mtime = await mtimeOf(file);
|
|
13191
|
+
if (mtime === 0) return {
|
|
13192
|
+
doc: defaultDoc(),
|
|
13193
|
+
mtime: 0
|
|
13194
|
+
};
|
|
13195
|
+
let text = "";
|
|
13196
|
+
try {
|
|
13197
|
+
text = await readFile(file, "utf8");
|
|
13198
|
+
} catch (error) {
|
|
13199
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13200
|
+
return {
|
|
13201
|
+
doc: defaultDoc(),
|
|
13202
|
+
warning: "快捷菜单配置读取失败(" + reason + "),已回落默认配置",
|
|
13203
|
+
mtime
|
|
13204
|
+
};
|
|
13205
|
+
}
|
|
13206
|
+
const parsed = parseDoc(text);
|
|
13207
|
+
if (!parsed.warning) return {
|
|
13208
|
+
doc: parsed.doc,
|
|
13209
|
+
mtime
|
|
13210
|
+
};
|
|
13211
|
+
const backup = await backupCorrupt(file);
|
|
13212
|
+
const doc = parsed.doc;
|
|
13213
|
+
try {
|
|
13214
|
+
await writeAtomic(file, serializeDoc(doc));
|
|
13215
|
+
} catch {}
|
|
13216
|
+
const hint = backup ? "已备份为 " + backup : "备份失败";
|
|
13217
|
+
return {
|
|
13218
|
+
doc,
|
|
13219
|
+
warning: parsed.warning + "(" + hint + ")",
|
|
13220
|
+
mtime
|
|
13221
|
+
};
|
|
13222
|
+
}
|
|
13223
|
+
/**
|
|
13224
|
+
* 读取快捷菜单文档(mtime 未变时复用缓存)。
|
|
13225
|
+
* @author ddj 2026年10月30号
|
|
13226
|
+
* @param home DSH home(测试可注入)
|
|
13227
|
+
* @returns 文档 + 告警
|
|
13228
|
+
*/
|
|
13229
|
+
async function readQuickMenu(home) {
|
|
13230
|
+
const file = quickMenuFile(home);
|
|
13231
|
+
const mtime = await mtimeOf(file);
|
|
13232
|
+
const cached = stateCache.get(file);
|
|
13233
|
+
if (cached && cached.mtime === mtime) return cached;
|
|
13234
|
+
const state = await loadFile(file);
|
|
13235
|
+
stateCache.set(file, state);
|
|
13236
|
+
return state;
|
|
13237
|
+
}
|
|
13238
|
+
/**
|
|
13239
|
+
* 校验待保存文档的硬性上限(超出即拒绝,避免静默截断用户数据)。
|
|
13240
|
+
* @author ddj 2026年10月30号
|
|
13241
|
+
* @param doc 待保存文档
|
|
13242
|
+
* @returns 错误文案(合法为 null)
|
|
13243
|
+
*/
|
|
13244
|
+
function checkLimits(doc) {
|
|
13245
|
+
if (doc.items.length > 200) return "菜单项超过上限(200 项)";
|
|
13246
|
+
if (Buffer.byteLength(serializeDoc(doc), "utf8") > 262144) return "配置超出容量上限(262144 字节)";
|
|
13247
|
+
return null;
|
|
13248
|
+
}
|
|
13249
|
+
/**
|
|
13250
|
+
* 保存文档(revision 不匹配即判定为并发冲突,返回磁盘最新文档)。
|
|
13251
|
+
* @author ddj 2026年10月30号
|
|
13252
|
+
* @param doc 客户端提交的完整文档
|
|
13253
|
+
* @param baseRevision 客户端读到的 revision
|
|
13254
|
+
* @param home DSH home(测试可注入)
|
|
13255
|
+
* @returns 保存结果
|
|
13256
|
+
*/
|
|
13257
|
+
async function saveQuickMenu(doc, baseRevision, home) {
|
|
13258
|
+
const file = quickMenuFile(home);
|
|
13259
|
+
return chainWrite$1(file, async () => {
|
|
13260
|
+
const limit = checkLimits(doc);
|
|
13261
|
+
if (limit) return {
|
|
13262
|
+
ok: false,
|
|
13263
|
+
error: limit
|
|
13264
|
+
};
|
|
13265
|
+
const current = await loadFile(file);
|
|
13266
|
+
if (current.doc.revision !== baseRevision) return {
|
|
13267
|
+
ok: false,
|
|
13268
|
+
error: "配置已被其他窗口修改,请重新加载后再保存",
|
|
13269
|
+
doc: current.doc
|
|
13270
|
+
};
|
|
13271
|
+
const next = normalizeDoc(doc);
|
|
13272
|
+
next.launcher = current.doc.launcher;
|
|
13273
|
+
next.revision = current.doc.revision + 1;
|
|
13274
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
13275
|
+
try {
|
|
13276
|
+
await backupPrevious(file);
|
|
13277
|
+
await writeAtomic(file, serializeDoc(next));
|
|
13278
|
+
} catch (error) {
|
|
13279
|
+
return {
|
|
13280
|
+
ok: false,
|
|
13281
|
+
error: "快捷菜单配置写入失败:" + (error instanceof Error ? error.message : String(error))
|
|
13282
|
+
};
|
|
13283
|
+
}
|
|
13284
|
+
const state = {
|
|
13285
|
+
doc: next,
|
|
13286
|
+
mtime: await mtimeOf(file)
|
|
13287
|
+
};
|
|
13288
|
+
stateCache.set(file, state);
|
|
13289
|
+
return {
|
|
13290
|
+
ok: true,
|
|
13291
|
+
doc: next
|
|
13292
|
+
};
|
|
13293
|
+
});
|
|
13294
|
+
}
|
|
13295
|
+
/**
|
|
13296
|
+
* 只更新悬浮按钮坐标(不推进 revision,避免拖拽把正在编辑的表单顶掉)。
|
|
13297
|
+
* @author ddj 2026年10月30号
|
|
13298
|
+
* @param launcher 坐标(null 表示回到输入框锚点)
|
|
13299
|
+
* @param home DSH home(测试可注入)
|
|
13300
|
+
* @returns 保存结果
|
|
13301
|
+
*/
|
|
13302
|
+
async function patchLauncher(launcher, home) {
|
|
13303
|
+
const file = quickMenuFile(home);
|
|
13304
|
+
return chainWrite$1(file, async () => {
|
|
13305
|
+
const next = {
|
|
13306
|
+
...(await loadFile(file)).doc,
|
|
13307
|
+
launcher: {
|
|
13308
|
+
x: launcher.x,
|
|
13309
|
+
y: launcher.y
|
|
13310
|
+
},
|
|
13311
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13312
|
+
};
|
|
13313
|
+
try {
|
|
13314
|
+
await writeAtomic(file, serializeDoc(next));
|
|
13315
|
+
} catch (error) {
|
|
13316
|
+
return {
|
|
13317
|
+
ok: false,
|
|
13318
|
+
error: "保存悬浮按钮位置失败:" + (error instanceof Error ? error.message : String(error))
|
|
13319
|
+
};
|
|
10484
13320
|
}
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
13321
|
+
const state = {
|
|
13322
|
+
doc: next,
|
|
13323
|
+
mtime: await mtimeOf(file)
|
|
13324
|
+
};
|
|
13325
|
+
stateCache.set(file, state);
|
|
13326
|
+
return {
|
|
13327
|
+
ok: true,
|
|
13328
|
+
doc: next
|
|
13329
|
+
};
|
|
13330
|
+
});
|
|
10490
13331
|
}
|
|
10491
13332
|
//#endregion
|
|
10492
13333
|
//#region src/shared/externalOpen.ts
|
|
@@ -13152,15 +15993,67 @@ function reviewHandlers(ctx, registry) {
|
|
|
13152
15993
|
await bucketOf(registry, ctx, scope.cwd);
|
|
13153
15994
|
return scope.cwd;
|
|
13154
15995
|
}
|
|
15996
|
+
/**
|
|
15997
|
+
* @private
|
|
15998
|
+
* @author ddj 2026年10月30号
|
|
15999
|
+
* @description: 读取待审页;待审库尚未建立或迁移失败时按空页处理。
|
|
16000
|
+
* 待审投影是增量能力,不能因为它让官方列表整体不可用。
|
|
16001
|
+
* @param cwd 工作区
|
|
16002
|
+
* @param sessionId 请求会话
|
|
16003
|
+
* @returns 待审页(失败为空页)
|
|
16004
|
+
*/
|
|
16005
|
+
function pendingPageOf(cwd, sessionId) {
|
|
16006
|
+
try {
|
|
16007
|
+
return loadPage(cwd, sessionId);
|
|
16008
|
+
} catch {
|
|
16009
|
+
return {
|
|
16010
|
+
items: [],
|
|
16011
|
+
pending: 0,
|
|
16012
|
+
nextCursor: null,
|
|
16013
|
+
gaps: []
|
|
16014
|
+
};
|
|
16015
|
+
}
|
|
16016
|
+
}
|
|
13155
16017
|
return {
|
|
13156
16018
|
"edrv.review.list": async (args) => {
|
|
16019
|
+
const cwd = await workspace(args.sessionId);
|
|
16020
|
+
const official = reviewPage(cwd, args.cursor, args.history, args.path);
|
|
16021
|
+
const pending = pendingPageOf(cwd, args.sessionId);
|
|
16022
|
+
if (args.history) return {
|
|
16023
|
+
ok: true,
|
|
16024
|
+
page: official
|
|
16025
|
+
};
|
|
16026
|
+
if (args.path !== void 0) {
|
|
16027
|
+
const key = args.path;
|
|
16028
|
+
const own = pending.items.filter((item) => pendingSamePath(item.path, key)).map((item) => pendingItemOf(item, cwd));
|
|
16029
|
+
const history = own.length ? official.items.filter((item) => !pendingSamePath(item.path, key)) : official.items;
|
|
16030
|
+
return {
|
|
16031
|
+
ok: true,
|
|
16032
|
+
page: {
|
|
16033
|
+
...official,
|
|
16034
|
+
items: [...own, ...history]
|
|
16035
|
+
}
|
|
16036
|
+
};
|
|
16037
|
+
}
|
|
13157
16038
|
return {
|
|
13158
16039
|
ok: true,
|
|
13159
|
-
page:
|
|
16040
|
+
page: mergedPage(official, pending, cwd)
|
|
13160
16041
|
};
|
|
13161
16042
|
},
|
|
13162
16043
|
"edrv.review.detail": async (args) => {
|
|
13163
|
-
const
|
|
16044
|
+
const cwd = await workspace(args.sessionId);
|
|
16045
|
+
if (args.id.startsWith("pending:")) {
|
|
16046
|
+
const detail = loadDetail(cwd, args.id.slice(8));
|
|
16047
|
+
if (!detail) return {
|
|
16048
|
+
ok: false,
|
|
16049
|
+
error: "待审条目不存在"
|
|
16050
|
+
};
|
|
16051
|
+
return {
|
|
16052
|
+
ok: true,
|
|
16053
|
+
detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
|
|
16054
|
+
};
|
|
16055
|
+
}
|
|
16056
|
+
const detail = reviewDetail(cwd, args.id);
|
|
13164
16057
|
return detail ? {
|
|
13165
16058
|
ok: true,
|
|
13166
16059
|
detail
|
|
@@ -13171,6 +16064,25 @@ function reviewHandlers(ctx, registry) {
|
|
|
13171
16064
|
},
|
|
13172
16065
|
"edrv.review.decide": async (args) => {
|
|
13173
16066
|
const cwd = await liveWorkspace(args.sessionId);
|
|
16067
|
+
if (args.id.startsWith("pending:")) {
|
|
16068
|
+
const result = await applyDecide(ctx.get("fs"), cwd, {
|
|
16069
|
+
sessionId: args.sessionId,
|
|
16070
|
+
id: args.id.slice(8),
|
|
16071
|
+
revision: args.revision,
|
|
16072
|
+
operationId: args.operationId,
|
|
16073
|
+
decision: args.decision,
|
|
16074
|
+
scope: args.scope === "hunk" ? "block" : "file",
|
|
16075
|
+
...args.blockId === void 0 ? {} : { blockId: args.blockId }
|
|
16076
|
+
});
|
|
16077
|
+
if (!result.ok) return {
|
|
16078
|
+
ok: false,
|
|
16079
|
+
error: result.reason
|
|
16080
|
+
};
|
|
16081
|
+
return {
|
|
16082
|
+
ok: true,
|
|
16083
|
+
revision: result.flags.revision
|
|
16084
|
+
};
|
|
16085
|
+
}
|
|
13174
16086
|
try {
|
|
13175
16087
|
return {
|
|
13176
16088
|
ok: true,
|
|
@@ -13186,6 +16098,248 @@ function reviewHandlers(ctx, registry) {
|
|
|
13186
16098
|
};
|
|
13187
16099
|
}
|
|
13188
16100
|
//#endregion
|
|
16101
|
+
//#region src/pendingRpc.ts
|
|
16102
|
+
/**
|
|
16103
|
+
* @author ddj 2026年10月30号
|
|
16104
|
+
* @description: 解析会话工作区并预热记录桶;读路径也走它,避免冷读时桶未就绪。
|
|
16105
|
+
* @param ctx Host 上下文
|
|
16106
|
+
* @param registry 工作区记录桶缓存
|
|
16107
|
+
* @param sessionId 请求作用域
|
|
16108
|
+
* @returns 工作区路径
|
|
16109
|
+
*/
|
|
16110
|
+
async function workspaceOf(ctx, registry, sessionId) {
|
|
16111
|
+
const scope = await resolveScope(ctx, sessionId);
|
|
16112
|
+
if ("err" in scope) throw new Error("会话不存在或没有工作区");
|
|
16113
|
+
await bucketOf(registry, ctx, scope.cwd);
|
|
16114
|
+
return scope.cwd;
|
|
16115
|
+
}
|
|
16116
|
+
/**
|
|
16117
|
+
* @author ddj 2026年10月30号
|
|
16118
|
+
* @description: 写路径专用的工作区解析:本端没有该会话就直接拒绝,不做跨实例写。
|
|
16119
|
+
* @param ctx Host 上下文
|
|
16120
|
+
* @param registry 工作区记录桶缓存
|
|
16121
|
+
* @param sessionId 请求作用域
|
|
16122
|
+
* @returns 工作区路径
|
|
16123
|
+
*/
|
|
16124
|
+
async function writeWorkspaceOf(ctx, registry, sessionId) {
|
|
16125
|
+
const cwd = await workspaceOf(ctx, registry, sessionId);
|
|
16126
|
+
if (!sessionOf(ctx, sessionId)) throw new Error("写入要求本端活跃会话");
|
|
16127
|
+
return cwd;
|
|
16128
|
+
}
|
|
16129
|
+
/**
|
|
16130
|
+
* @author ddj 2026年10月30号
|
|
16131
|
+
* @description: 把异常归一成可播报文案,避免处理器各自重复三元表达式。
|
|
16132
|
+
* @param error 捕获到的异常
|
|
16133
|
+
* @returns 错误文案
|
|
16134
|
+
*/
|
|
16135
|
+
function failureOf(error) {
|
|
16136
|
+
return error instanceof Error ? error.message : String(error);
|
|
16137
|
+
}
|
|
16138
|
+
/**
|
|
16139
|
+
* @author ddj 2026年10月30号
|
|
16140
|
+
* @description: 按会话策略包装 shell:执行器默认按 workspace-write 跑,本机无可用沙箱后端会直接拒绝。
|
|
16141
|
+
* @param raw 原始 shell 能力
|
|
16142
|
+
* @param policy 会话沙箱策略
|
|
16143
|
+
* @returns 带策略的 shell 视图
|
|
16144
|
+
*/
|
|
16145
|
+
function shellOf(raw, policy) {
|
|
16146
|
+
return {
|
|
16147
|
+
resolve: (request) => raw.resolve(policy ? {
|
|
16148
|
+
...request,
|
|
16149
|
+
sandboxPolicy: policy
|
|
16150
|
+
} : request),
|
|
16151
|
+
execute: (spec) => raw.execute(spec)
|
|
16152
|
+
};
|
|
16153
|
+
}
|
|
16154
|
+
/**
|
|
16155
|
+
* @author ddj 2026年10月30号
|
|
16156
|
+
* @description: 读路径处理器:列表与详情,允许跨实例冷读。
|
|
16157
|
+
* @param kit 作用域能力
|
|
16158
|
+
* @returns 处理器表
|
|
16159
|
+
*/
|
|
16160
|
+
function readHandlers(kit) {
|
|
16161
|
+
return {
|
|
16162
|
+
"edrv.pending.list": async (args) => {
|
|
16163
|
+
const cwd = await kit.read(args.sessionId);
|
|
16164
|
+
const page = loadPage(cwd, args.sessionId);
|
|
16165
|
+
for (const item of page.items) trackPendingCover(cwd, item.path, true);
|
|
16166
|
+
return {
|
|
16167
|
+
ok: true,
|
|
16168
|
+
page
|
|
16169
|
+
};
|
|
16170
|
+
},
|
|
16171
|
+
"edrv.pending.detail": async (args) => {
|
|
16172
|
+
const detail = loadDetail(await kit.read(args.sessionId), pendingRawId(args.id));
|
|
16173
|
+
return detail ? {
|
|
16174
|
+
ok: true,
|
|
16175
|
+
detail
|
|
16176
|
+
} : {
|
|
16177
|
+
ok: false,
|
|
16178
|
+
error: "待审条目不存在"
|
|
16179
|
+
};
|
|
16180
|
+
}
|
|
16181
|
+
};
|
|
16182
|
+
}
|
|
16183
|
+
/**
|
|
16184
|
+
* @author ddj 2026年10月30号
|
|
16185
|
+
* @description: 判定处理器:Keep/Revert 与已解决条目移除,都会改变待审状态。
|
|
16186
|
+
* @param kit 作用域能力
|
|
16187
|
+
* @returns 处理器表
|
|
16188
|
+
*/
|
|
16189
|
+
function decideHandlers(kit) {
|
|
16190
|
+
return {
|
|
16191
|
+
"edrv.pending.decide": async (args) => {
|
|
16192
|
+
const cwd = await kit.write(args.sessionId);
|
|
16193
|
+
const id = pendingRawId(args.id);
|
|
16194
|
+
try {
|
|
16195
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId));
|
|
16196
|
+
const result = await applyDecide(kit.ctx.get("fs"), cwd, {
|
|
16197
|
+
...args,
|
|
16198
|
+
id
|
|
16199
|
+
}, void 0, policy);
|
|
16200
|
+
if (!result.ok) return {
|
|
16201
|
+
ok: false,
|
|
16202
|
+
error: result.reason
|
|
16203
|
+
};
|
|
16204
|
+
const detail = loadDetail(cwd, id);
|
|
16205
|
+
return detail ? {
|
|
16206
|
+
ok: true,
|
|
16207
|
+
item: detail.item,
|
|
16208
|
+
wrote: result.wrote
|
|
16209
|
+
} : {
|
|
16210
|
+
ok: false,
|
|
16211
|
+
error: "待审条目不存在"
|
|
16212
|
+
};
|
|
16213
|
+
} catch (error) {
|
|
16214
|
+
return {
|
|
16215
|
+
ok: false,
|
|
16216
|
+
error: failureOf(error)
|
|
16217
|
+
};
|
|
16218
|
+
}
|
|
16219
|
+
},
|
|
16220
|
+
"edrv.pending.remove": async (args) => {
|
|
16221
|
+
const cwd = await kit.write(args.sessionId);
|
|
16222
|
+
const id = pendingRawId(args.id);
|
|
16223
|
+
const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path;
|
|
16224
|
+
const removed = removeEntry(cwd, id, args.revision);
|
|
16225
|
+
if (removed && path) trackPendingCover(cwd, path, false);
|
|
16226
|
+
return {
|
|
16227
|
+
ok: true,
|
|
16228
|
+
removed
|
|
16229
|
+
};
|
|
16230
|
+
}
|
|
16231
|
+
};
|
|
16232
|
+
}
|
|
16233
|
+
/**
|
|
16234
|
+
* @author ddj 2026年10月30号
|
|
16235
|
+
* @description: 历史处理器:撤销/重做一次决定。
|
|
16236
|
+
* @param kit 作用域能力
|
|
16237
|
+
* @returns 处理器表
|
|
16238
|
+
*/
|
|
16239
|
+
function historyHandlers(kit) {
|
|
16240
|
+
return { "edrv.pending.history": async (args) => {
|
|
16241
|
+
const cwd = await kit.write(args.sessionId);
|
|
16242
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId));
|
|
16243
|
+
try {
|
|
16244
|
+
const result = await undoDecide(kit.ctx.get("fs"), cwd, args, void 0, policy);
|
|
16245
|
+
return result.ok ? {
|
|
16246
|
+
ok: true,
|
|
16247
|
+
item: result.item,
|
|
16248
|
+
wrote: result.wrote
|
|
16249
|
+
} : {
|
|
16250
|
+
ok: false,
|
|
16251
|
+
error: result.reason
|
|
16252
|
+
};
|
|
16253
|
+
} catch (error) {
|
|
16254
|
+
return {
|
|
16255
|
+
ok: false,
|
|
16256
|
+
error: failureOf(error)
|
|
16257
|
+
};
|
|
16258
|
+
}
|
|
16259
|
+
} };
|
|
16260
|
+
}
|
|
16261
|
+
/**
|
|
16262
|
+
* @author ddj 2026年10月30号
|
|
16263
|
+
* @description: 投影维护处理器:从版本库导入、按磁盘现状刷新单条。
|
|
16264
|
+
* @param kit 作用域能力
|
|
16265
|
+
* @returns 处理器表
|
|
16266
|
+
*/
|
|
16267
|
+
function refreshHandlers(kit) {
|
|
16268
|
+
return {
|
|
16269
|
+
"edrv.pending.import": async (args) => {
|
|
16270
|
+
const cwd = await kit.write(args.sessionId);
|
|
16271
|
+
const raw = kit.ctx.get("shell");
|
|
16272
|
+
if (!raw) return {
|
|
16273
|
+
ok: false,
|
|
16274
|
+
error: "当前环境没有 shell 能力,无法读取版本库基线"
|
|
16275
|
+
};
|
|
16276
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId));
|
|
16277
|
+
try {
|
|
16278
|
+
const result = await importFromVcs(shellOf(raw, policy), cwd, args.sessionId, {
|
|
16279
|
+
includeUntracked: args.includeUntracked,
|
|
16280
|
+
path: args.path
|
|
16281
|
+
});
|
|
16282
|
+
for (const item of loadPage(cwd, args.sessionId).items) trackPendingCover(cwd, item.path, true);
|
|
16283
|
+
return {
|
|
16284
|
+
ok: true,
|
|
16285
|
+
imported: result.imported,
|
|
16286
|
+
changes: result.changes
|
|
16287
|
+
};
|
|
16288
|
+
} catch (error) {
|
|
16289
|
+
return {
|
|
16290
|
+
ok: false,
|
|
16291
|
+
error: failureOf(error)
|
|
16292
|
+
};
|
|
16293
|
+
}
|
|
16294
|
+
},
|
|
16295
|
+
"edrv.pending.refresh": async (args) => {
|
|
16296
|
+
const cwd = await kit.write(args.sessionId);
|
|
16297
|
+
const id = pendingRawId(args.id);
|
|
16298
|
+
const stored = loadEntries(cwd).find((entry) => entry.entry.id === id);
|
|
16299
|
+
if (!stored) return {
|
|
16300
|
+
ok: false,
|
|
16301
|
+
error: "待审条目不存在"
|
|
16302
|
+
};
|
|
16303
|
+
const fs = kit.ctx.get("fs");
|
|
16304
|
+
const session = sessionOf(kit.ctx, args.sessionId);
|
|
16305
|
+
const content = await readText(fs, await fs.resolve(id, {
|
|
16306
|
+
cwd,
|
|
16307
|
+
agent: session
|
|
16308
|
+
})).catch(() => void 0);
|
|
16309
|
+
if (content === void 0) return {
|
|
16310
|
+
ok: false,
|
|
16311
|
+
error: "读取失败"
|
|
16312
|
+
};
|
|
16313
|
+
const next = refreshed(stored, content, Date.now());
|
|
16314
|
+
if (next) saveEntry(cwd, next);
|
|
16315
|
+
return {
|
|
16316
|
+
ok: true,
|
|
16317
|
+
item: itemOf(next ?? stored)
|
|
16318
|
+
};
|
|
16319
|
+
}
|
|
16320
|
+
};
|
|
16321
|
+
}
|
|
16322
|
+
/**
|
|
16323
|
+
* @author ddj 2026年10月29号
|
|
16324
|
+
* @description: 待审投影处理器表:按用途拆成读/判定/历史/维护四组,便于分别收敛权限。
|
|
16325
|
+
* @param ctx Host 上下文
|
|
16326
|
+
* @param registry 工作区记录桶缓存
|
|
16327
|
+
* @returns 待审相关处理器表
|
|
16328
|
+
*/
|
|
16329
|
+
function pendingHandlers(ctx, registry) {
|
|
16330
|
+
const kit = {
|
|
16331
|
+
ctx,
|
|
16332
|
+
read: (sessionId) => workspaceOf(ctx, registry, sessionId),
|
|
16333
|
+
write: (sessionId) => writeWorkspaceOf(ctx, registry, sessionId)
|
|
16334
|
+
};
|
|
16335
|
+
return {
|
|
16336
|
+
...readHandlers(kit),
|
|
16337
|
+
...decideHandlers(kit),
|
|
16338
|
+
...historyHandlers(kit),
|
|
16339
|
+
...refreshHandlers(kit)
|
|
16340
|
+
};
|
|
16341
|
+
}
|
|
16342
|
+
//#endregion
|
|
13189
16343
|
//#region src/missingReview.ts
|
|
13190
16344
|
/** Missing historical files: frozen, whole-file deletion reviews. */
|
|
13191
16345
|
const frozen = /* @__PURE__ */ new Map();
|
|
@@ -13209,13 +16363,18 @@ function revisionOf(records) {
|
|
|
13209
16363
|
}));
|
|
13210
16364
|
return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
|
|
13211
16365
|
}
|
|
16366
|
+
/** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
|
|
16367
|
+
function fileKeyOf(ctx, target) {
|
|
16368
|
+
const path = targetPathOf(ctx, target);
|
|
16369
|
+
return path ? fileKey(path) : "";
|
|
16370
|
+
}
|
|
13212
16371
|
/** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
|
|
13213
16372
|
async function fileRecords(ctx, cwd, bucket, target) {
|
|
13214
16373
|
const matches = [];
|
|
13215
|
-
const identity =
|
|
16374
|
+
const identity = fileKeyOf(ctx, target);
|
|
13216
16375
|
for (const record of bucket.values()) {
|
|
13217
16376
|
if (record.superseded) continue;
|
|
13218
|
-
if (
|
|
16377
|
+
if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record);
|
|
13219
16378
|
}
|
|
13220
16379
|
return matches.sort((left, right) => left.at.localeCompare(right.at));
|
|
13221
16380
|
}
|
|
@@ -13233,10 +16392,11 @@ async function fileAbsent(ctx, cwd, path) {
|
|
|
13233
16392
|
}
|
|
13234
16393
|
/** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
|
|
13235
16394
|
async function missingReview(ctx, session, cwd, bucket, path) {
|
|
13236
|
-
const
|
|
13237
|
-
if (
|
|
13238
|
-
if (
|
|
13239
|
-
|
|
16395
|
+
const abs = targetPathOf(ctx, await resolveTargetIn(ctx, cwd, path));
|
|
16396
|
+
if (!abs) throw new Error("无法解析目标文件路径");
|
|
16397
|
+
if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error("该文件已有官方审查记录,请使用官方审查入口");
|
|
16398
|
+
if (!await fileAbsent(ctx, cwd, abs)) throw new Error("文件已重新出现,请刷新");
|
|
16399
|
+
const records = await fileRecords(ctx, cwd, bucket, abs);
|
|
13240
16400
|
const latest = records.at(-1);
|
|
13241
16401
|
if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error("没有待审历史记录");
|
|
13242
16402
|
if (!latest || typeof latest.after !== "string") throw new Error("缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件");
|
|
@@ -13276,37 +16436,48 @@ async function applyDisk(ctx, item, target, undo) {
|
|
|
13276
16436
|
if (absent && operation.restored && !await fs.stat(target)) throw new Error("恢复后的文件再次缺失,未结清记录");
|
|
13277
16437
|
if (await fs.readText(target) !== item.view.content) throw new Error("恢复内容校验失败,未结清记录");
|
|
13278
16438
|
}
|
|
16439
|
+
/**
|
|
16440
|
+
* @author ddj 2026年10月29号
|
|
16441
|
+
* @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
|
|
16442
|
+
* @param token 冻结预览 token
|
|
16443
|
+
* @returns 文件路径,token 无效时为 undefined
|
|
16444
|
+
*/
|
|
16445
|
+
function missingPath(token) {
|
|
16446
|
+
return frozen.get(token)?.view.path;
|
|
16447
|
+
}
|
|
13279
16448
|
/** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
|
|
13280
16449
|
async function missingDecide(ctx, session, cwd, bucket, token, operationId, undo) {
|
|
13281
16450
|
const item = frozen.get(token);
|
|
13282
16451
|
if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error("删除预览已过期,请刷新后重试");
|
|
13283
16452
|
if (!operationId || operationId.length > 128) throw new Error("缺少有效操作 ID");
|
|
13284
16453
|
const target = await resolveTargetIn(ctx, cwd, item.view.path);
|
|
13285
|
-
const
|
|
16454
|
+
const abs = targetPathOf(ctx, target);
|
|
16455
|
+
if (!abs) throw new Error("无法解析目标文件路径");
|
|
16456
|
+
const key = JSON.stringify([cwd, fileKey(abs)]);
|
|
13286
16457
|
if (locks.has(key)) throw new Error("该文件正在处理,请稍后重试");
|
|
13287
16458
|
locks.add(key);
|
|
13288
16459
|
item.session = session;
|
|
13289
16460
|
try {
|
|
13290
|
-
return await commitMissing(ctx, item, bucket, target, operationId, undo);
|
|
16461
|
+
return await commitMissing(ctx, item, bucket, target, abs, operationId, undo);
|
|
13291
16462
|
} finally {
|
|
13292
16463
|
locks.delete(key);
|
|
13293
16464
|
}
|
|
13294
16465
|
}
|
|
13295
|
-
/** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
|
|
13296
|
-
async function commitMissing(ctx, item, bucket, target, operationId, undo) {
|
|
16466
|
+
/** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
|
|
16467
|
+
async function commitMissing(ctx, item, bucket, target, abs, operationId, undo) {
|
|
13297
16468
|
const previous = item.operation;
|
|
13298
16469
|
if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error("已有不同操作,请刷新后重试");
|
|
13299
16470
|
if (previous?.done) return item.view.callIds;
|
|
13300
|
-
if (hasOfficial(item.cwd,
|
|
13301
|
-
if (revisionOf(await fileRecords(ctx, item.cwd, bucket,
|
|
16471
|
+
if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error("该文件已有官方审查记录,请刷新");
|
|
16472
|
+
if (revisionOf(await fileRecords(ctx, item.cwd, bucket, abs)) !== item.revision) throw new Error("历史记录已变化,请刷新删除预览");
|
|
13302
16473
|
item.operation ??= {
|
|
13303
16474
|
id: operationId,
|
|
13304
16475
|
undo,
|
|
13305
16476
|
restored: false,
|
|
13306
16477
|
done: false
|
|
13307
16478
|
};
|
|
13308
|
-
await applyDisk(ctx, item,
|
|
13309
|
-
const current = await fileRecords(ctx, item.cwd, bucket,
|
|
16479
|
+
await applyDisk(ctx, item, abs, undo);
|
|
16480
|
+
const current = await fileRecords(ctx, item.cwd, bucket, abs);
|
|
13310
16481
|
if (revisionOf(current) !== item.revision) throw new Error("历史记录已变化,未结清记录,请刷新");
|
|
13311
16482
|
const pending = current.filter((record) => item.view.callIds.includes(record.callId));
|
|
13312
16483
|
const decisions = pending.map((record) => record.decisions);
|
|
@@ -13364,6 +16535,18 @@ function recView(record, ws = null) {
|
|
|
13364
16535
|
...ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? void 0 } : {}
|
|
13365
16536
|
};
|
|
13366
16537
|
}
|
|
16538
|
+
/**
|
|
16539
|
+
* 旧写入口守卫:路径已由待审累计基线接管时一律拒绝,避免两套权威同时写同一文件。
|
|
16540
|
+
* @author ddj 2026年10月29号
|
|
16541
|
+
* @param cwd 工作区
|
|
16542
|
+
* @param path 请求涉及的路径(可为空表示无法定位)
|
|
16543
|
+
* @returns 拒绝原因,未接管时为 null
|
|
16544
|
+
*/
|
|
16545
|
+
function legacyBlocked(cwd, path) {
|
|
16546
|
+
if (!path) return null;
|
|
16547
|
+
if (!pendingCovers(cwd, path)) return null;
|
|
16548
|
+
return "该文件已由待审累计基线接管,请使用面板 Keep/Revert(edrv.review.decide)";
|
|
16549
|
+
}
|
|
13367
16550
|
/** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
|
|
13368
16551
|
async function requireSession(ctx, sessionId) {
|
|
13369
16552
|
const session = sessionOf(ctx, sessionId);
|
|
@@ -13846,6 +17029,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13846
17029
|
...svnHandlers ?? {},
|
|
13847
17030
|
...dapHandlers ?? {},
|
|
13848
17031
|
...reviewHandlers(ctx, registry),
|
|
17032
|
+
...pendingHandlers(ctx, registry),
|
|
13849
17033
|
"edrv.list": async (args) => {
|
|
13850
17034
|
const start = performance.now();
|
|
13851
17035
|
let staleMs = 0;
|
|
@@ -13937,6 +17121,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13937
17121
|
error: sc.err
|
|
13938
17122
|
};
|
|
13939
17123
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
17124
|
+
const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
|
|
17125
|
+
if (blocked) return {
|
|
17126
|
+
ok: false,
|
|
17127
|
+
error: blocked
|
|
17128
|
+
};
|
|
13940
17129
|
const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
|
|
13941
17130
|
callId: args.callId,
|
|
13942
17131
|
scope: args.scope,
|
|
@@ -13959,6 +17148,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13959
17148
|
error: sc.err
|
|
13960
17149
|
};
|
|
13961
17150
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
17151
|
+
const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
|
|
17152
|
+
if (blocked) return {
|
|
17153
|
+
ok: false,
|
|
17154
|
+
error: blocked
|
|
17155
|
+
};
|
|
13962
17156
|
const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
|
|
13963
17157
|
callId: args.callId,
|
|
13964
17158
|
scope: args.scope,
|
|
@@ -13980,6 +17174,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13980
17174
|
ok: false,
|
|
13981
17175
|
error: sc.err
|
|
13982
17176
|
};
|
|
17177
|
+
const blocked = legacyBlocked(sc.cwd, args.path);
|
|
17178
|
+
if (blocked) return {
|
|
17179
|
+
ok: false,
|
|
17180
|
+
error: blocked
|
|
17181
|
+
};
|
|
13983
17182
|
try {
|
|
13984
17183
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
13985
17184
|
if (!await fileAbsent(ctx, sc.cwd, args.path)) return {
|
|
@@ -14003,6 +17202,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
14003
17202
|
ok: false,
|
|
14004
17203
|
error: sc.err
|
|
14005
17204
|
};
|
|
17205
|
+
const blocked = legacyBlocked(sc.cwd, missingPath(args.token));
|
|
17206
|
+
if (blocked) return {
|
|
17207
|
+
ok: false,
|
|
17208
|
+
error: blocked
|
|
17209
|
+
};
|
|
14006
17210
|
try {
|
|
14007
17211
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
14008
17212
|
return {
|
|
@@ -14023,9 +17227,15 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
14023
17227
|
error: sc.err
|
|
14024
17228
|
};
|
|
14025
17229
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
17230
|
+
const items = Array.isArray(args.items) ? args.items : [];
|
|
17231
|
+
const blocked = items.map((item) => legacyBlocked(sc.cwd, bucket.get(item.callId)?.path)).find((reason) => reason !== null);
|
|
17232
|
+
if (blocked) return {
|
|
17233
|
+
ok: false,
|
|
17234
|
+
error: blocked
|
|
17235
|
+
};
|
|
14026
17236
|
return {
|
|
14027
17237
|
ok: true,
|
|
14028
|
-
results: await applyDecisions(ctx, sc.session, sc.cwd, bucket,
|
|
17238
|
+
results: await applyDecisions(ctx, sc.session, sc.cwd, bucket, items)
|
|
14029
17239
|
};
|
|
14030
17240
|
},
|
|
14031
17241
|
/** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
|
|
@@ -15538,6 +18748,19 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
15538
18748
|
};
|
|
15539
18749
|
}
|
|
15540
18750
|
},
|
|
18751
|
+
"skills.list": async (args) => {
|
|
18752
|
+
try {
|
|
18753
|
+
return {
|
|
18754
|
+
ok: true,
|
|
18755
|
+
...await listSkillCatalog(args?.cwd)
|
|
18756
|
+
};
|
|
18757
|
+
} catch (error) {
|
|
18758
|
+
return {
|
|
18759
|
+
ok: false,
|
|
18760
|
+
error: "读取技能候选失败:" + String(error)
|
|
18761
|
+
};
|
|
18762
|
+
}
|
|
18763
|
+
},
|
|
15541
18764
|
"snippets.list": async () => {
|
|
15542
18765
|
try {
|
|
15543
18766
|
return {
|
|
@@ -15601,6 +18824,60 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
15601
18824
|
error: "读取代码片段条目失败:" + String(error)
|
|
15602
18825
|
};
|
|
15603
18826
|
}
|
|
18827
|
+
},
|
|
18828
|
+
"quickMenu.get": async () => {
|
|
18829
|
+
try {
|
|
18830
|
+
const state = await readQuickMenu();
|
|
18831
|
+
return {
|
|
18832
|
+
ok: true,
|
|
18833
|
+
doc: state.doc,
|
|
18834
|
+
warning: state.warning
|
|
18835
|
+
};
|
|
18836
|
+
} catch (error) {
|
|
18837
|
+
return {
|
|
18838
|
+
ok: false,
|
|
18839
|
+
error: "读取快捷菜单失败:" + String(error)
|
|
18840
|
+
};
|
|
18841
|
+
}
|
|
18842
|
+
},
|
|
18843
|
+
"quickMenu.save": async (args) => {
|
|
18844
|
+
try {
|
|
18845
|
+
const result = await saveQuickMenu(args.doc, args.baseRevision);
|
|
18846
|
+
if (!result.ok) return {
|
|
18847
|
+
ok: false,
|
|
18848
|
+
error: result.error
|
|
18849
|
+
};
|
|
18850
|
+
return {
|
|
18851
|
+
ok: true,
|
|
18852
|
+
doc: result.doc
|
|
18853
|
+
};
|
|
18854
|
+
} catch (error) {
|
|
18855
|
+
return {
|
|
18856
|
+
ok: false,
|
|
18857
|
+
error: "保存快捷菜单失败:" + String(error)
|
|
18858
|
+
};
|
|
18859
|
+
}
|
|
18860
|
+
},
|
|
18861
|
+
"quickMenu.launcher": async (args) => {
|
|
18862
|
+
try {
|
|
18863
|
+
const result = await patchLauncher({
|
|
18864
|
+
x: args.x,
|
|
18865
|
+
y: args.y
|
|
18866
|
+
});
|
|
18867
|
+
if (!result.ok) return {
|
|
18868
|
+
ok: false,
|
|
18869
|
+
error: result.error
|
|
18870
|
+
};
|
|
18871
|
+
return {
|
|
18872
|
+
ok: true,
|
|
18873
|
+
doc: result.doc
|
|
18874
|
+
};
|
|
18875
|
+
} catch (error) {
|
|
18876
|
+
return {
|
|
18877
|
+
ok: false,
|
|
18878
|
+
error: "保存悬浮按钮位置失败:" + String(error)
|
|
18879
|
+
};
|
|
18880
|
+
}
|
|
15604
18881
|
}
|
|
15605
18882
|
};
|
|
15606
18883
|
}
|
|
@@ -26039,6 +29316,16 @@ function attachRuntime(ctx, runtime) {
|
|
|
26039
29316
|
evidence.persist(cwd, snapshot);
|
|
26040
29317
|
return true;
|
|
26041
29318
|
});
|
|
29319
|
+
const pending = installPending(ctx, (write) => {
|
|
29320
|
+
try {
|
|
29321
|
+
trackPendingCover(write.cwd, write.path, true);
|
|
29322
|
+
} catch {}
|
|
29323
|
+
try {
|
|
29324
|
+
recordWrite(write);
|
|
29325
|
+
} catch (error) {
|
|
29326
|
+
log.warn("待审差异落库失败:" + String(error));
|
|
29327
|
+
}
|
|
29328
|
+
});
|
|
26042
29329
|
/** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
|
|
26043
29330
|
function onResult(exec, result) {
|
|
26044
29331
|
captureToolResult(ctx, runtime.registry, exec, result);
|
|
@@ -26058,6 +29345,7 @@ function attachRuntime(ctx, runtime) {
|
|
|
26058
29345
|
async function cleanup() {
|
|
26059
29346
|
await stopCapture();
|
|
26060
29347
|
evidence.dispose();
|
|
29348
|
+
pending.dispose();
|
|
26061
29349
|
runtime.fileVersions.dispose();
|
|
26062
29350
|
closeArchiveDbs();
|
|
26063
29351
|
disposeAllServers();
|