dsh-vscode-mode 0.21.4 → 0.22.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 +50 -4
- package/lib/client.js +72 -98
- package/lib/client.js.map +2 -2
- package/lib/index.js +2419 -32
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/state/diffSummary.ts +43 -4
- 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/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/pending/capture.ts +136 -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 +324 -0
- package/src/pending/store.ts +467 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +189 -0
- package/src/pendingRpc.ts +128 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +29 -2
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/rpc.ts +18 -0
- package/src/store.ts +19 -0
package/lib/index.js
CHANGED
|
@@ -2488,6 +2488,26 @@ async function resolveTargetIn(ctx, cwd, path) {
|
|
|
2488
2488
|
return ctx.get("fs").resolve(path, cwd ? { cwd } : {});
|
|
2489
2489
|
}
|
|
2490
2490
|
/**
|
|
2491
|
+
* 把解析结果还原成宿主路径字符串。
|
|
2492
|
+
* 官方 provider 的 resolve 返回不透明 FsTarget,而多数调用点按字符串使用;
|
|
2493
|
+
* 直接把对象当路径传下去会落进 SQL 绑定或 path.resolve,报出与真实原因无关的错误。
|
|
2494
|
+
* @author ddj 2026年10月30号
|
|
2495
|
+
* @param ctx DSH 上下文
|
|
2496
|
+
* @param target resolve 的返回值:宿主路径字符串或 FsTarget
|
|
2497
|
+
* @returns 宿主路径字符串;无法还原时返回空串
|
|
2498
|
+
*/
|
|
2499
|
+
function targetPathOf(ctx, target) {
|
|
2500
|
+
if (typeof target === "string") return target;
|
|
2501
|
+
const fs = ctx.get("fs");
|
|
2502
|
+
if (typeof fs?.processPath !== "function") return "";
|
|
2503
|
+
try {
|
|
2504
|
+
const resolved = fs.processPath(target);
|
|
2505
|
+
return typeof resolved === "string" && resolved !== "[object Object]" ? resolved : "";
|
|
2506
|
+
} catch {
|
|
2507
|
+
return "";
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2491
2511
|
* 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
|
|
2492
2512
|
* @author ddj 2026年09月30号
|
|
2493
2513
|
* @param ctx DSH 上下文
|
|
@@ -5041,7 +5061,7 @@ function checkTarget(dir, target) {
|
|
|
5041
5061
|
* @param path 安装入口或备份入口
|
|
5042
5062
|
* @throws 删除失败
|
|
5043
5063
|
*/
|
|
5044
|
-
function removeEntry(path) {
|
|
5064
|
+
function removeEntry$1(path) {
|
|
5045
5065
|
const info = lstatSync(path, { throwIfNoEntry: false });
|
|
5046
5066
|
if (!info) return;
|
|
5047
5067
|
if (info.isSymbolicLink()) unlinkSync(path);
|
|
@@ -5098,7 +5118,7 @@ function backupProfile(dir) {
|
|
|
5098
5118
|
function restoreProfile(saved) {
|
|
5099
5119
|
const failures = [];
|
|
5100
5120
|
try {
|
|
5101
|
-
removeEntry(saved.link);
|
|
5121
|
+
removeEntry$1(saved.link);
|
|
5102
5122
|
if (saved.hadLink) renameSync(saved.savedLink, saved.link);
|
|
5103
5123
|
} catch (error) {
|
|
5104
5124
|
failures.push(String(error));
|
|
@@ -5140,7 +5160,7 @@ async function switchProfile(ctx, dir, enabled, target) {
|
|
|
5140
5160
|
throw error;
|
|
5141
5161
|
}
|
|
5142
5162
|
try {
|
|
5143
|
-
removeEntry(saved.savedLink);
|
|
5163
|
+
removeEntry$1(saved.savedLink);
|
|
5144
5164
|
rmSync(saved.dir, {
|
|
5145
5165
|
recursive: true,
|
|
5146
5166
|
force: true
|
|
@@ -7340,10 +7360,19 @@ async function captureToolResult(ctx, registry, exec, result) {
|
|
|
7340
7360
|
}
|
|
7341
7361
|
//#endregion
|
|
7342
7362
|
//#region src/shared/lineEndings.ts
|
|
7363
|
+
/** Shared logical-line comparison; never rewrites stored patch or file bytes. @author ddj 2026年10月28号 */
|
|
7364
|
+
/** @public @author ddj 2026年10月28号 @param text Decoded text. @returns Text without one leading Unicode BOM. */
|
|
7365
|
+
function stripBom(text) {
|
|
7366
|
+
return text.replace(/^\uFEFF/, "");
|
|
7367
|
+
}
|
|
7343
7368
|
/** @public @author ddj 2026年10月28号 @param line Physical line. @returns Line without one terminal CRLF, CR or LF; other whitespace stays intact. */
|
|
7344
7369
|
function logicalLine(line) {
|
|
7345
7370
|
return line.replace(/(?:\r\n|\r|\n)$/, "");
|
|
7346
7371
|
}
|
|
7372
|
+
/** @public @author ddj 2026年10月28号 @param text Decoded file text. @returns Logical lines excluding BOM, retaining the final empty line after a terminator. */
|
|
7373
|
+
function logicalLines(text) {
|
|
7374
|
+
return stripBom(text).split(/\r\n|\r|\n/);
|
|
7375
|
+
}
|
|
7347
7376
|
//#endregion
|
|
7348
7377
|
//#region src/shared/reviewRegions.ts
|
|
7349
7378
|
/** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
|
|
@@ -7551,7 +7580,7 @@ function captureState(cwd, gap, state) {
|
|
|
7551
7580
|
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
7581
|
}
|
|
7553
7582
|
/** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
|
|
7554
|
-
function itemOf(row) {
|
|
7583
|
+
function itemOf$1(row) {
|
|
7555
7584
|
const summary = JSON.parse(String(row.summary));
|
|
7556
7585
|
const kind = row.kind ?? JSON.parse(String(row.diff)).kind;
|
|
7557
7586
|
return {
|
|
@@ -7595,7 +7624,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
|
|
|
7595
7624
|
})),
|
|
7596
7625
|
coveredPaths: covered.map((row) => String(row.identity))
|
|
7597
7626
|
},
|
|
7598
|
-
items: rows.slice(0, 50).map(itemOf),
|
|
7627
|
+
items: rows.slice(0, 50).map(itemOf$1),
|
|
7599
7628
|
pending: Number(count.n),
|
|
7600
7629
|
nextCursor: rows.length > 50 ? offset + 50 : null,
|
|
7601
7630
|
gaps: [...gaps.map((row) => ({
|
|
@@ -7613,6 +7642,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
|
|
|
7613
7642
|
}
|
|
7614
7643
|
/** @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
7644
|
function hasOfficial(cwd, path) {
|
|
7645
|
+
if (typeof path !== "string") return false;
|
|
7616
7646
|
const db = dbFor(cwd);
|
|
7617
7647
|
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false;
|
|
7618
7648
|
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 +7660,7 @@ function reviewDetail(cwd, id) {
|
|
|
7630
7660
|
const pending = row.current === 1 && row.decision === "pending";
|
|
7631
7661
|
const undo = pending && !!evidence && diff.kind === "text" && diff.before && diff.after && !diff.coarse;
|
|
7632
7662
|
return {
|
|
7633
|
-
item: itemOf(row),
|
|
7663
|
+
item: itemOf$1(row),
|
|
7634
7664
|
diff,
|
|
7635
7665
|
capturedAt: String(row.captured_at),
|
|
7636
7666
|
blocks,
|
|
@@ -8079,20 +8109,2066 @@ function installEvidence(ctx) {
|
|
|
8079
8109
|
chains.clear();
|
|
8080
8110
|
staged.clear();
|
|
8081
8111
|
}
|
|
8082
|
-
return {
|
|
8083
|
-
persist,
|
|
8084
|
-
dispose
|
|
8112
|
+
return {
|
|
8113
|
+
persist,
|
|
8114
|
+
dispose
|
|
8115
|
+
};
|
|
8116
|
+
}
|
|
8117
|
+
/** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
|
|
8118
|
+
async function readRaw(fs, target, signal) {
|
|
8119
|
+
try {
|
|
8120
|
+
return rawReviewFile(await fs.readBytes(target, signal, 1048576));
|
|
8121
|
+
} catch (error) {
|
|
8122
|
+
const code = error.code;
|
|
8123
|
+
if (code === "ENOENT" || code === "FS_NOT_FOUND") return rawReviewFile(null);
|
|
8124
|
+
throw error;
|
|
8125
|
+
}
|
|
8126
|
+
}
|
|
8127
|
+
//#endregion
|
|
8128
|
+
//#region src/pending/capture.ts
|
|
8129
|
+
/**
|
|
8130
|
+
* 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
|
|
8131
|
+
* @author ddj 2026年10月29号
|
|
8132
|
+
*/
|
|
8133
|
+
/** 可被采信为待审来源的写文件工具。 */
|
|
8134
|
+
const WRITE_TOOLS = [
|
|
8135
|
+
"edit",
|
|
8136
|
+
"write",
|
|
8137
|
+
"str_replace_editor"
|
|
8138
|
+
];
|
|
8139
|
+
/** 单文件文本上限:超过即标注 oversized,不静默截断。 */
|
|
8140
|
+
const MAX_TEXT = 1048576;
|
|
8141
|
+
/**
|
|
8142
|
+
* @author ddj 2026年10月29号
|
|
8143
|
+
* @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
|
|
8144
|
+
* @param ctx Host 生命周期
|
|
8145
|
+
* @param sink 已确认写入的接收方
|
|
8146
|
+
* @returns 可释放的采集器
|
|
8147
|
+
*/
|
|
8148
|
+
function installPendingCapture(ctx, sink) {
|
|
8149
|
+
const busy = /* @__PURE__ */ new Set();
|
|
8150
|
+
let stopped = false;
|
|
8151
|
+
ctx.on("tools/execute", aroundTool);
|
|
8152
|
+
return { dispose };
|
|
8153
|
+
/**
|
|
8154
|
+
* @author ddj 2026年10月29号
|
|
8155
|
+
* @description: 写工具执行前后各读一次,内容变化才上报。
|
|
8156
|
+
* @param exec 工具分发上下文
|
|
8157
|
+
* @param next 原始分发
|
|
8158
|
+
* @returns 原始工具结果,永不因采集失败而改变
|
|
8159
|
+
*/
|
|
8160
|
+
async function aroundTool(exec, next) {
|
|
8161
|
+
const session = exec.agent?.session;
|
|
8162
|
+
const path = exec.arguments?.file_path ?? exec.arguments?.path;
|
|
8163
|
+
if (stopped || !session?.id || typeof path !== "string" || !WRITE_TOOLS.includes(exec.name)) return next();
|
|
8164
|
+
const cwd = cwdOf(session);
|
|
8165
|
+
if (!cwd) return next();
|
|
8166
|
+
let before = null;
|
|
8167
|
+
let identity = null;
|
|
8168
|
+
try {
|
|
8169
|
+
const fs = ctx.get("fs");
|
|
8170
|
+
const target = await resolveTarget(ctx, session, path);
|
|
8171
|
+
const key = fs.processPath(target);
|
|
8172
|
+
if (typeof key !== "string" || !key) return next();
|
|
8173
|
+
if (busy.has(key)) return next();
|
|
8174
|
+
before = await readText(fs, target);
|
|
8175
|
+
if (before !== null && before.length > MAX_TEXT) return next();
|
|
8176
|
+
identity = key;
|
|
8177
|
+
busy.add(key);
|
|
8178
|
+
} catch (error) {
|
|
8179
|
+
log.debug("待审采集跳过:" + String(error));
|
|
8180
|
+
return next();
|
|
8181
|
+
}
|
|
8182
|
+
try {
|
|
8183
|
+
return await next();
|
|
8184
|
+
} finally {
|
|
8185
|
+
if (identity !== null) {
|
|
8186
|
+
const key = identity;
|
|
8187
|
+
try {
|
|
8188
|
+
report(String(cwd), String(session.id), String(path), key, before, exec);
|
|
8189
|
+
} catch (error) {
|
|
8190
|
+
log.debug("待审上报失败:" + String(error));
|
|
8191
|
+
}
|
|
8192
|
+
busy.delete(key);
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8195
|
+
}
|
|
8196
|
+
/**
|
|
8197
|
+
* @author ddj 2026年10月29号
|
|
8198
|
+
* @description: 写后读回并比较,内容一致视为无净变化不产生条目。
|
|
8199
|
+
* @param cwd 工作区
|
|
8200
|
+
* @param sessionId 会话
|
|
8201
|
+
* @param path 展示路径
|
|
8202
|
+
* @param identity 目标身份
|
|
8203
|
+
* @param before 写前内容
|
|
8204
|
+
* @param exec 工具分发上下文
|
|
8205
|
+
* @returns 无
|
|
8206
|
+
*/
|
|
8207
|
+
function report(cwd, sessionId, path, identity, before, exec) {
|
|
8208
|
+
const fs = ctx.get("fs");
|
|
8209
|
+
resolveTarget(ctx, exec.agent.session, path).then(async (target) => {
|
|
8210
|
+
const after = await readText(fs, target);
|
|
8211
|
+
if (after !== null && after.length > MAX_TEXT) return;
|
|
8212
|
+
if (before === after) return;
|
|
8213
|
+
sink({
|
|
8214
|
+
cwd,
|
|
8215
|
+
sessionId,
|
|
8216
|
+
path,
|
|
8217
|
+
identity,
|
|
8218
|
+
before,
|
|
8219
|
+
after,
|
|
8220
|
+
now: Date.now()
|
|
8221
|
+
});
|
|
8222
|
+
}).catch((error) => log.debug("待审读回失败:" + String(error)));
|
|
8223
|
+
}
|
|
8224
|
+
/**
|
|
8225
|
+
* @author ddj 2026年10月29号
|
|
8226
|
+
* @description: 释放监听。
|
|
8227
|
+
* @returns 无
|
|
8228
|
+
*/
|
|
8229
|
+
function dispose() {
|
|
8230
|
+
stopped = true;
|
|
8231
|
+
busy.clear();
|
|
8232
|
+
}
|
|
8233
|
+
}
|
|
8234
|
+
/**
|
|
8235
|
+
* @author ddj 2026年10月29号
|
|
8236
|
+
* @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
|
|
8237
|
+
* @param fs 文件系统
|
|
8238
|
+
* @param target 目标
|
|
8239
|
+
* @returns 文本或 null
|
|
8240
|
+
*/
|
|
8241
|
+
async function readText(fs, target) {
|
|
8242
|
+
try {
|
|
8243
|
+
return String(await fs.readText(target));
|
|
8244
|
+
} catch (error) {
|
|
8245
|
+
const code = error.code;
|
|
8246
|
+
if (code === "ENOENT" || code === "FS_NOT_FOUND" || code === "FS_TOO_LARGE") return null;
|
|
8247
|
+
throw error;
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8250
|
+
//#endregion
|
|
8251
|
+
//#region src/pending/vcs.ts
|
|
8252
|
+
/**
|
|
8253
|
+
* 版本库导入:把工作副本相对基线的改动读成待审条目,来源对齐 dsh-diff-approval 的 vcs 内核(MIT)。
|
|
8254
|
+
* 只读命令,不修改工作副本;基线读取失败一律上报,不猜"文件不存在"。
|
|
8255
|
+
* @author ddj 2026年10月30号
|
|
8256
|
+
*/
|
|
8257
|
+
/** 命令超时:与上游一致,避免大仓 status 卡死。 */
|
|
8258
|
+
const COMMAND_TIMEOUT = 6e4;
|
|
8259
|
+
/** stdout 预算:够放一个常见源文件,超出交给上游判断为超大。 */
|
|
8260
|
+
const STDOUT_BUDGET = 8388608;
|
|
8261
|
+
/** 只有这些 git 报错才算"该版本不存在",其余一律抛出。 */
|
|
8262
|
+
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;
|
|
8263
|
+
/** 只有这些 P4 报错才算"该版本不存在",其余一律抛出。 */
|
|
8264
|
+
const P4_ABSENT_AT_REVISION = /no such file|not opened|does not exist|no file\(s\) to print|invalid file|no revisions/i;
|
|
8265
|
+
/** git 相对路径用正斜杠。 */
|
|
8266
|
+
const GIT_SEPARATOR = "/";
|
|
8267
|
+
/**
|
|
8268
|
+
* @author ddj 2026年10月30号
|
|
8269
|
+
* @description: 向上查找版本库根目录,同目录 git 优先于 svn,最后才是 Perforce 配置。
|
|
8270
|
+
* @param cwd 工作区
|
|
8271
|
+
* @returns 探测结果,未找到时 undefined
|
|
8272
|
+
*/
|
|
8273
|
+
async function detectVcsRoot(cwd) {
|
|
8274
|
+
let current = resolve(cwd);
|
|
8275
|
+
for (;;) {
|
|
8276
|
+
if (await exists(join(current, ".git"))) return {
|
|
8277
|
+
kind: "git",
|
|
8278
|
+
root: current
|
|
8279
|
+
};
|
|
8280
|
+
if (await exists(join(current, ".svn"))) return {
|
|
8281
|
+
kind: "svn",
|
|
8282
|
+
root: current
|
|
8283
|
+
};
|
|
8284
|
+
if (await isP4Root(current)) return {
|
|
8285
|
+
kind: "p4",
|
|
8286
|
+
root: current
|
|
8287
|
+
};
|
|
8288
|
+
const parent = dirname(current);
|
|
8289
|
+
if (parent === current) return void 0;
|
|
8290
|
+
current = parent;
|
|
8291
|
+
}
|
|
8292
|
+
}
|
|
8293
|
+
/**
|
|
8294
|
+
* @author ddj 2026年10月30号
|
|
8295
|
+
* @description: 判断目录是否是 Perforce 工作区根(P4CONFIG 文件所在目录)。
|
|
8296
|
+
* @param dir 目录
|
|
8297
|
+
* @returns 是否 P4 根
|
|
8298
|
+
*/
|
|
8299
|
+
async function isP4Root(dir) {
|
|
8300
|
+
return await exists(join(dir, ".p4config")) || await exists(join(dir, ".p4config.txt"));
|
|
8301
|
+
}
|
|
8302
|
+
/**
|
|
8303
|
+
* @author ddj 2026年10月30号
|
|
8304
|
+
* @description: 读出当前工作副本相对基线的改动,只返回给定范围(含未跟踪按选项)。
|
|
8305
|
+
* @param shell shell 能力
|
|
8306
|
+
* @param cwd 工作区
|
|
8307
|
+
* @param options 导入选项
|
|
8308
|
+
* @returns 改动列表
|
|
8309
|
+
*/
|
|
8310
|
+
async function importVcsChanges(shell, cwd, options = {}) {
|
|
8311
|
+
const detected = await detectVcsRoot(cwd);
|
|
8312
|
+
if (!detected) return [];
|
|
8313
|
+
const changes = await readChanges(shell, detected, cwd, options);
|
|
8314
|
+
const only = options.path ? resolve(cwd, options.path) : void 0;
|
|
8315
|
+
return changes.filter((change) => !only || samePath(change.path, only));
|
|
8316
|
+
}
|
|
8317
|
+
/**
|
|
8318
|
+
* @author ddj 2026年10月30号
|
|
8319
|
+
* @description: 按探测到的种类取改动,新增种类只在这里分流。
|
|
8320
|
+
* @param shell shell 能力
|
|
8321
|
+
* @param detected 版本库种类与根目录
|
|
8322
|
+
* @param cwd 工作区
|
|
8323
|
+
* @param options 导入选项
|
|
8324
|
+
* @returns 改动列表
|
|
8325
|
+
*/
|
|
8326
|
+
async function readChanges(shell, detected, cwd, options) {
|
|
8327
|
+
if (detected.kind === "git") return gitChanges(shell, cwd, detected.root, options);
|
|
8328
|
+
if (detected.kind === "svn") return svnChanges(shell, cwd, detected.root, options);
|
|
8329
|
+
return p4Changes(shell, cwd, detected.root, options);
|
|
8330
|
+
}
|
|
8331
|
+
/**
|
|
8332
|
+
* @author ddj 2026年10月30号
|
|
8333
|
+
* @description: git 改动:status -z 解析状态,基线取 HEAD 版本,未跟踪可按选项导入为新建。
|
|
8334
|
+
* @param shell shell 能力
|
|
8335
|
+
* @param cwd 工作区
|
|
8336
|
+
* @param root 仓库根
|
|
8337
|
+
* @param options 导入选项
|
|
8338
|
+
* @returns 改动列表
|
|
8339
|
+
*/
|
|
8340
|
+
async function gitChanges(shell, cwd, root, options) {
|
|
8341
|
+
const status = await runCommand(shell, "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all", root);
|
|
8342
|
+
if (!status.ok) throw new Error("git status 失败:" + status.stderr.slice(0, 200));
|
|
8343
|
+
const changes = [];
|
|
8344
|
+
for (const entry of parseGitStatus(status.stdout)) {
|
|
8345
|
+
if (entry.status === "??" && !options.includeUntracked) continue;
|
|
8346
|
+
const absolute = join(root, ...entry.path.split(GIT_SEPARATOR));
|
|
8347
|
+
if (!insideRoot(root, absolute)) continue;
|
|
8348
|
+
const current = await readTextOf(absolute);
|
|
8349
|
+
if (entry.status === "??" || entry.status === "A ") {
|
|
8350
|
+
changes.push({
|
|
8351
|
+
path: absolute,
|
|
8352
|
+
earlierVersion: "none",
|
|
8353
|
+
oldText: "",
|
|
8354
|
+
newText: current
|
|
8355
|
+
});
|
|
8356
|
+
continue;
|
|
8357
|
+
}
|
|
8358
|
+
const baseline = await gitBaseline(shell, root, entry.path);
|
|
8359
|
+
changes.push({
|
|
8360
|
+
path: absolute,
|
|
8361
|
+
earlierVersion: baseline === null ? "none" : "file",
|
|
8362
|
+
oldText: baseline ?? "",
|
|
8363
|
+
newText: current
|
|
8364
|
+
});
|
|
8365
|
+
}
|
|
8366
|
+
return changes;
|
|
8367
|
+
}
|
|
8368
|
+
/**
|
|
8369
|
+
* @author ddj 2026年10月30号
|
|
8370
|
+
* @description: svn 改动:status --xml 解析状态,基线取 BASE 版本。
|
|
8371
|
+
* @param shell shell 能力
|
|
8372
|
+
* @param cwd 工作区
|
|
8373
|
+
* @param root 工作副本根
|
|
8374
|
+
* @param options 导入选项
|
|
8375
|
+
* @returns 改动列表
|
|
8376
|
+
*/
|
|
8377
|
+
async function svnChanges(shell, cwd, root, options) {
|
|
8378
|
+
const status = await runCommand(shell, "svn status --xml", root);
|
|
8379
|
+
if (!status.ok) throw new Error("svn status 失败:" + status.stderr.slice(0, 200));
|
|
8380
|
+
const changes = [];
|
|
8381
|
+
for (const entry of parseSvnStatus(status.stdout)) {
|
|
8382
|
+
if (entry.status === "unversioned" && !options.includeUntracked) continue;
|
|
8383
|
+
const absolute = resolve(root, entry.path);
|
|
8384
|
+
if (!insideRoot(root, absolute)) continue;
|
|
8385
|
+
const current = await readTextOf(absolute);
|
|
8386
|
+
if (entry.status === "added" || entry.status === "unversioned" || entry.status === "replaced") {
|
|
8387
|
+
changes.push({
|
|
8388
|
+
path: absolute,
|
|
8389
|
+
earlierVersion: "none",
|
|
8390
|
+
oldText: "",
|
|
8391
|
+
newText: current
|
|
8392
|
+
});
|
|
8393
|
+
continue;
|
|
8394
|
+
}
|
|
8395
|
+
const baseline = await svnBaseline(shell, root, entry.path);
|
|
8396
|
+
changes.push({
|
|
8397
|
+
path: absolute,
|
|
8398
|
+
earlierVersion: "file",
|
|
8399
|
+
oldText: baseline,
|
|
8400
|
+
newText: current
|
|
8401
|
+
});
|
|
8402
|
+
}
|
|
8403
|
+
return changes;
|
|
8404
|
+
}
|
|
8405
|
+
/**
|
|
8406
|
+
* @author ddj 2026年10月30号
|
|
8407
|
+
* @description: 取 HEAD 版本内容;仅"该版本确实没有此文件"时返回 null,其余失败抛出。
|
|
8408
|
+
* @param shell shell 能力
|
|
8409
|
+
* @param root 仓库根
|
|
8410
|
+
* @param relative 仓库相对路径(正斜杠)
|
|
8411
|
+
* @returns 基线内容或 null
|
|
8412
|
+
*/
|
|
8413
|
+
async function gitBaseline(shell, root, relative) {
|
|
8414
|
+
const size = await runCommand(shell, `git cat-file -s HEAD:${quote(relative)}`, root);
|
|
8415
|
+
const budget = size.ok ? Number(size.stdout.trim()) + 1024 : STDOUT_BUDGET;
|
|
8416
|
+
const shown = await runCommand(shell, `git show HEAD:${quote(relative)}`, root, Math.min(budget, STDOUT_BUDGET));
|
|
8417
|
+
if (shown.ok) return shown.stdout;
|
|
8418
|
+
if (ABSENT_AT_REVISION.test(shown.stderr)) return null;
|
|
8419
|
+
throw new Error("读取 git 基线失败:" + shown.stderr.slice(0, 200));
|
|
8420
|
+
}
|
|
8421
|
+
/**
|
|
8422
|
+
* @author ddj 2026年10月30号
|
|
8423
|
+
* @description: 取 svn BASE 版本内容;失败一律抛出(svn 没有可靠的"不存在"判据)。
|
|
8424
|
+
* @param shell shell 能力
|
|
8425
|
+
* @param root 工作副本根
|
|
8426
|
+
* @param relative 相对路径
|
|
8427
|
+
* @returns 基线内容
|
|
8428
|
+
*/
|
|
8429
|
+
async function svnBaseline(shell, root, relative) {
|
|
8430
|
+
const shown = await runCommand(shell, `svn cat -r BASE ${quote(relative)}`, root);
|
|
8431
|
+
if (!shown.ok) throw new Error("读取 svn 基线失败:" + shown.stderr.slice(0, 200));
|
|
8432
|
+
return shown.stdout;
|
|
8433
|
+
}
|
|
8434
|
+
/**
|
|
8435
|
+
* @author ddj 2026年10月30号
|
|
8436
|
+
* @description: Perforce 改动:opened(含 status 的未跟踪项)解析 depot 路径,再折成本地路径。
|
|
8437
|
+
* @param shell shell 能力
|
|
8438
|
+
* @param cwd 工作区
|
|
8439
|
+
* @param root P4 工作区根
|
|
8440
|
+
* @param options 导入选项
|
|
8441
|
+
* @returns 改动列表
|
|
8442
|
+
*/
|
|
8443
|
+
async function p4Changes(shell, cwd, root, options) {
|
|
8444
|
+
const depotPaths = /* @__PURE__ */ new Set();
|
|
8445
|
+
const opened = await runCommand(shell, "p4 opened", root);
|
|
8446
|
+
if (!opened.ok) throw new Error("p4 opened 失败:" + opened.stderr.slice(0, 200));
|
|
8447
|
+
for (const path of parseP4Opened(opened.stdout)) depotPaths.add(path);
|
|
8448
|
+
if (options.includeUntracked) {
|
|
8449
|
+
const status = await runCommand(shell, "p4 status", root);
|
|
8450
|
+
if (!status.ok) throw new Error("p4 status 失败:" + status.stderr.slice(0, 200));
|
|
8451
|
+
for (const path of parseP4Status(status.stdout)) depotPaths.add(path);
|
|
8452
|
+
}
|
|
8453
|
+
const changes = [];
|
|
8454
|
+
for (const depot of depotPaths) {
|
|
8455
|
+
const absolute = await p4LocalPath(shell, root, depot);
|
|
8456
|
+
if (!absolute || !insideRoot(root, absolute)) continue;
|
|
8457
|
+
const current = await readTextOf(absolute);
|
|
8458
|
+
const baseline = await p4Baseline(shell, root, depot);
|
|
8459
|
+
changes.push({
|
|
8460
|
+
path: absolute,
|
|
8461
|
+
earlierVersion: baseline === null ? "none" : "file",
|
|
8462
|
+
oldText: baseline ?? "",
|
|
8463
|
+
newText: current
|
|
8464
|
+
});
|
|
8465
|
+
}
|
|
8466
|
+
return changes;
|
|
8467
|
+
}
|
|
8468
|
+
/**
|
|
8469
|
+
* @author ddj 2026年10月30号
|
|
8470
|
+
* @description: 解析 `p4 opened` 输出,只取 depot 路径(`//depot/x#3 - edit ...`)。
|
|
8471
|
+
* @param text 输出
|
|
8472
|
+
* @returns depot 路径列表
|
|
8473
|
+
*/
|
|
8474
|
+
function parseP4Opened(text) {
|
|
8475
|
+
const paths = [];
|
|
8476
|
+
for (const line of text.split(/\r?\n/)) {
|
|
8477
|
+
const match = /^(\/\/[^#\s]+)#\d+/.exec(line.trim());
|
|
8478
|
+
if (match) paths.push(match[1]);
|
|
8479
|
+
}
|
|
8480
|
+
return paths;
|
|
8481
|
+
}
|
|
8482
|
+
/**
|
|
8483
|
+
* @author ddj 2026年10月30号
|
|
8484
|
+
* @description: 解析 `p4 status` 输出;每行以本地路径或 depot 路径开头,取路径本身。
|
|
8485
|
+
* @param text 输出
|
|
8486
|
+
* @returns depot 或本地路径列表
|
|
8487
|
+
*/
|
|
8488
|
+
function parseP4Status(text) {
|
|
8489
|
+
const paths = [];
|
|
8490
|
+
for (const line of text.split(/\r?\n/)) {
|
|
8491
|
+
const trimmed = line.trim();
|
|
8492
|
+
if (!trimmed || trimmed.startsWith("-")) continue;
|
|
8493
|
+
const head = trimmed.split(/\s+-\s+/)[0]?.trim();
|
|
8494
|
+
if (!head) continue;
|
|
8495
|
+
paths.push(head);
|
|
8496
|
+
}
|
|
8497
|
+
return paths;
|
|
8498
|
+
}
|
|
8499
|
+
/**
|
|
8500
|
+
* @author ddj 2026年10月30号
|
|
8501
|
+
* @description: `p4 where` 把小写 depot 路径折成本地绝对路径。
|
|
8502
|
+
* @param shell shell 能力
|
|
8503
|
+
* @param root P4 工作区根
|
|
8504
|
+
* @param depot depot 路径
|
|
8505
|
+
* @returns 本地路径,失败时 undefined
|
|
8506
|
+
*/
|
|
8507
|
+
async function p4LocalPath(shell, root, depot) {
|
|
8508
|
+
if (depot.startsWith("//")) {
|
|
8509
|
+
const where = await runCommand(shell, `p4 where ${quote(depot)}`, root);
|
|
8510
|
+
if (!where.ok) return void 0;
|
|
8511
|
+
const tokens = where.stdout.trim().split(/\s+/);
|
|
8512
|
+
return tokens[tokens.length - 1] ?? void 0;
|
|
8513
|
+
}
|
|
8514
|
+
return resolve(root, depot);
|
|
8515
|
+
}
|
|
8516
|
+
/**
|
|
8517
|
+
* @author ddj 2026年10月30号
|
|
8518
|
+
* @description: 取 `#have` 版本内容;只有明确"没有该版本"时返回 null,其余失败抛出。
|
|
8519
|
+
* @param shell shell 能力
|
|
8520
|
+
* @param root P4 工作区根
|
|
8521
|
+
* @param depot depot 路径
|
|
8522
|
+
* @returns 基线内容或 null
|
|
8523
|
+
*/
|
|
8524
|
+
async function p4Baseline(shell, root, depot) {
|
|
8525
|
+
if (!depot.startsWith("//")) return null;
|
|
8526
|
+
const printed = await runCommand(shell, `p4 print -q ${quote(depot + "#have")}`, root);
|
|
8527
|
+
if (printed.ok) return printed.stdout;
|
|
8528
|
+
if (P4_ABSENT_AT_REVISION.test(printed.stderr)) return null;
|
|
8529
|
+
throw new Error("读取 p4 基线失败:" + printed.stderr.slice(0, 200));
|
|
8530
|
+
}
|
|
8531
|
+
/**
|
|
8532
|
+
* @author ddj 2026年10月30号
|
|
8533
|
+
* @description: 解析 git status -z 输出;重命名/复制记录按目标路径处理。
|
|
8534
|
+
* @param text -z 输出
|
|
8535
|
+
* @returns 状态与仓库相对路径
|
|
8536
|
+
*/
|
|
8537
|
+
function parseGitStatus(text) {
|
|
8538
|
+
const parts = text.split("\0").filter((part) => part.length > 2);
|
|
8539
|
+
const entries = [];
|
|
8540
|
+
for (let index = 0; index < parts.length; index++) {
|
|
8541
|
+
const part = parts[index];
|
|
8542
|
+
const status = part.slice(0, 2);
|
|
8543
|
+
const path = part.slice(3);
|
|
8544
|
+
if (status.includes("R") || status.includes("C")) index++;
|
|
8545
|
+
entries.push({
|
|
8546
|
+
status,
|
|
8547
|
+
path
|
|
8548
|
+
});
|
|
8549
|
+
}
|
|
8550
|
+
return entries;
|
|
8551
|
+
}
|
|
8552
|
+
/**
|
|
8553
|
+
* @author ddj 2026年10月30号
|
|
8554
|
+
* @description: 解析 svn status --xml 输出。
|
|
8555
|
+
* @param xml xml 文本
|
|
8556
|
+
* @returns 条目状态与相对路径
|
|
8557
|
+
*/
|
|
8558
|
+
function parseSvnStatus(xml) {
|
|
8559
|
+
const entries = [];
|
|
8560
|
+
for (const match of xml.matchAll(/<entry\s+path="([^"]+)"([\s\S]*?)<\/entry>/g)) {
|
|
8561
|
+
const status = /<wc-status[^>]*\sitem="([^"]+)"/.exec(match[2] ?? "")?.[1];
|
|
8562
|
+
if (status) entries.push({
|
|
8563
|
+
status,
|
|
8564
|
+
path: decodeXml(match[1])
|
|
8565
|
+
});
|
|
8566
|
+
}
|
|
8567
|
+
return entries;
|
|
8568
|
+
}
|
|
8569
|
+
/**
|
|
8570
|
+
* @author ddj 2026年10月30号
|
|
8571
|
+
* @description: 执行一条只读命令并收集输出;超时/取消/非零退出都回 ok=false,不抛出。
|
|
8572
|
+
* @param shell shell 能力
|
|
8573
|
+
* @param command 命令
|
|
8574
|
+
* @param workdir 工作目录
|
|
8575
|
+
* @param stdoutMaxBytes stdout 预算
|
|
8576
|
+
* @returns 输出与状态
|
|
8577
|
+
*/
|
|
8578
|
+
async function runCommand(shell, command, workdir, stdoutMaxBytes = STDOUT_BUDGET) {
|
|
8579
|
+
try {
|
|
8580
|
+
const spec = shell.resolve({
|
|
8581
|
+
command,
|
|
8582
|
+
workdir,
|
|
8583
|
+
timeoutMs: COMMAND_TIMEOUT,
|
|
8584
|
+
stdoutMaxBytes
|
|
8585
|
+
});
|
|
8586
|
+
const result = await (await shell.execute(spec)).result();
|
|
8587
|
+
return {
|
|
8588
|
+
ok: result.exitCode === 0 && !result.timedOut && !result.aborted,
|
|
8589
|
+
stdout: result.stdout.text,
|
|
8590
|
+
stderr: result.stderr.text
|
|
8591
|
+
};
|
|
8592
|
+
} catch (error) {
|
|
8593
|
+
return {
|
|
8594
|
+
ok: false,
|
|
8595
|
+
stdout: "",
|
|
8596
|
+
stderr: String(error)
|
|
8597
|
+
};
|
|
8598
|
+
}
|
|
8599
|
+
}
|
|
8600
|
+
/**
|
|
8601
|
+
* @author ddj 2026年10月30号
|
|
8602
|
+
* @description: 读单元格文本,不存在时返回 null。
|
|
8603
|
+
* @param path 文件路径
|
|
8604
|
+
* @returns 内容或 null
|
|
8605
|
+
*/
|
|
8606
|
+
async function readTextOf(path) {
|
|
8607
|
+
try {
|
|
8608
|
+
if (!(await stat(path)).isFile()) return null;
|
|
8609
|
+
return await readFile(path, "utf8");
|
|
8610
|
+
} catch {
|
|
8611
|
+
return null;
|
|
8612
|
+
}
|
|
8613
|
+
}
|
|
8614
|
+
/**
|
|
8615
|
+
* @author ddj 2026年10月30号
|
|
8616
|
+
* @description: 判断路径是否存在。
|
|
8617
|
+
* @param path 路径
|
|
8618
|
+
* @returns 是否存在
|
|
8619
|
+
*/
|
|
8620
|
+
async function exists(path) {
|
|
8621
|
+
try {
|
|
8622
|
+
await stat(path);
|
|
8623
|
+
return true;
|
|
8624
|
+
} catch {
|
|
8625
|
+
return false;
|
|
8626
|
+
}
|
|
8627
|
+
}
|
|
8628
|
+
/**
|
|
8629
|
+
* @author ddj 2026年10月30号
|
|
8630
|
+
* @description: 判断目标是否位于仓库根之内(Windows 下按大小写不敏感比较)。
|
|
8631
|
+
* @param root 仓库根
|
|
8632
|
+
* @param target 目标绝对路径
|
|
8633
|
+
* @returns 是否在根内
|
|
8634
|
+
*/
|
|
8635
|
+
function insideRoot(root, target) {
|
|
8636
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
8637
|
+
const rootKey = normalize(resolve(root)).replace(/[\\/]+$/, "") + sep;
|
|
8638
|
+
return normalize(resolve(target)).startsWith(rootKey);
|
|
8639
|
+
}
|
|
8640
|
+
/**
|
|
8641
|
+
* @author ddj 2026年10月30号
|
|
8642
|
+
* @description: 比较两个路径是否同一文件(Windows 下大小写不敏感)。
|
|
8643
|
+
* @param left 路径
|
|
8644
|
+
* @param right 路径
|
|
8645
|
+
* @returns 是否相同
|
|
8646
|
+
*/
|
|
8647
|
+
function samePath(left, right) {
|
|
8648
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
8649
|
+
return normalize(resolve(left)) === normalize(resolve(right));
|
|
8650
|
+
}
|
|
8651
|
+
/**
|
|
8652
|
+
* @author ddj 2026年10月30号
|
|
8653
|
+
* @description: 为 shell 命令参数加引号,避免空格与特殊字符改变命令含义。
|
|
8654
|
+
* @param value 参数
|
|
8655
|
+
* @returns 加引号后的参数
|
|
8656
|
+
*/
|
|
8657
|
+
function quote(value) {
|
|
8658
|
+
return "\"" + value.replace(/"/g, "\\\"") + "\"";
|
|
8659
|
+
}
|
|
8660
|
+
/**
|
|
8661
|
+
* @author ddj 2026年10月30号
|
|
8662
|
+
* @description: 还原 xml 实体(svn status 会转义非 ASCII 与空格)。
|
|
8663
|
+
* @param value 属性值
|
|
8664
|
+
* @returns 解码后的路径
|
|
8665
|
+
*/
|
|
8666
|
+
function decodeXml(value) {
|
|
8667
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&");
|
|
8668
|
+
}
|
|
8669
|
+
//#endregion
|
|
8670
|
+
//#region src/pending/hunks.ts
|
|
8671
|
+
/** 每个 hunk 的上下文行数。 */
|
|
8672
|
+
const CONTEXT = 3;
|
|
8673
|
+
/** LCS 单元格上限:超过后退化为整段替换,避免大文件卡死。 */
|
|
8674
|
+
const CELL_LIMIT = 1e6;
|
|
8675
|
+
/**
|
|
8676
|
+
* @author ddj 2026年10月29号
|
|
8677
|
+
* @description: 构造可直接交给官方渲染器的差异;两侧完全相同时返回空 hunk 列表。
|
|
8678
|
+
* @param path 相对路径
|
|
8679
|
+
* @param display 展示名
|
|
8680
|
+
* @param oldText 基线全文
|
|
8681
|
+
* @param newText 当前全文
|
|
8682
|
+
* @returns 文本差异
|
|
8683
|
+
*/
|
|
8684
|
+
function textDiff(path, display, oldText, newText) {
|
|
8685
|
+
const hunks = buildHunks(logicalLines(oldText), logicalLines(newText));
|
|
8686
|
+
return {
|
|
8687
|
+
kind: "text",
|
|
8688
|
+
path,
|
|
8689
|
+
display,
|
|
8690
|
+
before: true,
|
|
8691
|
+
after: true,
|
|
8692
|
+
hunks,
|
|
8693
|
+
coarse: hunks.length > 0 && isCoarse(oldText, newText)
|
|
8694
|
+
};
|
|
8695
|
+
}
|
|
8696
|
+
/**
|
|
8697
|
+
* @author ddj 2026年10月29号
|
|
8698
|
+
* @description: 块级 Keep:把该块的新内容写进基线,返回推进后的基线。
|
|
8699
|
+
* @param oldText 当前基线
|
|
8700
|
+
* @param block 目标块
|
|
8701
|
+
* @returns 新基线;块在当前基线中无法唯一定位时返回 null
|
|
8702
|
+
*/
|
|
8703
|
+
function keepBlock(oldText, block) {
|
|
8704
|
+
return spliceBlock(oldText, block.oldLines, block.newLines, block);
|
|
8705
|
+
}
|
|
8706
|
+
/**
|
|
8707
|
+
* @author ddj 2026年10月29号
|
|
8708
|
+
* @description: 块级 Revert:把该块在最新内容里换回基线内容,返回换回后的内容。
|
|
8709
|
+
* @param newText 当前内容
|
|
8710
|
+
* @param block 目标块
|
|
8711
|
+
* @returns 新内容;块无法唯一定位时返回 null
|
|
8712
|
+
*/
|
|
8713
|
+
function revertBlock(newText, block) {
|
|
8714
|
+
return spliceBlock(newText, block.newLines, block.oldLines, block);
|
|
8715
|
+
}
|
|
8716
|
+
/**
|
|
8717
|
+
* @author ddj 2026年10月29号
|
|
8718
|
+
* @description: 按"上下文 + 目标行"唯一定位后做一次整块替换,定位不唯一就不动。
|
|
8719
|
+
* @param content 目标全文
|
|
8720
|
+
* @param from 要被替换的块内行
|
|
8721
|
+
* @param to 替换成的块内行
|
|
8722
|
+
* @param block 来源块,提供上下文;缺上下文时退化为只看块内行
|
|
8723
|
+
* @returns 新全文或 null
|
|
8724
|
+
*/
|
|
8725
|
+
function spliceBlock(content, from, to, block) {
|
|
8726
|
+
if (from.length === 0 && to.length === 0) return null;
|
|
8727
|
+
const lines = logicalLines(content);
|
|
8728
|
+
const withContext = locateSpan(lines, [
|
|
8729
|
+
...block.contextBefore,
|
|
8730
|
+
...from,
|
|
8731
|
+
...block.contextAfter
|
|
8732
|
+
]);
|
|
8733
|
+
if (withContext !== null) return spliceAt(lines, withContext + block.contextBefore.length, from.length, to);
|
|
8734
|
+
const bare = locateSpan(lines, from);
|
|
8735
|
+
return bare === null ? null : spliceAt(lines, bare, from.length, to);
|
|
8736
|
+
}
|
|
8737
|
+
/**
|
|
8738
|
+
* @author ddj 2026年10月29号
|
|
8739
|
+
* @description: 在指定偏移处把连续若干行换成另一组行。
|
|
8740
|
+
* @param lines 目标行集合
|
|
8741
|
+
* @param start 起始偏移
|
|
8742
|
+
* @param count 被替换行数
|
|
8743
|
+
* @param to 替换成的行
|
|
8744
|
+
* @returns 新全文
|
|
8745
|
+
*/
|
|
8746
|
+
function spliceAt(lines, start, count, to) {
|
|
8747
|
+
return [
|
|
8748
|
+
...lines.slice(0, start),
|
|
8749
|
+
...to,
|
|
8750
|
+
...lines.slice(start + count)
|
|
8751
|
+
].join("\n");
|
|
8752
|
+
}
|
|
8753
|
+
/**
|
|
8754
|
+
* @author ddj 2026年10月29号
|
|
8755
|
+
* @description: 唯一定位一段行;出现多处命中即视为不可定位。
|
|
8756
|
+
* @param lines 目标行集合
|
|
8757
|
+
* @param pattern 待定位行
|
|
8758
|
+
* @returns 唯一偏移或 null
|
|
8759
|
+
*/
|
|
8760
|
+
function locateSpan(lines, pattern) {
|
|
8761
|
+
if (pattern.length === 0) return null;
|
|
8762
|
+
let at = -1;
|
|
8763
|
+
for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
|
|
8764
|
+
if (!pattern.every((text, index) => lines[offset + index] === text)) continue;
|
|
8765
|
+
if (at >= 0) return null;
|
|
8766
|
+
at = offset;
|
|
8767
|
+
}
|
|
8768
|
+
return at >= 0 ? at : null;
|
|
8769
|
+
}
|
|
8770
|
+
/**
|
|
8771
|
+
* @author ddj 2026年10月29号
|
|
8772
|
+
* @description: 生成带上下文的标准 hunk 列表。
|
|
8773
|
+
* @param oldLines 基线行
|
|
8774
|
+
* @param newLines 当前行
|
|
8775
|
+
* @returns hunk 列表
|
|
8776
|
+
*/
|
|
8777
|
+
function buildHunks(oldLines, newLines) {
|
|
8778
|
+
const ops = diffLines(oldLines, newLines);
|
|
8779
|
+
const hunks = [];
|
|
8780
|
+
let index = 0;
|
|
8781
|
+
let oldLine = 0;
|
|
8782
|
+
let newLine = 0;
|
|
8783
|
+
while (index < ops.length) {
|
|
8784
|
+
if (ops[index].kind === " ") {
|
|
8785
|
+
oldLine++;
|
|
8786
|
+
newLine++;
|
|
8787
|
+
index++;
|
|
8788
|
+
continue;
|
|
8789
|
+
}
|
|
8790
|
+
const head = ops.slice(Math.max(0, index - CONTEXT), index);
|
|
8791
|
+
const collected = [];
|
|
8792
|
+
let probe = index;
|
|
8793
|
+
while (probe < ops.length) {
|
|
8794
|
+
const op = ops[probe];
|
|
8795
|
+
if (op.kind !== " ") {
|
|
8796
|
+
collected.push(op);
|
|
8797
|
+
probe++;
|
|
8798
|
+
continue;
|
|
8799
|
+
}
|
|
8800
|
+
if (ops.slice(probe, probe + 6 + 1).some((item) => item.kind !== " ")) {
|
|
8801
|
+
collected.push(op);
|
|
8802
|
+
probe++;
|
|
8803
|
+
continue;
|
|
8804
|
+
}
|
|
8805
|
+
break;
|
|
8806
|
+
}
|
|
8807
|
+
const tail = ops.slice(probe, probe + CONTEXT);
|
|
8808
|
+
const oldStartLine = oldLine - head.length + 1;
|
|
8809
|
+
const newStartLine = newLine - head.length + 1;
|
|
8810
|
+
hunks.push(makeHunk([
|
|
8811
|
+
...head,
|
|
8812
|
+
...collected,
|
|
8813
|
+
...tail
|
|
8814
|
+
], oldStartLine, newStartLine));
|
|
8815
|
+
for (const op of collected) {
|
|
8816
|
+
if (op.kind !== "+") oldLine++;
|
|
8817
|
+
if (op.kind !== "-") newLine++;
|
|
8818
|
+
}
|
|
8819
|
+
oldLine += head.length + tail.length;
|
|
8820
|
+
newLine += head.length + tail.length;
|
|
8821
|
+
index = probe + tail.length;
|
|
8822
|
+
}
|
|
8823
|
+
return hunks;
|
|
8824
|
+
}
|
|
8825
|
+
/**
|
|
8826
|
+
* @author ddj 2026年10月29号
|
|
8827
|
+
* @description: 由一段操作装配 hunk。
|
|
8828
|
+
* @param ops 该 hunk 的操作
|
|
8829
|
+
* @param oldStart 基线起始行
|
|
8830
|
+
* @param newStart 当前起始行
|
|
8831
|
+
* @returns hunk
|
|
8832
|
+
*/
|
|
8833
|
+
function makeHunk(ops, oldStart, newStart) {
|
|
8834
|
+
return {
|
|
8835
|
+
oldStart,
|
|
8836
|
+
oldLines: ops.filter((op) => op.kind !== "+").length,
|
|
8837
|
+
newStart,
|
|
8838
|
+
newLines: ops.filter((op) => op.kind !== "-").length,
|
|
8839
|
+
lines: ops.map((op) => op.kind + op.text)
|
|
8840
|
+
};
|
|
8841
|
+
}
|
|
8842
|
+
/**
|
|
8843
|
+
* @author ddj 2026年10月29号
|
|
8844
|
+
* @description: 行级差异:先剪公共前后缀,剩余部分在限额内做 LCS,超限按整段替换。
|
|
8845
|
+
* @param oldLines 基线行
|
|
8846
|
+
* @param newLines 当前行
|
|
8847
|
+
* @returns 顺序完整的操作列表
|
|
8848
|
+
*/
|
|
8849
|
+
function diffLines(oldLines, newLines) {
|
|
8850
|
+
let head = 0;
|
|
8851
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head++;
|
|
8852
|
+
let tail = 0;
|
|
8853
|
+
while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail++;
|
|
8854
|
+
const left = oldLines.slice(head, oldLines.length - tail);
|
|
8855
|
+
const right = newLines.slice(head, newLines.length - tail);
|
|
8856
|
+
const middle = left.length * right.length > CELL_LIMIT ? coarseOps(left, right) : lcsOps(left, right);
|
|
8857
|
+
return [
|
|
8858
|
+
...oldLines.slice(0, head).map((text) => ({
|
|
8859
|
+
kind: " ",
|
|
8860
|
+
text
|
|
8861
|
+
})),
|
|
8862
|
+
...middle,
|
|
8863
|
+
...oldLines.slice(oldLines.length - tail).map((text) => ({
|
|
8864
|
+
kind: " ",
|
|
8865
|
+
text
|
|
8866
|
+
}))
|
|
8867
|
+
];
|
|
8868
|
+
}
|
|
8869
|
+
/**
|
|
8870
|
+
* @author ddj 2026年10月29号
|
|
8871
|
+
* @description: 超限时的退化口径:整段删除再整段新增,绝不谎报逐行对应。
|
|
8872
|
+
* @param left 基线剩余行
|
|
8873
|
+
* @param right 当前剩余行
|
|
8874
|
+
* @returns 操作列表
|
|
8875
|
+
*/
|
|
8876
|
+
function coarseOps(left, right) {
|
|
8877
|
+
return [...left.map((text) => ({
|
|
8878
|
+
kind: "-",
|
|
8879
|
+
text
|
|
8880
|
+
})), ...right.map((text) => ({
|
|
8881
|
+
kind: "+",
|
|
8882
|
+
text
|
|
8883
|
+
}))];
|
|
8884
|
+
}
|
|
8885
|
+
/**
|
|
8886
|
+
* @author ddj 2026年10月29号
|
|
8887
|
+
* @description: 限额内的 LCS 行差异。
|
|
8888
|
+
* @param left 基线剩余行
|
|
8889
|
+
* @param right 当前剩余行
|
|
8890
|
+
* @returns 操作列表
|
|
8891
|
+
*/
|
|
8892
|
+
function lcsOps(left, right) {
|
|
8893
|
+
const rows = left.length + 1;
|
|
8894
|
+
const cols = right.length + 1;
|
|
8895
|
+
const table = new Uint32Array(rows * cols);
|
|
8896
|
+
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]);
|
|
8897
|
+
const ops = [];
|
|
8898
|
+
let i = 0;
|
|
8899
|
+
let j = 0;
|
|
8900
|
+
while (i < left.length && j < right.length) {
|
|
8901
|
+
if (left[i] === right[j]) {
|
|
8902
|
+
ops.push({
|
|
8903
|
+
kind: " ",
|
|
8904
|
+
text: left[i]
|
|
8905
|
+
});
|
|
8906
|
+
i++;
|
|
8907
|
+
j++;
|
|
8908
|
+
continue;
|
|
8909
|
+
}
|
|
8910
|
+
if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
|
|
8911
|
+
ops.push({
|
|
8912
|
+
kind: "-",
|
|
8913
|
+
text: left[i]
|
|
8914
|
+
});
|
|
8915
|
+
i++;
|
|
8916
|
+
} else {
|
|
8917
|
+
ops.push({
|
|
8918
|
+
kind: "+",
|
|
8919
|
+
text: right[j]
|
|
8920
|
+
});
|
|
8921
|
+
j++;
|
|
8922
|
+
}
|
|
8923
|
+
}
|
|
8924
|
+
while (i < left.length) ops.push({
|
|
8925
|
+
kind: "-",
|
|
8926
|
+
text: left[i++]
|
|
8927
|
+
});
|
|
8928
|
+
while (j < right.length) ops.push({
|
|
8929
|
+
kind: "+",
|
|
8930
|
+
text: right[j++]
|
|
8931
|
+
});
|
|
8932
|
+
return ops;
|
|
8933
|
+
}
|
|
8934
|
+
/**
|
|
8935
|
+
* @author ddj 2026年10月29号
|
|
8936
|
+
* @description: 判断这次差异是否触发了大文件退化口径。
|
|
8937
|
+
* @param oldText 基线全文
|
|
8938
|
+
* @param newText 当前全文
|
|
8939
|
+
* @returns 是否粗粒度
|
|
8940
|
+
*/
|
|
8941
|
+
function isCoarse(oldText, newText) {
|
|
8942
|
+
return logicalLines(oldText).length * logicalLines(newText).length > CELL_LIMIT;
|
|
8943
|
+
}
|
|
8944
|
+
//#endregion
|
|
8945
|
+
//#region src/pending/kernel.ts
|
|
8946
|
+
/**
|
|
8947
|
+
* @author ddj 2026年10月29号
|
|
8948
|
+
* @description: 严格比较两侧文本:不做 BOM/行尾归一化,归一化只允许用于展示。
|
|
8949
|
+
* @param left 一侧全文
|
|
8950
|
+
* @param right 另一侧全文
|
|
8951
|
+
* @returns 两者是否逐码元相等
|
|
8952
|
+
*/
|
|
8953
|
+
function sameEntryText(left, right) {
|
|
8954
|
+
return left === right;
|
|
8955
|
+
}
|
|
8956
|
+
/**
|
|
8957
|
+
* @author ddj 2026年10月29号
|
|
8958
|
+
* @description: 净零判定:基线等于当前内容即为已解决,但条目仍留在列表等待移除。
|
|
8959
|
+
* @param entry 待审条目
|
|
8960
|
+
* @returns 是否已无净差异
|
|
8961
|
+
*/
|
|
8962
|
+
function isSettled(entry) {
|
|
8963
|
+
return sameEntryText(entry.oldText, entry.newText);
|
|
8964
|
+
}
|
|
8965
|
+
/**
|
|
8966
|
+
* @author ddj 2026年10月29号
|
|
8967
|
+
* @description: 合并同一身份的两条条目:基线取较早来源,最新内容取较晚来源,会话取并集。
|
|
8968
|
+
* @param earlier 较早记录
|
|
8969
|
+
* @param later 较晚记录
|
|
8970
|
+
* @returns 合并后的新条目
|
|
8971
|
+
*/
|
|
8972
|
+
function mergeEntries(earlier, later) {
|
|
8973
|
+
const merged = {
|
|
8974
|
+
...later,
|
|
8975
|
+
updatedAt: later.updatedAt,
|
|
8976
|
+
sessionIds: unionSessions(earlier, later)
|
|
8977
|
+
};
|
|
8978
|
+
if (later.lineage === void 0 && earlier.lineage !== void 0) merged.lineage = earlier.lineage;
|
|
8979
|
+
if (earlier.earlierVersion === "none" && later.earlierVersion === "none") {
|
|
8980
|
+
merged.earlierVersion = "none";
|
|
8981
|
+
merged.oldText = earlier.oldText;
|
|
8982
|
+
return merged;
|
|
8983
|
+
}
|
|
8984
|
+
merged.earlierVersion = "file";
|
|
8985
|
+
merged.oldText = earlier.earlierVersion === "none" ? earlier.newText : earlier.oldText;
|
|
8986
|
+
return merged;
|
|
8987
|
+
}
|
|
8988
|
+
/**
|
|
8989
|
+
* @author ddj 2026年10月29号
|
|
8990
|
+
* @description: 由新建入参构造条目,并把首个会话写进会话集合。
|
|
8991
|
+
* @param input 新建入参
|
|
8992
|
+
* @returns 新条目
|
|
8993
|
+
*/
|
|
8994
|
+
function entryFromNew(input) {
|
|
8995
|
+
const entry = {
|
|
8996
|
+
id: input.id,
|
|
8997
|
+
path: input.path,
|
|
8998
|
+
earlierVersion: input.earlierVersion,
|
|
8999
|
+
oldText: input.oldText,
|
|
9000
|
+
newText: input.newText,
|
|
9001
|
+
updatedAt: input.updatedAt,
|
|
9002
|
+
sessionId: input.sessionId,
|
|
9003
|
+
sessionIds: [input.sessionId]
|
|
9004
|
+
};
|
|
9005
|
+
if (input.lineage !== void 0) entry.lineage = input.lineage;
|
|
9006
|
+
return entry;
|
|
9007
|
+
}
|
|
9008
|
+
/**
|
|
9009
|
+
* @author ddj 2026年10月29号
|
|
9010
|
+
* @description: 应用一次决定带来的局部推进:块级 Keep 推进基线,块级 Revert 推进当前内容。
|
|
9011
|
+
* @param entry 待审条目
|
|
9012
|
+
* @param patch 要推进的一侧
|
|
9013
|
+
* @param now 本次更新时间
|
|
9014
|
+
* @returns 新条目;基线被推进时"创建"身份结束
|
|
9015
|
+
*/
|
|
9016
|
+
function applyUpdate(entry, patch, now) {
|
|
9017
|
+
const next = {
|
|
9018
|
+
...entry,
|
|
9019
|
+
updatedAt: now
|
|
9020
|
+
};
|
|
9021
|
+
if (patch.oldText !== void 0) {
|
|
9022
|
+
next.oldText = patch.oldText;
|
|
9023
|
+
next.earlierVersion = "file";
|
|
9024
|
+
}
|
|
9025
|
+
if (patch.newText !== void 0) next.newText = patch.newText;
|
|
9026
|
+
delete next.unseen;
|
|
9027
|
+
return next;
|
|
9028
|
+
}
|
|
9029
|
+
/**
|
|
9030
|
+
* @author ddj 2026年10月29号
|
|
9031
|
+
* @description: 合并两条条目的会话集合并去重,且保证首会话在列。
|
|
9032
|
+
* @param earlier 较早记录
|
|
9033
|
+
* @param later 较晚记录
|
|
9034
|
+
* @returns 去重后的会话集合
|
|
9035
|
+
*/
|
|
9036
|
+
function unionSessions(earlier, later) {
|
|
9037
|
+
const merged = [];
|
|
9038
|
+
for (const sessionId of [
|
|
9039
|
+
earlier.sessionId,
|
|
9040
|
+
...earlier.sessionIds,
|
|
9041
|
+
later.sessionId,
|
|
9042
|
+
...later.sessionIds
|
|
9043
|
+
]) if (sessionId && !merged.includes(sessionId)) merged.push(sessionId);
|
|
9044
|
+
return merged;
|
|
9045
|
+
}
|
|
9046
|
+
//#endregion
|
|
9047
|
+
//#region src/pending/decide.ts
|
|
9048
|
+
/**
|
|
9049
|
+
* @author ddj 2026年10月29号
|
|
9050
|
+
* @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
|
|
9051
|
+
* @param input 判定输入
|
|
9052
|
+
* @returns 判定结果
|
|
9053
|
+
*/
|
|
9054
|
+
async function decide(input) {
|
|
9055
|
+
const failed = (reason) => ({
|
|
9056
|
+
ok: false,
|
|
9057
|
+
reason,
|
|
9058
|
+
entry: input.entry,
|
|
9059
|
+
flags: input.flags,
|
|
9060
|
+
blocks: input.blocks,
|
|
9061
|
+
wrote: false
|
|
9062
|
+
});
|
|
9063
|
+
if (input.request.revision !== input.flags.revision) return failed("revision-mismatch");
|
|
9064
|
+
if (input.flags.oversized) return failed("oversized");
|
|
9065
|
+
if (input.entry.oldText === input.entry.newText) return failed("settled");
|
|
9066
|
+
const targets = pickBlocks(reviewBlocks(textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)), input);
|
|
9067
|
+
if (targets === null) return failed("unknown-block");
|
|
9068
|
+
if (input.request.scope !== "file" && input.request.scope !== "all" && targets.length === 0) return failed("no-block");
|
|
9069
|
+
const isKeep = input.request.decision === "accepted";
|
|
9070
|
+
const whole = input.request.scope === "file" || input.request.scope === "all";
|
|
9071
|
+
return isKeep ? keep(input, targets, whole) : revert(input, targets, whole);
|
|
9072
|
+
}
|
|
9073
|
+
/**
|
|
9074
|
+
* @author ddj 2026年10月29号
|
|
9075
|
+
* @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
|
|
9076
|
+
* @param input 判定输入
|
|
9077
|
+
* @param targets 目标块(整文件时为空)
|
|
9078
|
+
* @param whole 是否整文件
|
|
9079
|
+
* @returns 判定结果
|
|
9080
|
+
*/
|
|
9081
|
+
function keep(input, targets, whole) {
|
|
9082
|
+
if (whole) return {
|
|
9083
|
+
ok: true,
|
|
9084
|
+
reason: "",
|
|
9085
|
+
entry: applyUpdate(input.entry, { oldText: input.entry.newText }, input.now),
|
|
9086
|
+
wrote: false,
|
|
9087
|
+
flags: {
|
|
9088
|
+
...input.flags,
|
|
9089
|
+
revision: input.flags.revision + 1
|
|
9090
|
+
},
|
|
9091
|
+
blocks: markAll(input.blocks, targets, "accepted")
|
|
9092
|
+
};
|
|
9093
|
+
let oldText = input.entry.oldText;
|
|
9094
|
+
for (const block of targets) {
|
|
9095
|
+
const next = keepBlock(oldText, block);
|
|
9096
|
+
if (next === null) return {
|
|
9097
|
+
ok: false,
|
|
9098
|
+
reason: "ambiguous-block",
|
|
9099
|
+
entry: input.entry,
|
|
9100
|
+
flags: input.flags,
|
|
9101
|
+
blocks: input.blocks,
|
|
9102
|
+
wrote: false
|
|
9103
|
+
};
|
|
9104
|
+
oldText = next;
|
|
9105
|
+
}
|
|
9106
|
+
return {
|
|
9107
|
+
ok: true,
|
|
9108
|
+
reason: "",
|
|
9109
|
+
entry: applyUpdate(input.entry, { oldText }, input.now),
|
|
9110
|
+
wrote: false,
|
|
9111
|
+
flags: {
|
|
9112
|
+
...input.flags,
|
|
9113
|
+
revision: input.flags.revision + 1
|
|
9114
|
+
},
|
|
9115
|
+
blocks: markAll(input.blocks, targets, "accepted")
|
|
9116
|
+
};
|
|
9117
|
+
}
|
|
9118
|
+
/**
|
|
9119
|
+
* @author ddj 2026年10月29号
|
|
9120
|
+
* @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
|
|
9121
|
+
* @param input 判定输入
|
|
9122
|
+
* @param targets 目标块(整文件时为空)
|
|
9123
|
+
* @param whole 是否整文件
|
|
9124
|
+
* @returns 判定结果
|
|
9125
|
+
*/
|
|
9126
|
+
async function revert(input, targets, whole) {
|
|
9127
|
+
const failed = (reason) => ({
|
|
9128
|
+
ok: false,
|
|
9129
|
+
reason,
|
|
9130
|
+
entry: input.entry,
|
|
9131
|
+
flags: input.flags,
|
|
9132
|
+
blocks: input.blocks,
|
|
9133
|
+
wrote: false
|
|
9134
|
+
});
|
|
9135
|
+
const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets);
|
|
9136
|
+
if (next === void 0) return failed("ambiguous-block");
|
|
9137
|
+
const written = await input.write(next);
|
|
9138
|
+
if (!written.ok) return failed(written.reason);
|
|
9139
|
+
return {
|
|
9140
|
+
ok: true,
|
|
9141
|
+
reason: "",
|
|
9142
|
+
entry: applyUpdate(input.entry, { newText: written.content ?? "" }, input.now),
|
|
9143
|
+
wrote: true,
|
|
9144
|
+
flags: {
|
|
9145
|
+
...input.flags,
|
|
9146
|
+
revision: input.flags.revision + 1,
|
|
9147
|
+
missing: written.content === null
|
|
9148
|
+
},
|
|
9149
|
+
blocks: markAll(input.blocks, targets, "rejected")
|
|
9150
|
+
};
|
|
9151
|
+
}
|
|
9152
|
+
/**
|
|
9153
|
+
* @author ddj 2026年10月29号
|
|
9154
|
+
* @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
|
|
9155
|
+
* @param entry 待审条目
|
|
9156
|
+
* @returns 目标内容,null 表示删除
|
|
9157
|
+
*/
|
|
9158
|
+
function wholeRevert(entry) {
|
|
9159
|
+
return entry.earlierVersion === "none" ? null : entry.oldText;
|
|
9160
|
+
}
|
|
9161
|
+
/**
|
|
9162
|
+
* @author ddj 2026年10月29号
|
|
9163
|
+
* @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
|
|
9164
|
+
* @param entry 待审条目
|
|
9165
|
+
* @param targets 目标块
|
|
9166
|
+
* @returns 新内容或 undefined
|
|
9167
|
+
*/
|
|
9168
|
+
function blockRevert(entry, targets) {
|
|
9169
|
+
let newText = entry.newText;
|
|
9170
|
+
for (const block of targets) {
|
|
9171
|
+
const next = revertBlock(newText, block);
|
|
9172
|
+
if (next === null) return void 0;
|
|
9173
|
+
newText = next;
|
|
9174
|
+
}
|
|
9175
|
+
return newText;
|
|
9176
|
+
}
|
|
9177
|
+
/**
|
|
9178
|
+
* @author ddj 2026年10月29号
|
|
9179
|
+
* @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
|
|
9180
|
+
* @param blocks 当前差异块
|
|
9181
|
+
* @param input 判定输入
|
|
9182
|
+
* @returns 目标块,未知块 ID 时为 null
|
|
9183
|
+
*/
|
|
9184
|
+
function pickBlocks(blocks, input) {
|
|
9185
|
+
const scope = input.request.scope;
|
|
9186
|
+
if (scope === "file" || scope === "all") return [];
|
|
9187
|
+
if (scope === "block") {
|
|
9188
|
+
const block = blocks.find((item) => item.id === input.request.blockId);
|
|
9189
|
+
return block ? [block] : null;
|
|
9190
|
+
}
|
|
9191
|
+
const start = input.request.startLine ?? 0;
|
|
9192
|
+
const end = input.request.endLine ?? start;
|
|
9193
|
+
return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start);
|
|
9194
|
+
}
|
|
9195
|
+
/**
|
|
9196
|
+
* @author ddj 2026年10月29号
|
|
9197
|
+
* @description: 把目标块标记为已决定;整文件时把全部块一起标记。
|
|
9198
|
+
* @param states 已存块决定
|
|
9199
|
+
* @param targets 目标块
|
|
9200
|
+
* @param decision 决定
|
|
9201
|
+
* @returns 新的块决定列表
|
|
9202
|
+
*/
|
|
9203
|
+
function markAll(states, targets, decision) {
|
|
9204
|
+
const next = states.slice();
|
|
9205
|
+
for (const block of targets) {
|
|
9206
|
+
const at = next.findIndex((state) => state.id === block.id);
|
|
9207
|
+
if (at >= 0) next[at] = {
|
|
9208
|
+
id: block.id,
|
|
9209
|
+
decision
|
|
9210
|
+
};
|
|
9211
|
+
else next.push({
|
|
9212
|
+
id: block.id,
|
|
9213
|
+
decision
|
|
9214
|
+
});
|
|
9215
|
+
}
|
|
9216
|
+
return next;
|
|
9217
|
+
}
|
|
9218
|
+
/**
|
|
9219
|
+
* @author ddj 2026年10月29号
|
|
9220
|
+
* @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
|
|
9221
|
+
* @param entry 待审条目
|
|
9222
|
+
* @param flags 持久标记
|
|
9223
|
+
* @returns 能力与原因
|
|
9224
|
+
*/
|
|
9225
|
+
function capabilityOf(entry, flags) {
|
|
9226
|
+
if (isSettled(entry)) return {
|
|
9227
|
+
keep: false,
|
|
9228
|
+
revert: false,
|
|
9229
|
+
reason: "settled"
|
|
9230
|
+
};
|
|
9231
|
+
if (flags.oversized) return {
|
|
9232
|
+
keep: false,
|
|
9233
|
+
revert: false,
|
|
9234
|
+
reason: "oversized"
|
|
9235
|
+
};
|
|
9236
|
+
if (entry.earlierVersion === "none" && entry.newText.length === 0) return {
|
|
9237
|
+
keep: true,
|
|
9238
|
+
revert: false,
|
|
9239
|
+
reason: "empty-create"
|
|
9240
|
+
};
|
|
9241
|
+
return {
|
|
9242
|
+
keep: true,
|
|
9243
|
+
revert: true,
|
|
9244
|
+
reason: ""
|
|
9245
|
+
};
|
|
9246
|
+
}
|
|
9247
|
+
/**
|
|
9248
|
+
* @author ddj 2026年10月29号
|
|
9249
|
+
* @description: 供详情接口复用的差异构造。
|
|
9250
|
+
* @param entry 待审条目
|
|
9251
|
+
* @returns 官方差异
|
|
9252
|
+
*/
|
|
9253
|
+
function diffOf(entry) {
|
|
9254
|
+
return textDiff(entry.path, entry.path, entry.oldText, entry.newText);
|
|
9255
|
+
}
|
|
9256
|
+
//#endregion
|
|
9257
|
+
//#region src/pending/persist.ts
|
|
9258
|
+
/** 待审库版本号,写进 metadata 便于日后迁移。v3 给评论表加了引文与上下文列。 */
|
|
9259
|
+
const SCHEMA_VERSION = "3";
|
|
9260
|
+
/**
|
|
9261
|
+
* @author ddj 2026年10月29号
|
|
9262
|
+
* @description: 打开工作区库并确保待审表存在;DDL 与版本标记在同一事务里提交。
|
|
9263
|
+
* @param cwd 已迁移工作区
|
|
9264
|
+
* @returns 可读写的连接
|
|
9265
|
+
* @throws 未完成活跃库迁移时拒绝
|
|
9266
|
+
*/
|
|
9267
|
+
function pendingDb(cwd) {
|
|
9268
|
+
if (!activeReady(cwd)) throw new Error("待审投影要求先完成活跃库迁移");
|
|
9269
|
+
const db = dbFor(cwd);
|
|
9270
|
+
if (db.prepare("SELECT value FROM metadata WHERE name='pending_schema'").get()?.value === SCHEMA_VERSION) return db;
|
|
9271
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9272
|
+
try {
|
|
9273
|
+
db.exec(DDL);
|
|
9274
|
+
migrateHistory(db);
|
|
9275
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('pending_schema',?) ON CONFLICT(name) DO UPDATE SET value=excluded.value").run(SCHEMA_VERSION);
|
|
9276
|
+
db.exec("COMMIT");
|
|
9277
|
+
} catch (error) {
|
|
9278
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9279
|
+
throw error;
|
|
9280
|
+
}
|
|
9281
|
+
return db;
|
|
9282
|
+
}
|
|
9283
|
+
/** 待审表结构:条目、块决定、幂等操作、撤销栈。 */
|
|
9284
|
+
const DDL = `
|
|
9285
|
+
CREATE TABLE IF NOT EXISTS pending_entries (
|
|
9286
|
+
id TEXT PRIMARY KEY, path TEXT NOT NULL, earlier TEXT NOT NULL,
|
|
9287
|
+
old_text TEXT NOT NULL, new_text TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
|
9288
|
+
unseen INTEGER NOT NULL DEFAULT 0, missing INTEGER NOT NULL DEFAULT 0,
|
|
9289
|
+
diverged INTEGER NOT NULL DEFAULT 0, oversized INTEGER NOT NULL DEFAULT 0,
|
|
9290
|
+
revision INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
|
|
9291
|
+
session_ids TEXT NOT NULL, lineage TEXT);
|
|
9292
|
+
CREATE TABLE IF NOT EXISTS pending_blocks (
|
|
9293
|
+
entry_id TEXT NOT NULL, block_id TEXT NOT NULL, decision TEXT NOT NULL,
|
|
9294
|
+
PRIMARY KEY(entry_id,block_id));
|
|
9295
|
+
CREATE TABLE IF NOT EXISTS pending_ops (
|
|
9296
|
+
operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL,
|
|
9297
|
+
result TEXT, undo TEXT);
|
|
9298
|
+
CREATE TABLE IF NOT EXISTS pending_history (
|
|
9299
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, entry_id TEXT,
|
|
9300
|
+
snapshot TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
9301
|
+
state TEXT NOT NULL DEFAULT 'applied');
|
|
9302
|
+
`;
|
|
9303
|
+
/** v1 历史表补 state 列(rebuild 成本高于一次 ALTER)。 */
|
|
9304
|
+
function migrateHistory(db) {
|
|
9305
|
+
if (db.prepare("PRAGMA table_info(pending_history)").all().some((column) => column.name === "state")) return;
|
|
9306
|
+
db.exec("ALTER TABLE pending_history ADD COLUMN state TEXT NOT NULL DEFAULT 'applied'");
|
|
9307
|
+
}
|
|
9308
|
+
/**
|
|
9309
|
+
* @author ddj 2026年10月29号
|
|
9310
|
+
* @description: 读出全部待审条目,按更新时间升序。
|
|
9311
|
+
* @param cwd 工作区
|
|
9312
|
+
* @returns 条目与标记
|
|
9313
|
+
*/
|
|
9314
|
+
function loadEntries(cwd) {
|
|
9315
|
+
return pendingDb(cwd).prepare("SELECT * FROM pending_entries ORDER BY updated_at ASC").all().map((row) => ({
|
|
9316
|
+
entry: entryOfRow(row),
|
|
9317
|
+
flags: flagsOfRow(row)
|
|
9318
|
+
}));
|
|
9319
|
+
}
|
|
9320
|
+
/**
|
|
9321
|
+
* @author ddj 2026年10月29号
|
|
9322
|
+
* @description: 写入或更新一条条目(含标记),单条原子完成。
|
|
9323
|
+
* @param cwd 工作区
|
|
9324
|
+
* @param stored 条目与标记
|
|
9325
|
+
* @returns 无
|
|
9326
|
+
*/
|
|
9327
|
+
function saveEntry(cwd, stored) {
|
|
9328
|
+
const { entry, flags } = stored;
|
|
9329
|
+
pendingDb(cwd).prepare(`INSERT INTO pending_entries
|
|
9330
|
+
(id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
|
|
9331
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
9332
|
+
ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
|
|
9333
|
+
new_text=excluded.new_text,updated_at=excluded.updated_at,unseen=excluded.unseen,missing=excluded.missing,
|
|
9334
|
+
diverged=excluded.diverged,oversized=excluded.oversized,revision=excluded.revision,
|
|
9335
|
+
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);
|
|
9336
|
+
}
|
|
9337
|
+
/**
|
|
9338
|
+
* @author ddj 2026年10月29号
|
|
9339
|
+
* @description: 删除条目及其块决定,用于"已解决条目移除"。
|
|
9340
|
+
* @param cwd 工作区
|
|
9341
|
+
* @param id 条目身份
|
|
9342
|
+
* @returns 无
|
|
9343
|
+
*/
|
|
9344
|
+
function dropEntry(cwd, id) {
|
|
9345
|
+
const db = pendingDb(cwd);
|
|
9346
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9347
|
+
try {
|
|
9348
|
+
db.prepare("DELETE FROM pending_entries WHERE id=?").run(id);
|
|
9349
|
+
db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(id);
|
|
9350
|
+
db.exec("COMMIT");
|
|
9351
|
+
} catch (error) {
|
|
9352
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9353
|
+
throw error;
|
|
9354
|
+
}
|
|
9355
|
+
}
|
|
9356
|
+
/**
|
|
9357
|
+
* @author ddj 2026年10月29号
|
|
9358
|
+
* @description: 覆盖某条目的块决定集合。
|
|
9359
|
+
* @param cwd 工作区
|
|
9360
|
+
* @param entryId 条目身份
|
|
9361
|
+
* @param blocks 块决定
|
|
9362
|
+
* @returns 无
|
|
9363
|
+
*/
|
|
9364
|
+
function saveBlocks(cwd, entryId, blocks) {
|
|
9365
|
+
const db = pendingDb(cwd);
|
|
9366
|
+
db.exec("BEGIN IMMEDIATE");
|
|
9367
|
+
try {
|
|
9368
|
+
db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(entryId);
|
|
9369
|
+
const insert = db.prepare("INSERT INTO pending_blocks VALUES(?,?,?)");
|
|
9370
|
+
for (const block of blocks) insert.run(entryId, block.id, block.decision);
|
|
9371
|
+
db.exec("COMMIT");
|
|
9372
|
+
} catch (error) {
|
|
9373
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
9374
|
+
throw error;
|
|
9375
|
+
}
|
|
9376
|
+
}
|
|
9377
|
+
/**
|
|
9378
|
+
* @author ddj 2026年10月29号
|
|
9379
|
+
* @description: 读取幂等操作记录;同 operationId 的重复请求必须复用结果。
|
|
9380
|
+
* @param cwd 工作区
|
|
9381
|
+
* @param operationId 操作身份
|
|
9382
|
+
* @returns 请求原文、状态与结果
|
|
9383
|
+
*/
|
|
9384
|
+
function readOp(cwd, operationId) {
|
|
9385
|
+
const row = pendingDb(cwd).prepare("SELECT request,state,result FROM pending_ops WHERE operation_id=?").get(operationId);
|
|
9386
|
+
return row ? {
|
|
9387
|
+
request: String(row.request),
|
|
9388
|
+
state: String(row.state),
|
|
9389
|
+
result: row.result === null ? null : String(row.result)
|
|
9390
|
+
} : void 0;
|
|
9391
|
+
}
|
|
9392
|
+
/**
|
|
9393
|
+
* @author ddj 2026年10月29号
|
|
9394
|
+
* @description: 记录一次操作的阶段与撤销快照。
|
|
9395
|
+
* @param cwd 工作区
|
|
9396
|
+
* @param operationId 操作身份
|
|
9397
|
+
* @param request 请求原文
|
|
9398
|
+
* @param state prepared / committed
|
|
9399
|
+
* @param payload 结果与撤销快照
|
|
9400
|
+
* @returns 无
|
|
9401
|
+
*/
|
|
9402
|
+
function writeOp(cwd, operationId, request, state, payload = {}) {
|
|
9403
|
+
pendingDb(cwd).prepare(`INSERT INTO pending_ops VALUES(?,?,?,?,?)
|
|
9404
|
+
ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,
|
|
9405
|
+
result=COALESCE(excluded.result,pending_ops.result), undo=COALESCE(excluded.undo,pending_ops.undo)`).run(operationId, request, state, payload.result ?? null, payload.undo ?? null);
|
|
9406
|
+
}
|
|
9407
|
+
/**
|
|
9408
|
+
* @author ddj 2026年10月29号
|
|
9409
|
+
* @description: 追加一条撤销栈记录,并截断重做分支(新决定之后旧的"已撤销"记录不能再重做)。
|
|
9410
|
+
* @param cwd 工作区
|
|
9411
|
+
* @param kind 操作种类
|
|
9412
|
+
* @param entryId 涉及的条目
|
|
9413
|
+
* @param snapshot 可重放快照
|
|
9414
|
+
* @param now 时间
|
|
9415
|
+
* @returns 无
|
|
9416
|
+
*/
|
|
9417
|
+
function pushHistory(cwd, kind, entryId, snapshot, now) {
|
|
9418
|
+
const db = pendingDb(cwd);
|
|
9419
|
+
db.prepare("DELETE FROM pending_history WHERE state='undone'").run();
|
|
9420
|
+
db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')").run(kind, entryId, snapshot, now);
|
|
9421
|
+
}
|
|
9422
|
+
/**
|
|
9423
|
+
* @author ddj 2026年10月30号
|
|
9424
|
+
* @description: 取最近一条仍生效的历史记录,撤销就作用在它上面。
|
|
9425
|
+
* @param cwd 工作区
|
|
9426
|
+
* @returns 记录或 undefined
|
|
9427
|
+
*/
|
|
9428
|
+
function latestApplied(cwd) {
|
|
9429
|
+
const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='applied' ORDER BY id DESC LIMIT 1").get();
|
|
9430
|
+
return row ? rowOfHistory(row) : void 0;
|
|
9431
|
+
}
|
|
9432
|
+
/**
|
|
9433
|
+
* @author ddj 2026年10月30号
|
|
9434
|
+
* @description: 取最早一条被撤销的记录,重做按"撤销的逆序"重新生效。
|
|
9435
|
+
* @param cwd 工作区
|
|
9436
|
+
* @returns 记录或 undefined
|
|
9437
|
+
*/
|
|
9438
|
+
function earliestUndone(cwd) {
|
|
9439
|
+
const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='undone' ORDER BY id ASC LIMIT 1").get();
|
|
9440
|
+
return row ? rowOfHistory(row) : void 0;
|
|
9441
|
+
}
|
|
9442
|
+
/**
|
|
9443
|
+
* @author ddj 2026年10月30号
|
|
9444
|
+
* @description: 翻转一条历史记录的生效状态。
|
|
9445
|
+
* @param cwd 工作区
|
|
9446
|
+
* @param id 记录主键
|
|
9447
|
+
* @param state applied / undone
|
|
9448
|
+
* @returns 无
|
|
9449
|
+
*/
|
|
9450
|
+
function setHistoryState(cwd, id, state) {
|
|
9451
|
+
pendingDb(cwd).prepare("UPDATE pending_history SET state=? WHERE id=?").run(state, id);
|
|
9452
|
+
}
|
|
9453
|
+
/**
|
|
9454
|
+
* @author ddj 2026年10月30号
|
|
9455
|
+
* @description: 把数据库行还原成历史记录。
|
|
9456
|
+
* @param row 数据库行
|
|
9457
|
+
* @returns 记录
|
|
9458
|
+
*/
|
|
9459
|
+
function rowOfHistory(row) {
|
|
9460
|
+
return {
|
|
9461
|
+
id: Number(row.id),
|
|
9462
|
+
kind: String(row.kind),
|
|
9463
|
+
entryId: String(row.entry_id),
|
|
9464
|
+
snapshot: String(row.snapshot)
|
|
9465
|
+
};
|
|
9466
|
+
}
|
|
9467
|
+
/**
|
|
9468
|
+
* @author ddj 2026年10月29号
|
|
9469
|
+
* @description: 由数据库行还原条目。
|
|
9470
|
+
* @param row 数据库行
|
|
9471
|
+
* @returns 条目
|
|
9472
|
+
*/
|
|
9473
|
+
function entryOfRow(row) {
|
|
9474
|
+
const lineage = row.lineage === null ? void 0 : JSON.parse(String(row.lineage));
|
|
9475
|
+
const entry = {
|
|
9476
|
+
id: String(row.id),
|
|
9477
|
+
path: String(row.path),
|
|
9478
|
+
earlierVersion: String(row.earlier) === "none" ? "none" : "file",
|
|
9479
|
+
oldText: String(row.old_text),
|
|
9480
|
+
newText: String(row.new_text),
|
|
9481
|
+
updatedAt: Number(row.updated_at),
|
|
9482
|
+
sessionId: String(row.session_id),
|
|
9483
|
+
sessionIds: JSON.parse(String(row.session_ids))
|
|
9484
|
+
};
|
|
9485
|
+
if (Number(row.unseen) === 1) entry.unseen = true;
|
|
9486
|
+
if (lineage !== void 0) entry.lineage = lineage;
|
|
9487
|
+
return entry;
|
|
9488
|
+
}
|
|
9489
|
+
/**
|
|
9490
|
+
* @author ddj 2026年10月29号
|
|
9491
|
+
* @description: 由数据库行还原标记。
|
|
9492
|
+
* @param row 数据库行
|
|
9493
|
+
* @returns 标记
|
|
9494
|
+
*/
|
|
9495
|
+
function flagsOfRow(row) {
|
|
9496
|
+
return {
|
|
9497
|
+
missing: Number(row.missing) === 1,
|
|
9498
|
+
diverged: Number(row.diverged) === 1,
|
|
9499
|
+
oversized: Number(row.oversized) === 1,
|
|
9500
|
+
revision: Number(row.revision)
|
|
9501
|
+
};
|
|
9502
|
+
}
|
|
9503
|
+
//#endregion
|
|
9504
|
+
//#region src/pending/store.ts
|
|
9505
|
+
/** 统计一次差异的增删行数。 */
|
|
9506
|
+
function countsOf(entry) {
|
|
9507
|
+
const diff = diffOf(entry);
|
|
9508
|
+
if (diff.kind !== "text") return {
|
|
9509
|
+
added: 0,
|
|
9510
|
+
deleted: 0
|
|
9511
|
+
};
|
|
9512
|
+
let added = 0;
|
|
9513
|
+
let deleted = 0;
|
|
9514
|
+
for (const hunk of diff.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added++;
|
|
9515
|
+
else if (line.startsWith("-")) deleted++;
|
|
9516
|
+
return {
|
|
9517
|
+
added,
|
|
9518
|
+
deleted
|
|
9519
|
+
};
|
|
9520
|
+
}
|
|
9521
|
+
/**
|
|
9522
|
+
* @author ddj 2026年10月30号
|
|
9523
|
+
* @description: 由当前累计条目现场推导块身份;块是差异的纯函数,落库的只是决定状态。
|
|
9524
|
+
* @param entry 累计条目
|
|
9525
|
+
* @returns 块状态列表(决定一律 pending,判定推进后由下一次推导自然收窄)
|
|
9526
|
+
*/
|
|
9527
|
+
function blockStatesOf(entry) {
|
|
9528
|
+
const diff = diffOf(entry);
|
|
9529
|
+
if (diff.kind !== "text") return [];
|
|
9530
|
+
return reviewBlocks(diff).map((block) => ({
|
|
9531
|
+
id: block.id,
|
|
9532
|
+
decision: "pending"
|
|
9533
|
+
}));
|
|
9534
|
+
}
|
|
9535
|
+
/**
|
|
9536
|
+
* @author ddj 2026年10月29号
|
|
9537
|
+
* @description: 把一次已确认写入折进累计条目:同一身份只保留一条,基线留最早、内容取最新。
|
|
9538
|
+
* @param stored 已有条目(可为空)
|
|
9539
|
+
* @param write 采集到的写入
|
|
9540
|
+
* @returns 折叠后的条目与标记
|
|
9541
|
+
*/
|
|
9542
|
+
function foldWrite(stored, write) {
|
|
9543
|
+
const fresh = entryFromNew({
|
|
9544
|
+
id: write.identity,
|
|
9545
|
+
path: write.path,
|
|
9546
|
+
sessionId: write.sessionId,
|
|
9547
|
+
earlierVersion: write.before === null ? "none" : "file",
|
|
9548
|
+
oldText: write.before ?? "",
|
|
9549
|
+
newText: write.after ?? "",
|
|
9550
|
+
updatedAt: write.now
|
|
9551
|
+
});
|
|
9552
|
+
if (!stored) return {
|
|
9553
|
+
entry: fresh,
|
|
9554
|
+
flags: {
|
|
9555
|
+
missing: write.after === null,
|
|
9556
|
+
diverged: false,
|
|
9557
|
+
oversized: false,
|
|
9558
|
+
revision: 0
|
|
9559
|
+
}
|
|
9560
|
+
};
|
|
9561
|
+
const merged = mergeEntries(stored.entry, fresh);
|
|
9562
|
+
merged.path = write.path;
|
|
9563
|
+
return {
|
|
9564
|
+
entry: merged,
|
|
9565
|
+
flags: {
|
|
9566
|
+
missing: write.after === null,
|
|
9567
|
+
diverged: false,
|
|
9568
|
+
oversized: stored.flags.oversized,
|
|
9569
|
+
revision: stored.flags.revision + 1
|
|
9570
|
+
}
|
|
9571
|
+
};
|
|
9572
|
+
}
|
|
9573
|
+
/**
|
|
9574
|
+
* @author ddj 2026年10月29号
|
|
9575
|
+
* @description: 条目转列表投影,已解决条目仍保留但不可再决定。
|
|
9576
|
+
* @param stored 条目与标记
|
|
9577
|
+
* @returns 列表项
|
|
9578
|
+
*/
|
|
9579
|
+
function itemOf(stored) {
|
|
9580
|
+
const { entry, flags } = stored;
|
|
9581
|
+
const counts = countsOf(entry);
|
|
9582
|
+
const item = {
|
|
9583
|
+
id: entry.id,
|
|
9584
|
+
path: entry.path,
|
|
9585
|
+
earlierVersion: entry.earlierVersion,
|
|
9586
|
+
added: counts.added,
|
|
9587
|
+
deleted: counts.deleted,
|
|
9588
|
+
settled: isSettled(entry),
|
|
9589
|
+
unseen: entry.unseen === true,
|
|
9590
|
+
missing: flags.missing,
|
|
9591
|
+
diverged: flags.diverged,
|
|
9592
|
+
oversized: flags.oversized,
|
|
9593
|
+
revision: flags.revision,
|
|
9594
|
+
updatedAt: entry.updatedAt,
|
|
9595
|
+
sessionId: entry.sessionId,
|
|
9596
|
+
sessionIds: entry.sessionIds
|
|
9597
|
+
};
|
|
9598
|
+
if (entry.lineage !== void 0) item.lineage = entry.lineage;
|
|
9599
|
+
return item;
|
|
9600
|
+
}
|
|
9601
|
+
/**
|
|
9602
|
+
* @author ddj 2026年10月29号
|
|
9603
|
+
* @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
|
|
9604
|
+
* @param cwd 工作区
|
|
9605
|
+
* @param sessionId 当前会话
|
|
9606
|
+
* @returns 列表
|
|
9607
|
+
*/
|
|
9608
|
+
function loadPage(cwd, sessionId) {
|
|
9609
|
+
const all = loadEntries(cwd);
|
|
9610
|
+
const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all;
|
|
9611
|
+
const items = visible.map(itemOf);
|
|
9612
|
+
const gaps = [];
|
|
9613
|
+
for (const stored of visible) if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`);
|
|
9614
|
+
return {
|
|
9615
|
+
items,
|
|
9616
|
+
pending: items.filter((item) => !item.settled).length,
|
|
9617
|
+
nextCursor: null,
|
|
9618
|
+
gaps
|
|
9619
|
+
};
|
|
9620
|
+
}
|
|
9621
|
+
/**
|
|
9622
|
+
* @author ddj 2026年10月29号
|
|
9623
|
+
* @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
|
|
9624
|
+
* @param cwd 工作区
|
|
9625
|
+
* @param id 条目身份
|
|
9626
|
+
* @returns 详情,条目不存在时 null
|
|
9627
|
+
*/
|
|
9628
|
+
function loadDetail(cwd, id) {
|
|
9629
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === id);
|
|
9630
|
+
if (!stored) return null;
|
|
9631
|
+
const diff = diffOf(stored.entry);
|
|
9632
|
+
return {
|
|
9633
|
+
item: itemOf(stored),
|
|
9634
|
+
diff,
|
|
9635
|
+
blocks: blockStatesOf(stored.entry),
|
|
9636
|
+
capability: capabilityOf(stored.entry, stored.flags)
|
|
9637
|
+
};
|
|
9638
|
+
}
|
|
9639
|
+
/**
|
|
9640
|
+
* @author ddj 2026年10月29号
|
|
9641
|
+
* @description: 采集入口:折叠写入并落库。
|
|
9642
|
+
* @param write 已确认写入
|
|
9643
|
+
* @returns 无
|
|
9644
|
+
*/
|
|
9645
|
+
function recordWrite(write) {
|
|
9646
|
+
const stored = loadEntries(write.cwd).find((item) => item.entry.id === write.identity);
|
|
9647
|
+
if (stored && stored.entry.oldText === stored.entry.newText && stored.entry.newText === (write.after ?? "")) return;
|
|
9648
|
+
saveEntry(write.cwd, foldWrite(stored, write));
|
|
9649
|
+
}
|
|
9650
|
+
/**
|
|
9651
|
+
* @author ddj 2026年10月29号
|
|
9652
|
+
* @description: 外部改动吸收:当前内容与条目最新内容不同时采纳并标分歧,基线不动。
|
|
9653
|
+
* @param stored 条目
|
|
9654
|
+
* @param content 磁盘当前内容
|
|
9655
|
+
* @param now 时间
|
|
9656
|
+
* @returns 新条目与标记
|
|
9657
|
+
*/
|
|
9658
|
+
function absorbed(stored, content, now) {
|
|
9659
|
+
return {
|
|
9660
|
+
entry: {
|
|
9661
|
+
...stored.entry,
|
|
9662
|
+
newText: content,
|
|
9663
|
+
updatedAt: now,
|
|
9664
|
+
unseen: true
|
|
9665
|
+
},
|
|
9666
|
+
flags: {
|
|
9667
|
+
...stored.flags,
|
|
9668
|
+
diverged: true,
|
|
9669
|
+
missing: false,
|
|
9670
|
+
revision: stored.flags.revision + 1
|
|
9671
|
+
}
|
|
9672
|
+
};
|
|
9673
|
+
}
|
|
9674
|
+
/**
|
|
9675
|
+
* @author ddj 2026年10月29号
|
|
9676
|
+
* @description: 读回磁盘并把可读的外部改动吸收进条目;缺失只标标记不删条目。
|
|
9677
|
+
* @param stored 条目
|
|
9678
|
+
* @param content 磁盘当前内容,null 表示缺失或不可读
|
|
9679
|
+
* @param now 时间
|
|
9680
|
+
* @returns 需要落库的新状态,无需改动时为 null
|
|
9681
|
+
*/
|
|
9682
|
+
function refreshed(stored, content, now) {
|
|
9683
|
+
if (content === null) {
|
|
9684
|
+
if (stored.flags.missing) return null;
|
|
9685
|
+
return {
|
|
9686
|
+
entry: stored.entry,
|
|
9687
|
+
flags: {
|
|
9688
|
+
...stored.flags,
|
|
9689
|
+
missing: true,
|
|
9690
|
+
revision: stored.flags.revision + 1
|
|
9691
|
+
}
|
|
9692
|
+
};
|
|
9693
|
+
}
|
|
9694
|
+
if (content === stored.entry.newText) {
|
|
9695
|
+
if (!stored.flags.missing) return null;
|
|
9696
|
+
return {
|
|
9697
|
+
entry: stored.entry,
|
|
9698
|
+
flags: {
|
|
9699
|
+
...stored.flags,
|
|
9700
|
+
missing: false,
|
|
9701
|
+
revision: stored.flags.revision + 1
|
|
9702
|
+
}
|
|
9703
|
+
};
|
|
9704
|
+
}
|
|
9705
|
+
return absorbed(stored, content, now);
|
|
9706
|
+
}
|
|
9707
|
+
/**
|
|
9708
|
+
* @author ddj 2026年10月29号
|
|
9709
|
+
* @description: 构造带版本守卫与读回校验的写盘函数;内容已被外部改动时直接拒绝。
|
|
9710
|
+
* @param fs 文件系统
|
|
9711
|
+
* @param cwd 工作区
|
|
9712
|
+
* @param identity 目标身份
|
|
9713
|
+
* @param expected 写前应当具备的内容
|
|
9714
|
+
* @param signal 取消信号
|
|
9715
|
+
* @param policy 沙箱策略
|
|
9716
|
+
* @param allowCreate 文件已被删除时是否允许按快照重建(仅撤销/重做路径使用)
|
|
9717
|
+
* @returns 写盘函数
|
|
9718
|
+
*/
|
|
9719
|
+
function writerFor(fs, cwd, identity, expected, signal, policy, allowCreate = false) {
|
|
9720
|
+
return async (content) => {
|
|
9721
|
+
let target;
|
|
9722
|
+
try {
|
|
9723
|
+
target = await fs.resolve(identity, {
|
|
9724
|
+
cwd,
|
|
9725
|
+
signal
|
|
9726
|
+
});
|
|
9727
|
+
} catch {
|
|
9728
|
+
return {
|
|
9729
|
+
ok: false,
|
|
9730
|
+
reason: "resolve-failed"
|
|
9731
|
+
};
|
|
9732
|
+
}
|
|
9733
|
+
const current = await readText(fs, target).catch(() => void 0);
|
|
9734
|
+
if (current === void 0) return {
|
|
9735
|
+
ok: false,
|
|
9736
|
+
reason: "read-failed"
|
|
9737
|
+
};
|
|
9738
|
+
if (!(allowCreate && current === null) && current !== expected) return {
|
|
9739
|
+
ok: false,
|
|
9740
|
+
reason: "diverged"
|
|
9741
|
+
};
|
|
9742
|
+
const info = await fs.stat(target, signal).catch(() => void 0);
|
|
9743
|
+
try {
|
|
9744
|
+
await fs.writeText(target, content ?? "", info ? {
|
|
9745
|
+
kind: "replaceIfVersion",
|
|
9746
|
+
version: info.version
|
|
9747
|
+
} : void 0, signal, policy);
|
|
9748
|
+
const readBack = await readText(fs, target);
|
|
9749
|
+
if ((content ?? "") !== (readBack ?? "")) return {
|
|
9750
|
+
ok: false,
|
|
9751
|
+
reason: "read-back-mismatch"
|
|
9752
|
+
};
|
|
9753
|
+
if (content === null && readBack !== null) return {
|
|
9754
|
+
ok: false,
|
|
9755
|
+
reason: "delete-failed"
|
|
9756
|
+
};
|
|
9757
|
+
return {
|
|
9758
|
+
ok: true,
|
|
9759
|
+
content
|
|
9760
|
+
};
|
|
9761
|
+
} catch (error) {
|
|
9762
|
+
return {
|
|
9763
|
+
ok: false,
|
|
9764
|
+
reason: reasonOf(error)
|
|
9765
|
+
};
|
|
9766
|
+
}
|
|
9767
|
+
};
|
|
9768
|
+
}
|
|
9769
|
+
/**
|
|
9770
|
+
* @author ddj 2026年10月29号
|
|
9771
|
+
* @description: 执行一次 Keep/Revert 并把结果落库;失败只返回原因,不写任何状态。
|
|
9772
|
+
* @param fs 文件系统
|
|
9773
|
+
* @param cwd 工作区
|
|
9774
|
+
* @param request 判定请求
|
|
9775
|
+
* @param signal 取消信号
|
|
9776
|
+
* @returns 判定结果
|
|
9777
|
+
*/
|
|
9778
|
+
async function applyDecide(fs, cwd, request, signal, policy) {
|
|
9779
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === request.id);
|
|
9780
|
+
if (!stored) return {
|
|
9781
|
+
ok: false,
|
|
9782
|
+
reason: "unknown-item"
|
|
9783
|
+
};
|
|
9784
|
+
const beforeBlocks = blockStatesOf(stored.entry);
|
|
9785
|
+
const outcome = await decide({
|
|
9786
|
+
request,
|
|
9787
|
+
entry: stored.entry,
|
|
9788
|
+
flags: stored.flags,
|
|
9789
|
+
blocks: beforeBlocks,
|
|
9790
|
+
now: Date.now(),
|
|
9791
|
+
write: (content) => {
|
|
9792
|
+
if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal);
|
|
9793
|
+
return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content);
|
|
9794
|
+
}
|
|
9795
|
+
});
|
|
9796
|
+
if (!outcome.ok) return {
|
|
9797
|
+
ok: false,
|
|
9798
|
+
reason: outcome.reason
|
|
9799
|
+
};
|
|
9800
|
+
saveEntry(cwd, {
|
|
9801
|
+
entry: outcome.entry,
|
|
9802
|
+
flags: outcome.flags
|
|
9803
|
+
});
|
|
9804
|
+
saveBlocks(cwd, request.id, outcome.blocks);
|
|
9805
|
+
pushHistory(cwd, "decide", request.id, JSON.stringify({
|
|
9806
|
+
before: {
|
|
9807
|
+
entry: stored.entry,
|
|
9808
|
+
flags: stored.flags,
|
|
9809
|
+
blocks: beforeBlocks
|
|
9810
|
+
},
|
|
9811
|
+
after: {
|
|
9812
|
+
entry: outcome.entry,
|
|
9813
|
+
flags: outcome.flags,
|
|
9814
|
+
blocks: outcome.blocks
|
|
9815
|
+
},
|
|
9816
|
+
wrote: outcome.wrote
|
|
9817
|
+
}), Date.now());
|
|
9818
|
+
return outcome;
|
|
9819
|
+
}
|
|
9820
|
+
/**
|
|
9821
|
+
* @author ddj 2026年10月30号
|
|
9822
|
+
* @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
|
|
9823
|
+
* @param shell shell 能力
|
|
9824
|
+
* @param cwd 工作区
|
|
9825
|
+
* @param sessionId 触发导入的会话
|
|
9826
|
+
* @param options 导入选项
|
|
9827
|
+
* @returns 导入条数与探测到的改动数
|
|
9828
|
+
*/
|
|
9829
|
+
async function importFromVcs(shell, cwd, sessionId, options = {}) {
|
|
9830
|
+
const changes = await importVcsChanges(shell, cwd, options);
|
|
9831
|
+
const now = Date.now();
|
|
9832
|
+
for (const change of changes) recordWrite({
|
|
9833
|
+
cwd,
|
|
9834
|
+
sessionId,
|
|
9835
|
+
path: change.path,
|
|
9836
|
+
identity: change.path,
|
|
9837
|
+
before: change.earlierVersion === "none" ? null : change.oldText,
|
|
9838
|
+
after: change.newText,
|
|
9839
|
+
now
|
|
9840
|
+
});
|
|
9841
|
+
return {
|
|
9842
|
+
imported: changes.length,
|
|
9843
|
+
changes: changes.length
|
|
9844
|
+
};
|
|
9845
|
+
}
|
|
9846
|
+
/**
|
|
9847
|
+
* @author ddj 2026年10月30号
|
|
9848
|
+
* @description: 撤销或重做最近一次判定。先按 operationId 判幂等,再据栈顶记录判定方向是否合法,
|
|
9849
|
+
* 只有写盘过的判定才反向写盘,写盘失败时条目状态保持原样。
|
|
9850
|
+
* @param fs 文件系统
|
|
9851
|
+
* @param cwd 工作区
|
|
9852
|
+
* @param move 撤销/重做请求
|
|
9853
|
+
* @param signal 取消信号
|
|
9854
|
+
* @returns 结果条目与是否写盘
|
|
9855
|
+
*/
|
|
9856
|
+
async function undoDecide(fs, cwd, move, signal, policy) {
|
|
9857
|
+
const replay = readOp(cwd, move.operationId);
|
|
9858
|
+
if (replay && replay.state === "committed" && replay.result) return JSON.parse(replay.result);
|
|
9859
|
+
const record = move.direction === "undo" ? latestApplied(cwd) : earliestUndone(cwd);
|
|
9860
|
+
if (!record) return {
|
|
9861
|
+
ok: false,
|
|
9862
|
+
reason: move.direction === "undo" ? "no-undo" : "no-redo"
|
|
9863
|
+
};
|
|
9864
|
+
const snapshot = parseSnapshot(record.snapshot);
|
|
9865
|
+
if (!snapshot) return {
|
|
9866
|
+
ok: false,
|
|
9867
|
+
reason: "bad-history"
|
|
9868
|
+
};
|
|
9869
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === record.entryId);
|
|
9870
|
+
if (!stored) return {
|
|
9871
|
+
ok: false,
|
|
9872
|
+
reason: "unknown-item"
|
|
9873
|
+
};
|
|
9874
|
+
const target = move.direction === "undo" ? snapshot.before : snapshot.after;
|
|
9875
|
+
writeOp(cwd, move.operationId, JSON.stringify(move), "prepared");
|
|
9876
|
+
let wrote = false;
|
|
9877
|
+
if (snapshot.wrote) {
|
|
9878
|
+
const result = await writeBack(fs, cwd, stored, target, signal, policy);
|
|
9879
|
+
if (!result.ok) return {
|
|
9880
|
+
ok: false,
|
|
9881
|
+
reason: result.reason
|
|
9882
|
+
};
|
|
9883
|
+
wrote = true;
|
|
9884
|
+
}
|
|
9885
|
+
saveEntry(cwd, {
|
|
9886
|
+
entry: target.entry,
|
|
9887
|
+
flags: target.flags
|
|
9888
|
+
});
|
|
9889
|
+
saveBlocks(cwd, record.entryId, target.blocks);
|
|
9890
|
+
setHistoryState(cwd, record.id, move.direction === "undo" ? "undone" : "applied");
|
|
9891
|
+
const outcome = {
|
|
9892
|
+
ok: true,
|
|
9893
|
+
item: itemOf({
|
|
9894
|
+
entry: target.entry,
|
|
9895
|
+
flags: target.flags
|
|
9896
|
+
}),
|
|
9897
|
+
wrote
|
|
8085
9898
|
};
|
|
9899
|
+
writeOp(cwd, move.operationId, JSON.stringify(move), "committed", { result: JSON.stringify(outcome) });
|
|
9900
|
+
return outcome;
|
|
8086
9901
|
}
|
|
8087
|
-
/**
|
|
8088
|
-
|
|
9902
|
+
/**
|
|
9903
|
+
* @author ddj 2026年10月30号
|
|
9904
|
+
* @description: 把文件内容恢复到目标快照:目标为缺失则删除,否则按版本守卫写回并读回校验。
|
|
9905
|
+
* @param fs 文件系统
|
|
9906
|
+
* @param cwd 工作区
|
|
9907
|
+
* @param stored 当前条目
|
|
9908
|
+
* @param target 目标快照
|
|
9909
|
+
* @param signal 取消信号
|
|
9910
|
+
* @returns 写盘结果
|
|
9911
|
+
*/
|
|
9912
|
+
async function writeBack(fs, cwd, stored, target, signal, policy) {
|
|
9913
|
+
if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal);
|
|
9914
|
+
return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)(target.entry.newText);
|
|
9915
|
+
}
|
|
9916
|
+
/**
|
|
9917
|
+
* @author ddj 2026年10月30号
|
|
9918
|
+
* @description: 解析历史快照;损坏时返回 null,不猜测内容。
|
|
9919
|
+
* @param text 快照文本
|
|
9920
|
+
* @returns 快照或 null
|
|
9921
|
+
*/
|
|
9922
|
+
function parseSnapshot(text) {
|
|
8089
9923
|
try {
|
|
8090
|
-
|
|
9924
|
+
const parsed = JSON.parse(text);
|
|
9925
|
+
if (!parsed?.before?.entry || !parsed?.after?.entry) return null;
|
|
9926
|
+
return parsed;
|
|
9927
|
+
} catch {
|
|
9928
|
+
return null;
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9931
|
+
/**
|
|
9932
|
+
* @author ddj 2026年10月29号
|
|
9933
|
+
* @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
|
|
9934
|
+
* @param fs 文件系统
|
|
9935
|
+
* @param cwd 工作区
|
|
9936
|
+
* @param identity 目标身份
|
|
9937
|
+
* @param signal 取消信号
|
|
9938
|
+
* @returns 删除结果
|
|
9939
|
+
*/
|
|
9940
|
+
async function deleteTarget(fs, cwd, identity, signal) {
|
|
9941
|
+
let target;
|
|
9942
|
+
try {
|
|
9943
|
+
target = await fs.resolve(identity, {
|
|
9944
|
+
cwd,
|
|
9945
|
+
signal
|
|
9946
|
+
});
|
|
9947
|
+
} catch {
|
|
9948
|
+
return {
|
|
9949
|
+
ok: false,
|
|
9950
|
+
reason: "resolve-failed"
|
|
9951
|
+
};
|
|
9952
|
+
}
|
|
9953
|
+
if (!await fs.stat(target, signal).catch(() => void 0)) return {
|
|
9954
|
+
ok: true,
|
|
9955
|
+
content: null
|
|
9956
|
+
};
|
|
9957
|
+
const absolute = String(fs.processPath(target));
|
|
9958
|
+
if (!insideWorkspace(cwd, absolute)) return {
|
|
9959
|
+
ok: false,
|
|
9960
|
+
reason: "outside-workspace"
|
|
9961
|
+
};
|
|
9962
|
+
try {
|
|
9963
|
+
await unlink(absolute);
|
|
8091
9964
|
} catch (error) {
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
9965
|
+
return {
|
|
9966
|
+
ok: false,
|
|
9967
|
+
reason: reasonOf(error)
|
|
9968
|
+
};
|
|
9969
|
+
}
|
|
9970
|
+
return await fs.stat(target, signal).catch(() => void 0) ? {
|
|
9971
|
+
ok: false,
|
|
9972
|
+
reason: "delete-failed"
|
|
9973
|
+
} : {
|
|
9974
|
+
ok: true,
|
|
9975
|
+
content: null
|
|
9976
|
+
};
|
|
9977
|
+
}
|
|
9978
|
+
/**
|
|
9979
|
+
* @author ddj 2026年10月29号
|
|
9980
|
+
* @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
|
|
9981
|
+
* @param cwd 工作区
|
|
9982
|
+
* @param absolute 目标绝对路径
|
|
9983
|
+
* @returns 是否在工作区内
|
|
9984
|
+
*/
|
|
9985
|
+
function insideWorkspace(cwd, absolute) {
|
|
9986
|
+
const root = resolve(cwd);
|
|
9987
|
+
const candidate = resolve(absolute);
|
|
9988
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
9989
|
+
const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep;
|
|
9990
|
+
return normalize(candidate).startsWith(rootKey);
|
|
9991
|
+
}
|
|
9992
|
+
/**
|
|
9993
|
+
* @author ddj 2026年10月29号
|
|
9994
|
+
* @description: 从列表移除已解决条目;未解决条目拒绝移除,避免表里看不到却被删掉。
|
|
9995
|
+
* @param cwd 工作区
|
|
9996
|
+
* @param id 条目身份
|
|
9997
|
+
* @param revision 并发校验版本
|
|
9998
|
+
* @returns 是否移除
|
|
9999
|
+
*/
|
|
10000
|
+
function removeEntry(cwd, id, revision) {
|
|
10001
|
+
const stored = loadEntries(cwd).find((item) => item.entry.id === id);
|
|
10002
|
+
if (!stored || stored.flags.revision !== revision || !isSettled(stored.entry)) return false;
|
|
10003
|
+
dropEntry(cwd, id);
|
|
10004
|
+
return true;
|
|
10005
|
+
}
|
|
10006
|
+
/**
|
|
10007
|
+
* @author ddj 2026年10月29号
|
|
10008
|
+
* @description: 取错误码用于拒绝原因,没有则回落到消息文本。
|
|
10009
|
+
* @param error 抛出的错误
|
|
10010
|
+
* @returns 原因字符串
|
|
10011
|
+
*/
|
|
10012
|
+
function reasonOf(error) {
|
|
10013
|
+
const code = error.code;
|
|
10014
|
+
return typeof code === "string" ? code : String(error);
|
|
10015
|
+
}
|
|
10016
|
+
//#endregion
|
|
10017
|
+
//#region src/shared/pendingReview.ts
|
|
10018
|
+
/** 待审条目在面板列表里的 id 前缀;客户端与 Host 共用这一份口径。 */
|
|
10019
|
+
const PENDING_PREFIX = "pending:";
|
|
10020
|
+
//#endregion
|
|
10021
|
+
//#region src/pendingCompat.ts
|
|
10022
|
+
/** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
|
|
10023
|
+
const coveredPaths = /* @__PURE__ */ new Set();
|
|
10024
|
+
/**
|
|
10025
|
+
* @author ddj 2026年10月29号
|
|
10026
|
+
* @description: 由条目 id 派生稳定序号,保证轮询期间面板锚点不跳。
|
|
10027
|
+
* @param id 待审条目身份
|
|
10028
|
+
* @returns 正整数序号
|
|
10029
|
+
*/
|
|
10030
|
+
function stableSeq(id) {
|
|
10031
|
+
let hash = 2166136261;
|
|
10032
|
+
for (let index = 0; index < id.length; index++) {
|
|
10033
|
+
hash ^= id.charCodeAt(index);
|
|
10034
|
+
hash = Math.imul(hash, 16777619);
|
|
8095
10035
|
}
|
|
10036
|
+
return Math.abs(hash) % 2147483647 + 1;
|
|
10037
|
+
}
|
|
10038
|
+
/**
|
|
10039
|
+
* @author ddj 2026年10月29号
|
|
10040
|
+
* @description: 统一路径比较口径,消除分隔符与 Windows 大小写差异;远端身份保留原大小写。
|
|
10041
|
+
* @param path 路径
|
|
10042
|
+
* @returns 比较键
|
|
10043
|
+
*/
|
|
10044
|
+
function pathKey$1(path) {
|
|
10045
|
+
const unified = path.replace(/\\/g, "/");
|
|
10046
|
+
if (/^[a-z]+:\/\//i.test(unified)) return unified;
|
|
10047
|
+
if (/^[a-z]:\//i.test(unified)) return unified.toLowerCase();
|
|
10048
|
+
return process.platform === "win32" ? unified.toLowerCase() : unified;
|
|
10049
|
+
}
|
|
10050
|
+
/**
|
|
10051
|
+
* @author ddj 2026年10月30号
|
|
10052
|
+
* @description: 判断两个路径是否指同一文件:比较键相同,或一方是另一方的工作区相对写法。
|
|
10053
|
+
* 面板传下来的路径可能是相对路径,而条目里存的是绝对路径。
|
|
10054
|
+
* @param left 路径
|
|
10055
|
+
* @param right 路径
|
|
10056
|
+
* @returns 是否同一文件
|
|
10057
|
+
*/
|
|
10058
|
+
function pendingSamePath(left, right) {
|
|
10059
|
+
const a = pathKey$1(left);
|
|
10060
|
+
const b = pathKey$1(right);
|
|
10061
|
+
if (!a || !b) return false;
|
|
10062
|
+
return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
|
|
10063
|
+
}
|
|
10064
|
+
/**
|
|
10065
|
+
* @author ddj 2026年10月30号
|
|
10066
|
+
* @description: 列表里的条目 id 带待审前缀用于区分通道,内核按原始身份存取,这里统一还原。
|
|
10067
|
+
* @param id 请求里的条目 id
|
|
10068
|
+
* @returns 待审内核使用的原始 id
|
|
10069
|
+
*/
|
|
10070
|
+
function pendingRawId(id) {
|
|
10071
|
+
return id.startsWith("pending:") ? id.slice(8) : id;
|
|
10072
|
+
}
|
|
10073
|
+
/**
|
|
10074
|
+
* @author ddj 2026年10月30号
|
|
10075
|
+
* @description: 列表显示用的短路径:工作区内显示相对路径,区外保留原文。
|
|
10076
|
+
* @param cwd 工作区
|
|
10077
|
+
* @param path 条目路径
|
|
10078
|
+
* @returns 显示用路径
|
|
10079
|
+
*/
|
|
10080
|
+
function pendingDisplay(cwd, path) {
|
|
10081
|
+
const root = cwd.replace(/[\\/]+$/, "");
|
|
10082
|
+
const unified = path.replace(/\\/g, "/");
|
|
10083
|
+
const base = root.replace(/\\/g, "/");
|
|
10084
|
+
if (base && (unified === base || unified.toLowerCase().startsWith(base.toLowerCase() + "/"))) return unified.slice(base.length + 1);
|
|
10085
|
+
return path;
|
|
10086
|
+
}
|
|
10087
|
+
/**
|
|
10088
|
+
* @author ddj 2026年10月29号
|
|
10089
|
+
* @description: 待审条目转面板列表项。
|
|
10090
|
+
* @param item 待审条目
|
|
10091
|
+
* @param cwd 工作区,用于显示相对路径;缺省显示原始路径
|
|
10092
|
+
* @returns 面板列表项
|
|
10093
|
+
*/
|
|
10094
|
+
function pendingItemOf(item, cwd) {
|
|
10095
|
+
return {
|
|
10096
|
+
id: PENDING_PREFIX + item.id,
|
|
10097
|
+
sessionId: item.sessionId,
|
|
10098
|
+
turn: 0,
|
|
10099
|
+
seq: stableSeq(item.id),
|
|
10100
|
+
path: item.path,
|
|
10101
|
+
currentPath: item.path,
|
|
10102
|
+
display: cwd ? pendingDisplay(cwd, item.path) : item.path,
|
|
10103
|
+
added: item.added,
|
|
10104
|
+
deleted: item.deleted,
|
|
10105
|
+
kind: item.oversized ? "oversized" : "text",
|
|
10106
|
+
decision: item.settled ? "accepted" : "pending",
|
|
10107
|
+
revision: item.revision,
|
|
10108
|
+
current: true
|
|
10109
|
+
};
|
|
10110
|
+
}
|
|
10111
|
+
/**
|
|
10112
|
+
* @author ddj 2026年10月29号
|
|
10113
|
+
* @description: 待审详情转面板详情;Revert 映射到面板的 Undo 能力位。
|
|
10114
|
+
* @param detail 待审详情
|
|
10115
|
+
* @param capturedAt 采集时间文案
|
|
10116
|
+
* @returns 面板详情
|
|
10117
|
+
*/
|
|
10118
|
+
function pendingDetailOf(detail, capturedAt, cwd) {
|
|
10119
|
+
return {
|
|
10120
|
+
item: pendingItemOf(detail.item, cwd),
|
|
10121
|
+
diff: detail.diff,
|
|
10122
|
+
capturedAt,
|
|
10123
|
+
blocks: detail.blocks,
|
|
10124
|
+
capability: {
|
|
10125
|
+
keep: detail.capability.keep,
|
|
10126
|
+
undo: detail.capability.revert,
|
|
10127
|
+
reason: detail.capability.reason
|
|
10128
|
+
}
|
|
10129
|
+
};
|
|
10130
|
+
}
|
|
10131
|
+
/**
|
|
10132
|
+
* @author ddj 2026年10月29号
|
|
10133
|
+
* @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
|
|
10134
|
+
* @param cwd 工作区
|
|
10135
|
+
* @param path 路径
|
|
10136
|
+
* @param covered 是否接管
|
|
10137
|
+
* @returns 无
|
|
10138
|
+
*/
|
|
10139
|
+
function trackPendingCover(cwd, path, covered) {
|
|
10140
|
+
const key = `${pathKey$1(cwd)}|${pathKey$1(path)}`;
|
|
10141
|
+
if (covered) coveredPaths.add(key);
|
|
10142
|
+
else coveredPaths.delete(key);
|
|
10143
|
+
}
|
|
10144
|
+
/**
|
|
10145
|
+
* @author ddj 2026年10月29号
|
|
10146
|
+
* @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
|
|
10147
|
+
* @param cwd 工作区
|
|
10148
|
+
* @param path 请求路径
|
|
10149
|
+
* @returns 是否已被接管
|
|
10150
|
+
*/
|
|
10151
|
+
function pendingCovers(cwd, path) {
|
|
10152
|
+
return coveredPaths.has(`${pathKey$1(cwd)}|${pathKey$1(path)}`);
|
|
10153
|
+
}
|
|
10154
|
+
/**
|
|
10155
|
+
* @author ddj 2026年10月29号
|
|
10156
|
+
* @description: 合并官方页与待审页:待审覆盖的路径从官方结果中剔除,其余原样保留。
|
|
10157
|
+
* @param official 官方页
|
|
10158
|
+
* @param pending 待审页
|
|
10159
|
+
* @param cwd 工作区,用于把待审行显示成相对路径
|
|
10160
|
+
* @returns 合并后的页
|
|
10161
|
+
*/
|
|
10162
|
+
function mergedPage(official, pending, cwd) {
|
|
10163
|
+
const covered = new Set(pending.items.map((item) => pathKey$1(item.path)));
|
|
10164
|
+
const kept = official.items.filter((item) => !covered.has(pathKey$1(item.currentPath)) && !covered.has(pathKey$1(item.path)));
|
|
10165
|
+
return {
|
|
10166
|
+
items: [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept],
|
|
10167
|
+
pending: pending.pending + kept.filter((item) => item.decision === "pending").length,
|
|
10168
|
+
nextCursor: official.nextCursor,
|
|
10169
|
+
gaps: official.gaps,
|
|
10170
|
+
...official.overview ? { overview: official.overview } : {}
|
|
10171
|
+
};
|
|
8096
10172
|
}
|
|
8097
10173
|
//#endregion
|
|
8098
10174
|
//#region src/search/query.ts
|
|
@@ -13152,15 +15228,67 @@ function reviewHandlers(ctx, registry) {
|
|
|
13152
15228
|
await bucketOf(registry, ctx, scope.cwd);
|
|
13153
15229
|
return scope.cwd;
|
|
13154
15230
|
}
|
|
15231
|
+
/**
|
|
15232
|
+
* @private
|
|
15233
|
+
* @author ddj 2026年10月30号
|
|
15234
|
+
* @description: 读取待审页;待审库尚未建立或迁移失败时按空页处理。
|
|
15235
|
+
* 待审投影是增量能力,不能因为它让官方列表整体不可用。
|
|
15236
|
+
* @param cwd 工作区
|
|
15237
|
+
* @param sessionId 请求会话
|
|
15238
|
+
* @returns 待审页(失败为空页)
|
|
15239
|
+
*/
|
|
15240
|
+
function pendingPageOf(cwd, sessionId) {
|
|
15241
|
+
try {
|
|
15242
|
+
return loadPage(cwd, sessionId);
|
|
15243
|
+
} catch {
|
|
15244
|
+
return {
|
|
15245
|
+
items: [],
|
|
15246
|
+
pending: 0,
|
|
15247
|
+
nextCursor: null,
|
|
15248
|
+
gaps: []
|
|
15249
|
+
};
|
|
15250
|
+
}
|
|
15251
|
+
}
|
|
13155
15252
|
return {
|
|
13156
15253
|
"edrv.review.list": async (args) => {
|
|
15254
|
+
const cwd = await workspace(args.sessionId);
|
|
15255
|
+
const official = reviewPage(cwd, args.cursor, args.history, args.path);
|
|
15256
|
+
const pending = pendingPageOf(cwd, args.sessionId);
|
|
15257
|
+
if (args.history) return {
|
|
15258
|
+
ok: true,
|
|
15259
|
+
page: official
|
|
15260
|
+
};
|
|
15261
|
+
if (args.path !== void 0) {
|
|
15262
|
+
const key = args.path;
|
|
15263
|
+
const own = pending.items.filter((item) => pendingSamePath(item.path, key)).map((item) => pendingItemOf(item, cwd));
|
|
15264
|
+
const history = own.length ? official.items.filter((item) => !pendingSamePath(item.path, key)) : official.items;
|
|
15265
|
+
return {
|
|
15266
|
+
ok: true,
|
|
15267
|
+
page: {
|
|
15268
|
+
...official,
|
|
15269
|
+
items: [...own, ...history]
|
|
15270
|
+
}
|
|
15271
|
+
};
|
|
15272
|
+
}
|
|
13157
15273
|
return {
|
|
13158
15274
|
ok: true,
|
|
13159
|
-
page:
|
|
15275
|
+
page: mergedPage(official, pending, cwd)
|
|
13160
15276
|
};
|
|
13161
15277
|
},
|
|
13162
15278
|
"edrv.review.detail": async (args) => {
|
|
13163
|
-
const
|
|
15279
|
+
const cwd = await workspace(args.sessionId);
|
|
15280
|
+
if (args.id.startsWith("pending:")) {
|
|
15281
|
+
const detail = loadDetail(cwd, args.id.slice(8));
|
|
15282
|
+
if (!detail) return {
|
|
15283
|
+
ok: false,
|
|
15284
|
+
error: "待审条目不存在"
|
|
15285
|
+
};
|
|
15286
|
+
return {
|
|
15287
|
+
ok: true,
|
|
15288
|
+
detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
|
|
15289
|
+
};
|
|
15290
|
+
}
|
|
15291
|
+
const detail = reviewDetail(cwd, args.id);
|
|
13164
15292
|
return detail ? {
|
|
13165
15293
|
ok: true,
|
|
13166
15294
|
detail
|
|
@@ -13171,6 +15299,25 @@ function reviewHandlers(ctx, registry) {
|
|
|
13171
15299
|
},
|
|
13172
15300
|
"edrv.review.decide": async (args) => {
|
|
13173
15301
|
const cwd = await liveWorkspace(args.sessionId);
|
|
15302
|
+
if (args.id.startsWith("pending:")) {
|
|
15303
|
+
const result = await applyDecide(ctx.get("fs"), cwd, {
|
|
15304
|
+
sessionId: args.sessionId,
|
|
15305
|
+
id: args.id.slice(8),
|
|
15306
|
+
revision: args.revision,
|
|
15307
|
+
operationId: args.operationId,
|
|
15308
|
+
decision: args.decision,
|
|
15309
|
+
scope: args.scope === "hunk" ? "block" : "file",
|
|
15310
|
+
...args.blockId === void 0 ? {} : { blockId: args.blockId }
|
|
15311
|
+
});
|
|
15312
|
+
if (!result.ok) return {
|
|
15313
|
+
ok: false,
|
|
15314
|
+
error: result.reason
|
|
15315
|
+
};
|
|
15316
|
+
return {
|
|
15317
|
+
ok: true,
|
|
15318
|
+
revision: result.flags.revision
|
|
15319
|
+
};
|
|
15320
|
+
}
|
|
13174
15321
|
try {
|
|
13175
15322
|
return {
|
|
13176
15323
|
ok: true,
|
|
@@ -13186,6 +15333,179 @@ function reviewHandlers(ctx, registry) {
|
|
|
13186
15333
|
};
|
|
13187
15334
|
}
|
|
13188
15335
|
//#endregion
|
|
15336
|
+
//#region src/pendingRpc.ts
|
|
15337
|
+
/**
|
|
15338
|
+
* @author ddj 2026年10月29号
|
|
15339
|
+
* @description: 待审投影处理器。读路径允许跨实例冷读,写路径必须本端 live 会话。
|
|
15340
|
+
* @param ctx Host 上下文
|
|
15341
|
+
* @param registry 工作区记录桶缓存
|
|
15342
|
+
* @returns 待审相关处理器表
|
|
15343
|
+
*/
|
|
15344
|
+
function pendingHandlers(ctx, registry) {
|
|
15345
|
+
/** @private @author ddj 2026年10月29号 @param sessionId 请求作用域 @returns 工作区 */
|
|
15346
|
+
async function workspace(sessionId) {
|
|
15347
|
+
const scope = await resolveScope(ctx, sessionId);
|
|
15348
|
+
if ("err" in scope) throw new Error("会话不存在或没有工作区");
|
|
15349
|
+
await bucketOf(registry, ctx, scope.cwd);
|
|
15350
|
+
return scope.cwd;
|
|
15351
|
+
}
|
|
15352
|
+
/** @private @author ddj 2026年10月29号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区 */
|
|
15353
|
+
async function liveWorkspace(sessionId) {
|
|
15354
|
+
const cwd = await workspace(sessionId);
|
|
15355
|
+
if (!sessionOf(ctx, sessionId)) throw new Error("写入要求本端活跃会话");
|
|
15356
|
+
return cwd;
|
|
15357
|
+
}
|
|
15358
|
+
/**
|
|
15359
|
+
* @private
|
|
15360
|
+
* @author ddj 2026年10月30号
|
|
15361
|
+
* @description: 面板列表里的条目 id 带待审前缀(用于和官方条目区分),
|
|
15362
|
+
* 而待审内核按原始身份存取;各处理器统一走 pendingRawId 剥掉前缀。
|
|
15363
|
+
* @param id 请求里的条目 id
|
|
15364
|
+
* @returns 待审内核使用的原始 id
|
|
15365
|
+
*/
|
|
15366
|
+
const rawId = pendingRawId;
|
|
15367
|
+
return {
|
|
15368
|
+
"edrv.pending.list": async (args) => {
|
|
15369
|
+
const cwd = await workspace(args.sessionId);
|
|
15370
|
+
const page = loadPage(cwd, args.sessionId);
|
|
15371
|
+
for (const item of page.items) trackPendingCover(cwd, item.path, true);
|
|
15372
|
+
return {
|
|
15373
|
+
ok: true,
|
|
15374
|
+
page
|
|
15375
|
+
};
|
|
15376
|
+
},
|
|
15377
|
+
"edrv.pending.detail": async (args) => {
|
|
15378
|
+
const detail = loadDetail(await workspace(args.sessionId), rawId(args.id));
|
|
15379
|
+
return detail ? {
|
|
15380
|
+
ok: true,
|
|
15381
|
+
detail
|
|
15382
|
+
} : {
|
|
15383
|
+
ok: false,
|
|
15384
|
+
error: "待审条目不存在"
|
|
15385
|
+
};
|
|
15386
|
+
},
|
|
15387
|
+
"edrv.pending.decide": async (args) => {
|
|
15388
|
+
const cwd = await liveWorkspace(args.sessionId);
|
|
15389
|
+
try {
|
|
15390
|
+
const id = rawId(args.id);
|
|
15391
|
+
const result = await applyDecide(ctx.get("fs"), cwd, {
|
|
15392
|
+
...args,
|
|
15393
|
+
id
|
|
15394
|
+
}, void 0, policyOf(ctx, sessionOf(ctx, args.sessionId)));
|
|
15395
|
+
if (!result.ok) return {
|
|
15396
|
+
ok: false,
|
|
15397
|
+
error: result.reason
|
|
15398
|
+
};
|
|
15399
|
+
const detail = loadDetail(cwd, id);
|
|
15400
|
+
if (!detail) return {
|
|
15401
|
+
ok: false,
|
|
15402
|
+
error: "待审条目不存在"
|
|
15403
|
+
};
|
|
15404
|
+
return {
|
|
15405
|
+
ok: true,
|
|
15406
|
+
item: detail.item,
|
|
15407
|
+
wrote: result.wrote
|
|
15408
|
+
};
|
|
15409
|
+
} catch (error) {
|
|
15410
|
+
return {
|
|
15411
|
+
ok: false,
|
|
15412
|
+
error: error instanceof Error ? error.message : String(error)
|
|
15413
|
+
};
|
|
15414
|
+
}
|
|
15415
|
+
},
|
|
15416
|
+
"edrv.pending.remove": async (args) => {
|
|
15417
|
+
const cwd = await liveWorkspace(args.sessionId);
|
|
15418
|
+
const id = rawId(args.id);
|
|
15419
|
+
const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path;
|
|
15420
|
+
const removed = removeEntry(cwd, id, args.revision);
|
|
15421
|
+
if (removed && path) trackPendingCover(cwd, path, false);
|
|
15422
|
+
return {
|
|
15423
|
+
ok: true,
|
|
15424
|
+
removed
|
|
15425
|
+
};
|
|
15426
|
+
},
|
|
15427
|
+
"edrv.pending.history": async (args) => {
|
|
15428
|
+
const cwd = await liveWorkspace(args.sessionId);
|
|
15429
|
+
try {
|
|
15430
|
+
const result = await undoDecide(ctx.get("fs"), cwd, args, void 0, policyOf(ctx, sessionOf(ctx, args.sessionId)));
|
|
15431
|
+
if (!result.ok) return {
|
|
15432
|
+
ok: false,
|
|
15433
|
+
error: result.reason
|
|
15434
|
+
};
|
|
15435
|
+
return {
|
|
15436
|
+
ok: true,
|
|
15437
|
+
item: result.item,
|
|
15438
|
+
wrote: result.wrote
|
|
15439
|
+
};
|
|
15440
|
+
} catch (error) {
|
|
15441
|
+
return {
|
|
15442
|
+
ok: false,
|
|
15443
|
+
error: error instanceof Error ? error.message : String(error)
|
|
15444
|
+
};
|
|
15445
|
+
}
|
|
15446
|
+
},
|
|
15447
|
+
"edrv.pending.import": async (args) => {
|
|
15448
|
+
const cwd = await liveWorkspace(args.sessionId);
|
|
15449
|
+
const raw = ctx.get("shell");
|
|
15450
|
+
if (!raw) return {
|
|
15451
|
+
ok: false,
|
|
15452
|
+
error: "当前环境没有 shell 能力,无法读取版本库基线"
|
|
15453
|
+
};
|
|
15454
|
+
const policy = policyOf(ctx, sessionOf(ctx, args.sessionId));
|
|
15455
|
+
const shell = {
|
|
15456
|
+
resolve: (request) => raw.resolve(policy ? {
|
|
15457
|
+
...request,
|
|
15458
|
+
sandboxPolicy: policy
|
|
15459
|
+
} : request),
|
|
15460
|
+
execute: (spec) => raw.execute(spec)
|
|
15461
|
+
};
|
|
15462
|
+
try {
|
|
15463
|
+
const result = await importFromVcs(shell, cwd, args.sessionId, {
|
|
15464
|
+
includeUntracked: args.includeUntracked,
|
|
15465
|
+
path: args.path
|
|
15466
|
+
});
|
|
15467
|
+
const page = loadPage(cwd, args.sessionId);
|
|
15468
|
+
for (const item of page.items) trackPendingCover(cwd, item.path, true);
|
|
15469
|
+
return {
|
|
15470
|
+
ok: true,
|
|
15471
|
+
imported: result.imported,
|
|
15472
|
+
changes: result.changes
|
|
15473
|
+
};
|
|
15474
|
+
} catch (error) {
|
|
15475
|
+
return {
|
|
15476
|
+
ok: false,
|
|
15477
|
+
error: error instanceof Error ? error.message : String(error)
|
|
15478
|
+
};
|
|
15479
|
+
}
|
|
15480
|
+
},
|
|
15481
|
+
"edrv.pending.refresh": async (args) => {
|
|
15482
|
+
const cwd = await liveWorkspace(args.sessionId);
|
|
15483
|
+
const id = rawId(args.id);
|
|
15484
|
+
const stored = loadEntries(cwd).find((entry) => entry.entry.id === id);
|
|
15485
|
+
if (!stored) return {
|
|
15486
|
+
ok: false,
|
|
15487
|
+
error: "待审条目不存在"
|
|
15488
|
+
};
|
|
15489
|
+
const session = sessionOf(ctx, args.sessionId);
|
|
15490
|
+
const fs = ctx.get("fs");
|
|
15491
|
+
const content = await readText(fs, await fs.resolve(id, {
|
|
15492
|
+
cwd,
|
|
15493
|
+
agent: session
|
|
15494
|
+
})).catch(() => void 0);
|
|
15495
|
+
if (content === void 0) return {
|
|
15496
|
+
ok: false,
|
|
15497
|
+
error: "读取失败"
|
|
15498
|
+
};
|
|
15499
|
+
const next = refreshed(stored, content, Date.now());
|
|
15500
|
+
if (next) saveEntry(cwd, next);
|
|
15501
|
+
return {
|
|
15502
|
+
ok: true,
|
|
15503
|
+
item: itemOf(next ?? stored)
|
|
15504
|
+
};
|
|
15505
|
+
}
|
|
15506
|
+
};
|
|
15507
|
+
}
|
|
15508
|
+
//#endregion
|
|
13189
15509
|
//#region src/missingReview.ts
|
|
13190
15510
|
/** Missing historical files: frozen, whole-file deletion reviews. */
|
|
13191
15511
|
const frozen = /* @__PURE__ */ new Map();
|
|
@@ -13209,13 +15529,18 @@ function revisionOf(records) {
|
|
|
13209
15529
|
}));
|
|
13210
15530
|
return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
|
|
13211
15531
|
}
|
|
15532
|
+
/** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
|
|
15533
|
+
function fileKeyOf(ctx, target) {
|
|
15534
|
+
const path = targetPathOf(ctx, target);
|
|
15535
|
+
return path ? fileKey(path) : "";
|
|
15536
|
+
}
|
|
13212
15537
|
/** @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
15538
|
async function fileRecords(ctx, cwd, bucket, target) {
|
|
13214
15539
|
const matches = [];
|
|
13215
|
-
const identity =
|
|
15540
|
+
const identity = fileKeyOf(ctx, target);
|
|
13216
15541
|
for (const record of bucket.values()) {
|
|
13217
15542
|
if (record.superseded) continue;
|
|
13218
|
-
if (
|
|
15543
|
+
if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record);
|
|
13219
15544
|
}
|
|
13220
15545
|
return matches.sort((left, right) => left.at.localeCompare(right.at));
|
|
13221
15546
|
}
|
|
@@ -13233,10 +15558,11 @@ async function fileAbsent(ctx, cwd, path) {
|
|
|
13233
15558
|
}
|
|
13234
15559
|
/** @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
15560
|
async function missingReview(ctx, session, cwd, bucket, path) {
|
|
13236
|
-
const
|
|
13237
|
-
if (
|
|
13238
|
-
if (
|
|
13239
|
-
|
|
15561
|
+
const abs = targetPathOf(ctx, await resolveTargetIn(ctx, cwd, path));
|
|
15562
|
+
if (!abs) throw new Error("无法解析目标文件路径");
|
|
15563
|
+
if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error("该文件已有官方审查记录,请使用官方审查入口");
|
|
15564
|
+
if (!await fileAbsent(ctx, cwd, abs)) throw new Error("文件已重新出现,请刷新");
|
|
15565
|
+
const records = await fileRecords(ctx, cwd, bucket, abs);
|
|
13240
15566
|
const latest = records.at(-1);
|
|
13241
15567
|
if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error("没有待审历史记录");
|
|
13242
15568
|
if (!latest || typeof latest.after !== "string") throw new Error("缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件");
|
|
@@ -13276,37 +15602,48 @@ async function applyDisk(ctx, item, target, undo) {
|
|
|
13276
15602
|
if (absent && operation.restored && !await fs.stat(target)) throw new Error("恢复后的文件再次缺失,未结清记录");
|
|
13277
15603
|
if (await fs.readText(target) !== item.view.content) throw new Error("恢复内容校验失败,未结清记录");
|
|
13278
15604
|
}
|
|
15605
|
+
/**
|
|
15606
|
+
* @author ddj 2026年10月29号
|
|
15607
|
+
* @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
|
|
15608
|
+
* @param token 冻结预览 token
|
|
15609
|
+
* @returns 文件路径,token 无效时为 undefined
|
|
15610
|
+
*/
|
|
15611
|
+
function missingPath(token) {
|
|
15612
|
+
return frozen.get(token)?.view.path;
|
|
15613
|
+
}
|
|
13279
15614
|
/** @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
15615
|
async function missingDecide(ctx, session, cwd, bucket, token, operationId, undo) {
|
|
13281
15616
|
const item = frozen.get(token);
|
|
13282
15617
|
if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error("删除预览已过期,请刷新后重试");
|
|
13283
15618
|
if (!operationId || operationId.length > 128) throw new Error("缺少有效操作 ID");
|
|
13284
15619
|
const target = await resolveTargetIn(ctx, cwd, item.view.path);
|
|
13285
|
-
const
|
|
15620
|
+
const abs = targetPathOf(ctx, target);
|
|
15621
|
+
if (!abs) throw new Error("无法解析目标文件路径");
|
|
15622
|
+
const key = JSON.stringify([cwd, fileKey(abs)]);
|
|
13286
15623
|
if (locks.has(key)) throw new Error("该文件正在处理,请稍后重试");
|
|
13287
15624
|
locks.add(key);
|
|
13288
15625
|
item.session = session;
|
|
13289
15626
|
try {
|
|
13290
|
-
return await commitMissing(ctx, item, bucket, target, operationId, undo);
|
|
15627
|
+
return await commitMissing(ctx, item, bucket, target, abs, operationId, undo);
|
|
13291
15628
|
} finally {
|
|
13292
15629
|
locks.delete(key);
|
|
13293
15630
|
}
|
|
13294
15631
|
}
|
|
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) {
|
|
15632
|
+
/** @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. */
|
|
15633
|
+
async function commitMissing(ctx, item, bucket, target, abs, operationId, undo) {
|
|
13297
15634
|
const previous = item.operation;
|
|
13298
15635
|
if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error("已有不同操作,请刷新后重试");
|
|
13299
15636
|
if (previous?.done) return item.view.callIds;
|
|
13300
|
-
if (hasOfficial(item.cwd,
|
|
13301
|
-
if (revisionOf(await fileRecords(ctx, item.cwd, bucket,
|
|
15637
|
+
if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error("该文件已有官方审查记录,请刷新");
|
|
15638
|
+
if (revisionOf(await fileRecords(ctx, item.cwd, bucket, abs)) !== item.revision) throw new Error("历史记录已变化,请刷新删除预览");
|
|
13302
15639
|
item.operation ??= {
|
|
13303
15640
|
id: operationId,
|
|
13304
15641
|
undo,
|
|
13305
15642
|
restored: false,
|
|
13306
15643
|
done: false
|
|
13307
15644
|
};
|
|
13308
|
-
await applyDisk(ctx, item,
|
|
13309
|
-
const current = await fileRecords(ctx, item.cwd, bucket,
|
|
15645
|
+
await applyDisk(ctx, item, abs, undo);
|
|
15646
|
+
const current = await fileRecords(ctx, item.cwd, bucket, abs);
|
|
13310
15647
|
if (revisionOf(current) !== item.revision) throw new Error("历史记录已变化,未结清记录,请刷新");
|
|
13311
15648
|
const pending = current.filter((record) => item.view.callIds.includes(record.callId));
|
|
13312
15649
|
const decisions = pending.map((record) => record.decisions);
|
|
@@ -13364,6 +15701,18 @@ function recView(record, ws = null) {
|
|
|
13364
15701
|
...ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? void 0 } : {}
|
|
13365
15702
|
};
|
|
13366
15703
|
}
|
|
15704
|
+
/**
|
|
15705
|
+
* 旧写入口守卫:路径已由待审累计基线接管时一律拒绝,避免两套权威同时写同一文件。
|
|
15706
|
+
* @author ddj 2026年10月29号
|
|
15707
|
+
* @param cwd 工作区
|
|
15708
|
+
* @param path 请求涉及的路径(可为空表示无法定位)
|
|
15709
|
+
* @returns 拒绝原因,未接管时为 null
|
|
15710
|
+
*/
|
|
15711
|
+
function legacyBlocked(cwd, path) {
|
|
15712
|
+
if (!path) return null;
|
|
15713
|
+
if (!pendingCovers(cwd, path)) return null;
|
|
15714
|
+
return "该文件已由待审累计基线接管,请使用面板 Keep/Revert(edrv.review.decide)";
|
|
15715
|
+
}
|
|
13367
15716
|
/** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
|
|
13368
15717
|
async function requireSession(ctx, sessionId) {
|
|
13369
15718
|
const session = sessionOf(ctx, sessionId);
|
|
@@ -13846,6 +16195,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13846
16195
|
...svnHandlers ?? {},
|
|
13847
16196
|
...dapHandlers ?? {},
|
|
13848
16197
|
...reviewHandlers(ctx, registry),
|
|
16198
|
+
...pendingHandlers(ctx, registry),
|
|
13849
16199
|
"edrv.list": async (args) => {
|
|
13850
16200
|
const start = performance.now();
|
|
13851
16201
|
let staleMs = 0;
|
|
@@ -13937,6 +16287,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13937
16287
|
error: sc.err
|
|
13938
16288
|
};
|
|
13939
16289
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
16290
|
+
const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
|
|
16291
|
+
if (blocked) return {
|
|
16292
|
+
ok: false,
|
|
16293
|
+
error: blocked
|
|
16294
|
+
};
|
|
13940
16295
|
const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
|
|
13941
16296
|
callId: args.callId,
|
|
13942
16297
|
scope: args.scope,
|
|
@@ -13959,6 +16314,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13959
16314
|
error: sc.err
|
|
13960
16315
|
};
|
|
13961
16316
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
16317
|
+
const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
|
|
16318
|
+
if (blocked) return {
|
|
16319
|
+
ok: false,
|
|
16320
|
+
error: blocked
|
|
16321
|
+
};
|
|
13962
16322
|
const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
|
|
13963
16323
|
callId: args.callId,
|
|
13964
16324
|
scope: args.scope,
|
|
@@ -13980,6 +16340,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
13980
16340
|
ok: false,
|
|
13981
16341
|
error: sc.err
|
|
13982
16342
|
};
|
|
16343
|
+
const blocked = legacyBlocked(sc.cwd, args.path);
|
|
16344
|
+
if (blocked) return {
|
|
16345
|
+
ok: false,
|
|
16346
|
+
error: blocked
|
|
16347
|
+
};
|
|
13983
16348
|
try {
|
|
13984
16349
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
13985
16350
|
if (!await fileAbsent(ctx, sc.cwd, args.path)) return {
|
|
@@ -14003,6 +16368,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
14003
16368
|
ok: false,
|
|
14004
16369
|
error: sc.err
|
|
14005
16370
|
};
|
|
16371
|
+
const blocked = legacyBlocked(sc.cwd, missingPath(args.token));
|
|
16372
|
+
if (blocked) return {
|
|
16373
|
+
ok: false,
|
|
16374
|
+
error: blocked
|
|
16375
|
+
};
|
|
14006
16376
|
try {
|
|
14007
16377
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
14008
16378
|
return {
|
|
@@ -14023,9 +16393,15 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
14023
16393
|
error: sc.err
|
|
14024
16394
|
};
|
|
14025
16395
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
16396
|
+
const items = Array.isArray(args.items) ? args.items : [];
|
|
16397
|
+
const blocked = items.map((item) => legacyBlocked(sc.cwd, bucket.get(item.callId)?.path)).find((reason) => reason !== null);
|
|
16398
|
+
if (blocked) return {
|
|
16399
|
+
ok: false,
|
|
16400
|
+
error: blocked
|
|
16401
|
+
};
|
|
14026
16402
|
return {
|
|
14027
16403
|
ok: true,
|
|
14028
|
-
results: await applyDecisions(ctx, sc.session, sc.cwd, bucket,
|
|
16404
|
+
results: await applyDecisions(ctx, sc.session, sc.cwd, bucket, items)
|
|
14029
16405
|
};
|
|
14030
16406
|
},
|
|
14031
16407
|
/** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
|
|
@@ -26039,6 +28415,16 @@ function attachRuntime(ctx, runtime) {
|
|
|
26039
28415
|
evidence.persist(cwd, snapshot);
|
|
26040
28416
|
return true;
|
|
26041
28417
|
});
|
|
28418
|
+
const pending = installPendingCapture(ctx, (write) => {
|
|
28419
|
+
try {
|
|
28420
|
+
trackPendingCover(write.cwd, write.path, true);
|
|
28421
|
+
} catch {}
|
|
28422
|
+
try {
|
|
28423
|
+
recordWrite(write);
|
|
28424
|
+
} catch (error) {
|
|
28425
|
+
log.warn("待审差异落库失败:" + String(error));
|
|
28426
|
+
}
|
|
28427
|
+
});
|
|
26042
28428
|
/** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
|
|
26043
28429
|
function onResult(exec, result) {
|
|
26044
28430
|
captureToolResult(ctx, runtime.registry, exec, result);
|
|
@@ -26058,6 +28444,7 @@ function attachRuntime(ctx, runtime) {
|
|
|
26058
28444
|
async function cleanup() {
|
|
26059
28445
|
await stopCapture();
|
|
26060
28446
|
evidence.dispose();
|
|
28447
|
+
pending.dispose();
|
|
26061
28448
|
runtime.fileVersions.dispose();
|
|
26062
28449
|
closeArchiveDbs();
|
|
26063
28450
|
disposeAllServers();
|