dsh-vscode-mode 0.21.4 → 0.23.0

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