dsh-vscode-mode 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -3686,7 +3686,15 @@ window.__ModuleLoader__.load({
3686
3686
  if (page.nextCursor !== null || page.items.length !== page.pending) throw new Error("待审记录数量不完整,未执行批量操作");
3687
3687
  return page.items;
3688
3688
  }
3689
- /** @public @author ddj 2026年10月27号 @param port Guarded existing per-file decision adapter. @param reject Undo instead of Keep. @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback. */
3689
+ /**
3690
+ * @public
3691
+ * @author ddj 2026年10月27号
3692
+ * @description: 逐条决策引擎,现仅供 Undo All 使用(Keep 已改走 `reviewKeepAll.ts` 的宿主批量通道);
3693
+ * 保留 reject 形态以便对照既有契约与测试。
3694
+ * @param port Guarded existing per-file decision adapter.
3695
+ * @param reject Undo instead of Keep.
3696
+ * @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback.
3697
+ */
3690
3698
  async function runReviewBatch(port, reject) {
3691
3699
  const result = {
3692
3700
  completed: 0,
@@ -3721,6 +3729,1278 @@ window.__ModuleLoader__.load({
3721
3729
  return result;
3722
3730
  }
3723
3731
  //#endregion
3732
+ //#region src/client/reviewKeepAll.ts
3733
+ /**
3734
+ * @private
3735
+ * @author ddj 2026年11月03号
3736
+ * @description: 取一次完整待审快照;完整性校验失败时重取一次,避免瞬时漂移被当成不可恢复错误。
3737
+ * @param port 已鉴权的 RPC 适配器
3738
+ * @returns 冻结的批量目标
3739
+ * @throws 两次都取不到一致快照时抛出原始原因
3740
+ */
3741
+ async function snapshotOnce(port) {
3742
+ try {
3743
+ return toItems(await loadPending(port));
3744
+ } catch {
3745
+ port.guard();
3746
+ return toItems(await loadPending(port));
3747
+ }
3748
+ }
3749
+ /**
3750
+ * @private
3751
+ * @author ddj 2026年11月03号
3752
+ * @description: 把快照条目裁成批量目标,只带 host 需要的身份与版本线索。
3753
+ * @param items 完整待审集
3754
+ * @returns 批量目标
3755
+ */
3756
+ function toItems(items) {
3757
+ return items.map((item) => ({
3758
+ id: item.id,
3759
+ display: item.display,
3760
+ seq: item.seq,
3761
+ revision: item.revision
3762
+ }));
3763
+ }
3764
+ /**
3765
+ * @public
3766
+ * @author ddj 2026年11月03号
3767
+ * @description: 批量保留全部待审记录(Keep 不改写磁盘)。一次往返完成,跳过项只进 skipped,
3768
+ * 段级异常才写 error;guard 失效时保留已完成计数交给调用方呈现。
3769
+ * @param port 已鉴权的 RPC 适配器
3770
+ * @returns 与 BatchResult 同构的批量结果(含跳过明细)
3771
+ */
3772
+ async function runKeepAll(port) {
3773
+ const result = {
3774
+ completed: 0,
3775
+ total: 0,
3776
+ paths: [],
3777
+ skipped: [],
3778
+ error: ""
3779
+ };
3780
+ try {
3781
+ port.guard();
3782
+ const items = await snapshotOnce(port);
3783
+ result.total = items.length;
3784
+ if (!items.length) return result;
3785
+ port.guard();
3786
+ const reply = await port.keepAll(items, crypto.randomUUID());
3787
+ if (!reply.ok) {
3788
+ result.error = reply.error;
3789
+ return result;
3790
+ }
3791
+ return reply.result;
3792
+ } catch (cause) {
3793
+ result.error = cause instanceof Error ? cause.message : String(cause);
3794
+ return result;
3795
+ }
3796
+ }
3797
+ //#endregion
3798
+ //#region src/shared/externalOpen.ts
3799
+ /**
3800
+ * dsh-vscode-mode shared — 外部深链 URL 契约(Windows launcher / Unity 包 / client 共用)。
3801
+ * 形态:?<FLAG>=1&<PATHS_KEY>=<enc1>[,<enc2>…] [&<LINE_KEY>=N] [&<COLUMN_KEY>=M]
3802
+ * 每个路径独立 percent-encode(encodeURIComponent / Uri.EscapeDataString)后以 `,` 连接:
3803
+ * 编码结果不含裸逗号(Windows 路径本身不允许逗号,编码兜底其余字符),URLSearchParams
3804
+ * 取值做一次标准解码即得 `enc1,enc2`,按 `,` split 即路径,无需二次 decode。
3805
+ * Purity rule: no node/react imports(URLSearchParams 为标准全局)。
3806
+ * 作者 ddj 2026-09-07
3807
+ */
3808
+ /** 深链开关参数名(值恒为 '1')。 */
3809
+ const EDRV_OPEN_FLAG = "edrvOpen";
3810
+ /** 路径列表参数名(逗号分隔的已编码路径)。 */
3811
+ const EDRV_PATHS_KEY = "edrvPaths";
3812
+ /** 目标行参数名(1-based,可选,作用于首个路径)。 */
3813
+ const EDRV_LINE_KEY = "edrvLine";
3814
+ /** 目标列参数名(1-based,可选,作用于首个路径)。 */
3815
+ const EDRV_COLUMN_KEY = "edrvColumn";
3816
+ /** 深链参数键全集(URL 清理用)。 */
3817
+ const EDRV_PARAM_KEYS = [
3818
+ EDRV_OPEN_FLAG,
3819
+ EDRV_PATHS_KEY,
3820
+ EDRV_LINE_KEY,
3821
+ EDRV_COLUMN_KEY
3822
+ ];
3823
+ /**
3824
+ * 解析深链查询串:非深链/路径为空 → null;行列仅接受正整数,非法忽略。
3825
+ * @author ddj 2026年09月07号
3826
+ * @param search location.search(含或不含前导 ? 均可)
3827
+ * @returns 深链参数或 null
3828
+ */
3829
+ function parseOpenParams(search) {
3830
+ const params = new URLSearchParams(search ?? "");
3831
+ if (params.get("edrvOpen") !== "1") return null;
3832
+ const paths = (params.get("edrvPaths") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
3833
+ if (!paths.length) return null;
3834
+ return {
3835
+ paths,
3836
+ line: positiveIntOf(params.get(EDRV_LINE_KEY)),
3837
+ column: positiveIntOf(params.get(EDRV_COLUMN_KEY))
3838
+ };
3839
+ }
3840
+ /**
3841
+ * 正整数解析(非法/非正 → undefined)。
3842
+ * @author ddj 2026年09月07号
3843
+ * @param raw 参数原文(可空)
3844
+ * @returns 正整数或 undefined
3845
+ */
3846
+ function positiveIntOf(raw) {
3847
+ const value = Number(raw);
3848
+ return Number.isInteger(value) && value > 0 ? value : void 0;
3849
+ }
3850
+ //#endregion
3851
+ //#region src/client/chooseDialog.ts
3852
+ /**
3853
+ * dsh-vscode-mode client — 工作区选择弹窗(深链打开规则 1)。
3854
+ * 复用与「添加 MCP」一致的 vsm-mcp-modal / vsm-mcp-dialog 模态(页面居中卡片 + 遮罩 + 主题
3855
+ * token),React portal 挂 body;Esc / 遮罩点击 / 取消 → null(中止流程不改任何状态)。
3856
+ * 作者 ddj 2026-09-07
3857
+ */
3858
+ /** 选择弹窗(vsm-mcp-modal 遮罩 + vsm-mcp-dialog 卡片,Esc/遮罩点击取消)。 */
3859
+ function ChoiceModal({ title, folder, done }) {
3860
+ react$1.default.useEffect(() => {
3861
+ const onKey = (event) => {
3862
+ if (event.key === "Escape") done(null);
3863
+ };
3864
+ window.addEventListener("keydown", onKey);
3865
+ return () => window.removeEventListener("keydown", onKey);
3866
+ }, []);
3867
+ return react$1.default.createElement("div", {
3868
+ className: "vsm-mcp-modal",
3869
+ onMouseDown: (event) => {
3870
+ if (event.target === event.currentTarget) done(null);
3871
+ }
3872
+ }, react$1.default.createElement("div", { className: "vsm-mcp-dialog" }, react$1.default.createElement("h3", null, "在 DSH 中打开"), react$1.default.createElement("label", null, "文件夹 " + folder + " 位于已注册的工作区「" + title + "」内,选择打开方式:"), react$1.default.createElement("div", { className: "vsm-mcp-dialog-actions" }, react$1.default.createElement("button", { onClick: () => done(null) }, "取消"), react$1.default.createElement("button", { onClick: () => done("create") }, "新建工作区"), react$1.default.createElement("button", {
3873
+ className: "vsm-primary",
3874
+ onClick: () => done("recent")
3875
+ }, "使用最近的工作区"))));
3876
+ }
3877
+ /**
3878
+ * 弹出工作区选择弹窗(深链打开规则 1)。
3879
+ * @author ddj 2026年09月07号
3880
+ * @param title 匹配到的工作区标题
3881
+ * @param folder 文件夹路径
3882
+ * @returns 用户选择;null = 取消/中止
3883
+ */
3884
+ function chooseWorkspace(title, folder) {
3885
+ return new Promise((resolve) => {
3886
+ const host = document.createElement("div");
3887
+ document.body.appendChild(host);
3888
+ const root = (0, react_dom_client.createRoot)(host);
3889
+ const done = (choice) => {
3890
+ root.unmount();
3891
+ host.remove();
3892
+ resolve(choice);
3893
+ };
3894
+ root.render(react$1.default.createElement(ChoiceModal, {
3895
+ title,
3896
+ folder,
3897
+ done
3898
+ }));
3899
+ });
3900
+ }
3901
+ //#endregion
3902
+ //#region src/client/addToConversation.ts
3903
+ /**
3904
+ * dsh-vscode-mode client — 把文件/选中内容注入会话对话输入框(composer draft)。
3905
+ * 复用 DSH 官方输入门面:ctx.conversation.input.for(sessions.scope(sessionId)) → SessionInput。
3906
+ * 引用 chip:insertReference(reference source,发送时序列化为 @path 文本);忙态降级为纯文本 setDraft。
3907
+ * 纯函数部分 node 可单测。
3908
+ * 作者 ddj 2026-08-25
3909
+ */
3910
+ /** 把追加引用结果映射为可读文案(ok 用 okText;busy 提示已降级纯文本;unavailable 提示不可用)。
3911
+ * @author ddj 2026年09月03号 / 2026年09月11号
3912
+ * @param outcome 追加结果状态
3913
+ * @param okText 成功文案(如「已添加文件引用」/「已添加文件夹引用」)
3914
+ * @returns 状态栏/通知用文案
3915
+ */
3916
+ function statusOfAdd(outcome, okText) {
3917
+ if (outcome === "ok") return okText;
3918
+ if (outcome === "busy") return okText + "(已降级纯文本)";
3919
+ if (outcome === "failed") return "添加引用失败(输入框忙或未就绪,请重试)";
3920
+ return "无法添加到对话(无会话或输入框不可用)";
3921
+ }
3922
+ /** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
3923
+ const REF_SOURCE = "reference";
3924
+ /**
3925
+ * 把 clipboard 投影的末尾折算成 **detect 投影**的末尾。
3926
+ *
3927
+ * ⚠️ 两个投影对 chip 的计长不同,混用会直接毁掉已插入的引用(真实缺陷,勿改回 `draft.length`):
3928
+ * DSH 的 `$composerLayout()` 中 chip 在 detect 投影里只贡献 **1 个 U+FFFC**,
3929
+ * 在 clipboard 投影里贡献 `clipboardText` 全文
3930
+ * (`pushLeaf('chip', kid, '', kid.getTextContent())`)。
3931
+ * 而 `insertReference(ref, span)` 的 span 走 detect 坐标并经 `selectSpan` 校验:
3932
+ * `span.end > layout.detectLength` 时返回 null → 插入失败。
3933
+ *
3934
+ * 旧实现直接用 `draft.length`(clipboard 长度)当 detect 起点:草稿里一旦有 chip,
3935
+ * 该值就超出 detect 长度 → 插入被拒 → 落入 `setDraft` 兜底,
3936
+ * 而 `setDraft` 会整篇重建并剔除 U+FFFC(`text.replace(REFERENCE_PLACEHOLDER_RE, '')`),
3937
+ * 于是**既有 chip 被销毁、引用退化为重复的纯文本**(即「连续添加多个引用显示异常」)。
3938
+ *
3939
+ * 换算:每个 chip 在 clipboard 中多算了 `length - 1`,逐个扣回即得 detect 末尾。
3940
+ * 无 occurrences(旧版 DSH)时退化为 `draft.length`,与旧行为一致。
3941
+ * @author ddj 2026年09月11号
3942
+ * @param snapshot 输入快照
3943
+ * @returns detect 投影中的文档末尾偏移
3944
+ */
3945
+ function detectEndOf(snapshot) {
3946
+ const total = String(snapshot?.draft ?? "").length;
3947
+ const occurrences = snapshot?.occurrences;
3948
+ if (!Array.isArray(occurrences) || !occurrences.length) return total;
3949
+ let clipped = 0;
3950
+ for (const occurrence of occurrences) {
3951
+ const length = Number(occurrence?.length ?? 0);
3952
+ if (!Number.isFinite(length) || length <= 1) continue;
3953
+ clipped += length - 1;
3954
+ }
3955
+ return Math.max(0, total - clipped);
3956
+ }
3957
+ /** chip 列表按 clipboard 偏移升序(官方已排序,此处防御性归一,丢弃非法项)。 */
3958
+ function chipsOf(snapshot) {
3959
+ const occurrences = snapshot?.occurrences;
3960
+ if (!Array.isArray(occurrences) || !occurrences.length) return [];
3961
+ const out = [];
3962
+ for (const occurrence of occurrences) {
3963
+ const offset = Number(occurrence?.offset ?? NaN);
3964
+ const length = Number(occurrence?.length ?? NaN);
3965
+ if (!Number.isFinite(offset) || !Number.isFinite(length) || length <= 1) continue;
3966
+ out.push({
3967
+ offset: Math.max(0, offset),
3968
+ length
3969
+ });
3970
+ }
3971
+ return out.sort((a, b) => a.offset - b.offset);
3972
+ }
3973
+ /**
3974
+ * detect 偏移 → clipboard 偏移(paddingAt 用:draft 是 clipboard 投影,不能直接按下标取字符)。
3975
+ * 每个位于该偏移之前的 chip 在 clipboard 中多占 `length − 1`,累计补上即可。
3976
+ * @author ddj 2026年09月11号
3977
+ * @param snapshot 输入快照
3978
+ * @param detectOffset detect 投影偏移
3979
+ * @returns clipboard 投影偏移(已夹到 draft 范围内)
3980
+ */
3981
+ function detectToClipboardOf(snapshot, detectOffset) {
3982
+ const total = String(snapshot?.draft ?? "").length;
3983
+ const target = Math.max(0, Math.min(Number(detectOffset) || 0, detectEndOf(snapshot)));
3984
+ let shift = 0;
3985
+ for (const chip of chipsOf(snapshot)) {
3986
+ if (chip.offset - shift >= target) break;
3987
+ shift += chip.length - 1;
3988
+ }
3989
+ return Math.max(0, Math.min(target + shift, total));
3990
+ }
3991
+ /**
3992
+ * 插入点两侧是否需要补空格(仅纯文本通道需要;chip 由 facade 自行补尾随空格)。
3993
+ *
3994
+ * 在 detect 坐标的任意位置求值,故**末尾追加的行为与旧实现逐字符一致**
3995
+ * (末尾前为非空白 → 前后各补一个空格;末尾前已是空白 → 只补尾随空格),
3996
+ * 同时正确支持「插在文本中间」不再贴字。
3997
+ * @author ddj 2026年09月11号
3998
+ * @param snapshot 输入快照
3999
+ * @param detectOffset detect 投影插入点
4000
+ * @returns 是否需要前导/尾随空格
4001
+ */
4002
+ function paddingAt(snapshot, detectOffset) {
4003
+ const draft = String(snapshot?.draft ?? "");
4004
+ const at = detectToClipboardOf(snapshot, detectOffset);
4005
+ const before = at > 0 ? draft.slice(at - 1, at) : "";
4006
+ const after = at < draft.length ? draft.slice(at, at + 1) : "";
4007
+ return {
4008
+ lead: before !== "" && !/\s/.test(before),
4009
+ tail: after === "" || !/\s/.test(after)
4010
+ };
4011
+ }
4012
+ /**
4013
+ * 解析插入点(detect 坐标):优先用输入框**当前光标/选区**(`caretSpan()`,
4014
+ * 官方契约即 detect 坐标,无选区时回落文档末尾),并夹到 `[0, detectLength]`。
4015
+ *
4016
+ * 选区非塌缩时取 `end`(右边界)—— 插入而非替换,绝不删除用户已选中的内容。
4017
+ * caret 缺失/非法(旧版 facade 无该方法、抛异常、NaN 等)一律回落到文档末尾。
4018
+ * @author ddj 2026年09月11号
4019
+ * @param caret caretSpan() 的返回值(可为 null/非法)
4020
+ * @param detectLength detect 投影文档长度
4021
+ * @returns detect 投影插入偏移
4022
+ */
4023
+ function insertOffsetOf(caret, detectLength) {
4024
+ const max = Math.max(0, Number(detectLength) || 0);
4025
+ if (!caret || typeof caret !== "object") return max;
4026
+ const span = caret;
4027
+ const raw = Number(span.end);
4028
+ const value = Number.isFinite(raw) ? raw : Number(span.start);
4029
+ if (!Number.isFinite(value)) return max;
4030
+ return Math.max(0, Math.min(value, max));
4031
+ }
4032
+ /**
4033
+ * 生成 DSH @file 语法引用串:cwd 相对化、\ → /、含空白时按 @"path" 语法加引号。
4034
+ * cwd 外/无法相对化的路径回退原路径。
4035
+ * @author ddj 2026年08月25号
4036
+ * @param path 打开的文件路径(相对 cwd 或绝对)
4037
+ * @param cwd 会话工作区目录(可选)
4038
+ * @returns 引用串(如 @src/index.ts 或 @"a b.ts")
4039
+ */
4040
+ function mentionOf(path, cwd) {
4041
+ const raw = String(path);
4042
+ let rel = raw;
4043
+ if (cwd) {
4044
+ const base = String(cwd).replace(/[\\/]+$/, "");
4045
+ const up = raw.replace(/\\/g, "/");
4046
+ const baseUp = base.replace(/\\/g, "/");
4047
+ if (up.startsWith(baseUp + "/")) rel = up.slice(baseUp.length + 1);
4048
+ else if (up === baseUp) rel = "";
4049
+ }
4050
+ rel = rel.replace(/\\/g, "/").replace(/^\.\//, "");
4051
+ if (!rel) rel = raw.replace(/\\/g, "/");
4052
+ if (/[\u0000-\u001f\u007f-\u009f"]/u.test(rel)) return "@" + raw;
4053
+ return /\s/u.test(rel) ? `@"${rel}"` : `@${rel}`;
4054
+ }
4055
+ /** 引用标签:文件名 + 可选行区间(chip 内展示,不带 @ 前缀)。
4056
+ * @author ddj 2026年08月25号 */
4057
+ function labelOf(path, range) {
4058
+ const base = String(path).split(/[\\/]/).pop() || String(path);
4059
+ if (!range || range.endLine < range.startLine) return base;
4060
+ return range.startLine === range.endLine ? `${base} L${range.startLine}` : `${base} L${range.startLine}-${range.endLine}`;
4061
+ }
4062
+ /** 引用 ref:引用串 + 可选行区间(序列化直出,agent 据此读取对应片段)。
4063
+ * @author ddj 2026年08月25号 */
4064
+ function refOf(mention, range) {
4065
+ if (!range || range.endLine < range.startLine) return mention;
4066
+ return range.startLine === range.endLine ? `${mention} L${range.startLine}` : `${mention} L${range.startLine}-${range.endLine}`;
4067
+ }
4068
+ /** 构造 DSH 文件引用插入载荷,模型文本、剪贴板投影与降级文本共用完整行区间。
4069
+ * @public
4070
+ * @author ddj 2026年10月28号
4071
+ * @returns 引用载荷与含可选行区间的完整引用文本
4072
+ * @param path 目标路径(相对 cwd 或绝对)
4073
+ * @param cwd 会话工作区目录(可选)
4074
+ * @param range 行区间(可选)
4075
+ * @param appearance 引用外观:'file'(默认)| 'folder'
4076
+ */
4077
+ function buildFileRef(path, cwd, range, appearance = "file") {
4078
+ const mention = refOf(mentionOf(path, cwd), range);
4079
+ return {
4080
+ mention,
4081
+ reference: {
4082
+ source: REF_SOURCE,
4083
+ ref: mention,
4084
+ label: labelOf(path, range),
4085
+ appearance,
4086
+ clipboardText: mention
4087
+ }
4088
+ };
4089
+ }
4090
+ /** @private @author ddj 2026年10月29号 @param receipt Raw selected-channel receipt. @returns Explicit admission evidence and the historical boolean. */
4091
+ function taskReceipt(receipt) {
4092
+ const ok = receipt?.ok;
4093
+ if (ok === true) return {
4094
+ status: "accepted",
4095
+ legacyOk: true
4096
+ };
4097
+ if (ok === false) return {
4098
+ status: "rejected",
4099
+ legacyOk: false
4100
+ };
4101
+ return {
4102
+ status: "unknown",
4103
+ legacyOk: false
4104
+ };
4105
+ }
4106
+ /** @private @author ddj 2026年10月29号 @param ctx Lifecycle owner. @param sessions Public Controller. @param id Fixed target. @param text Task body. @returns Admission evidence without switching transport. @sideEffects Retains and releases exactly one generation. */
4107
+ async function sendOwned(ctx, sessions, id, text) {
4108
+ const controller = new AbortController();
4109
+ const reference = sessions.retain(id, {
4110
+ source: "controllerOperation",
4111
+ signal: controller.signal
4112
+ });
4113
+ let released = false;
4114
+ let cleanupFailed = false;
4115
+ /** @private @author ddj 2026年10月29号 Cancels and releases this exact generation once; remembers unload cleanup failures. */
4116
+ function release() {
4117
+ if (released) return;
4118
+ released = true;
4119
+ controller.abort();
4120
+ try {
4121
+ reference.release();
4122
+ } catch (error) {
4123
+ cleanupFailed = true;
4124
+ throw error;
4125
+ }
4126
+ }
4127
+ let remove;
4128
+ let started = false;
4129
+ let result = {
4130
+ status: "unavailable",
4131
+ legacyOk: false
4132
+ };
4133
+ try {
4134
+ remove = ctx.effect?.(() => release, "vscode-mode: task session reference");
4135
+ const binding = await reference.ready;
4136
+ if (controller.signal.aborted) result = {
4137
+ status: "cancelled",
4138
+ legacyOk: false
4139
+ };
4140
+ else {
4141
+ started = true;
4142
+ result = taskReceipt(await binding.session.prompt([{
4143
+ type: "text",
4144
+ text
4145
+ }], "queue", controller.signal));
4146
+ }
4147
+ } catch {
4148
+ result = {
4149
+ status: started ? "unknown" : "unavailable",
4150
+ legacyOk: false
4151
+ };
4152
+ }
4153
+ const cancelled = controller.signal.aborted;
4154
+ try {
4155
+ release();
4156
+ } catch {
4157
+ cleanupFailed = true;
4158
+ }
4159
+ try {
4160
+ remove?.();
4161
+ } catch {
4162
+ cleanupFailed = true;
4163
+ }
4164
+ if (cleanupFailed) result = {
4165
+ status: started ? "unknown" : "unavailable",
4166
+ legacyOk: false
4167
+ };
4168
+ if (cancelled) result = {
4169
+ status: "cancelled",
4170
+ legacyOk: result.legacyOk
4171
+ };
4172
+ return result;
4173
+ }
4174
+ /** @private @author ddj 2026年10月29号 @param ctx Service owner. @param id Fixed target. @param text Task body. @returns One legacy-channel attempt; no cross-channel retry. */
4175
+ async function sendLegacy(ctx, id, text) {
4176
+ const input = inputFor(ctx, id);
4177
+ if (typeof input?.setDraft === "function" && typeof input.submit === "function") {
4178
+ let submitted = false;
4179
+ try {
4180
+ input.setDraft(text);
4181
+ submitted = true;
4182
+ input.submit();
4183
+ return {
4184
+ status: "submitted",
4185
+ legacyOk: true
4186
+ };
4187
+ } catch {
4188
+ return {
4189
+ status: submitted ? "unknown" : "unavailable",
4190
+ legacyOk: false
4191
+ };
4192
+ }
4193
+ }
4194
+ const remote = ctx.get("remote.session");
4195
+ if (typeof remote?.prompt !== "function") return httpTaskResult(id, text);
4196
+ try {
4197
+ const payload = {
4198
+ sessionId: id,
4199
+ mode: "queue",
4200
+ content: [{
4201
+ type: "text",
4202
+ text
4203
+ }]
4204
+ };
4205
+ const receipt = await remote.prompt(slotArg(payload));
4206
+ return taskReceipt(receipt?.result ?? receipt);
4207
+ } catch {
4208
+ return {
4209
+ status: "unknown",
4210
+ legacyOk: false
4211
+ };
4212
+ }
4213
+ }
4214
+ /** apiproxy wire 端点(POST /api/<method>;浏览器同源 fetch 自带 cookie 鉴权;实证 401=未鉴权而非 404)。 */
4215
+ const SESSION_CREATE_PATH = "/api/session/create";
4216
+ /** apiproxy wire 端点:发送任务(session/prompt;点击即发送方案)。 */
4217
+ const SESSION_PROMPT_PATH = "/api/session/prompt";
4218
+ /** mint RPC 请求 id(RpcId 品牌类型运行时即普通字符串)。 */
4219
+ function mintRpcId() {
4220
+ try {
4221
+ return globalThis.crypto?.randomUUID?.() ?? "rpc-" + Date.now();
4222
+ } catch {
4223
+ return "rpc-" + Date.now();
4224
+ }
4225
+ }
4226
+ /**
4227
+ * wire 槽实参组装(gateway `invoke` 铁证:`request.args[parameter.wire]` 具名槽,
4228
+ * `session/prompt` 的槽名 = "request",值 = RpcRequest `{rpcId, payload}`)。
4229
+ * @author ddj 2026年09月24号
4230
+ * @param payload 业务载荷
4231
+ * @returns 具名槽实参 `{ args: { request: RpcRequest } }`
4232
+ */
4233
+ function slotArg(payload) {
4234
+ return { args: { request: {
4235
+ rpcId: mintRpcId(),
4236
+ payload
4237
+ } } };
4238
+ }
4239
+ /**
4240
+ * RPC 返回解析(照官方 dsh-client-runtime 调用形态:`const { result } = await api.sessions.create(...)`)。
4241
+ * 官方 api.sessions 面返回**信封** `{ rpcId, result: RpcResult }`——须解 .result;
4242
+ * 兼容裸 RpcResult 形态(个别补丁面直接回 {ok, value})。
4243
+ * @author ddj 2026年09月23号
4244
+ * @param res 信封或裸 RpcResult
4245
+ * @returns 归一后的 { ok, value }
4246
+ */
4247
+ function rpcOk(res) {
4248
+ const r = res?.result ?? res;
4249
+ return {
4250
+ ok: r?.ok === true,
4251
+ value: r?.value
4252
+ };
4253
+ }
4254
+ /**
4255
+ * wire 直调 session/create(主路径):ClientRequest 信封 → ServerResponse.result(RpcResult 形态)。
4256
+ * 契约以当前安装版 dsh-host-apiproxy 为准(api/sessions.d.ts「Method signatures are the
4257
+ * source of truth」):`create({cwd}) → {sessionId}`;信封 = `{type:'client-request', rpcId,
4258
+ * method, payload}` → `{result: {ok:true, value:{sessionId}} | {ok:false, error}}`。
4259
+ * @author ddj 2026年09月23号
4260
+ * @param cwd 新会话工作区目录
4261
+ * @returns 新会话 id(失败空串)
4262
+ */
4263
+ async function apiSessionCreate(cwd) {
4264
+ const json = await (await fetch(appUrl(SESSION_CREATE_PATH), {
4265
+ method: "POST",
4266
+ headers: { "content-type": "application/json" },
4267
+ body: JSON.stringify({
4268
+ type: "client-request",
4269
+ rpcId: mintRpcId(),
4270
+ method: "session/create",
4271
+ payload: cwd ? { cwd } : {}
4272
+ })
4273
+ })).json().catch(() => null);
4274
+ const id = json?.result?.value?.sessionId;
4275
+ return json?.result?.ok === true && typeof id === "string" ? id : "";
4276
+ }
4277
+ /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns HTTP receipt evidence; errors after request entry are never safe rejection. */
4278
+ async function httpTaskResult(sessionId, text) {
4279
+ try {
4280
+ const payload = {
4281
+ sessionId,
4282
+ mode: "queue",
4283
+ content: [{
4284
+ type: "text",
4285
+ text
4286
+ }]
4287
+ };
4288
+ return taskReceipt((await (await fetch(appUrl(SESSION_PROMPT_PATH), {
4289
+ method: "POST",
4290
+ headers: { "content-type": "application/json" },
4291
+ body: JSON.stringify({
4292
+ type: "client-request",
4293
+ rpcId: mintRpcId(),
4294
+ method: "session/prompt",
4295
+ payload: slotArg(payload)
4296
+ })
4297
+ })).json().catch(() => null))?.result);
4298
+ } catch {
4299
+ return {
4300
+ status: "unknown",
4301
+ legacyOk: false
4302
+ };
4303
+ }
4304
+ }
4305
+ /**
4306
+ * 新建独立会话承载旁路任务(02-deep-session-prompt:深度分析不污染当前对话)。
4307
+ * 优先公开 sessions.create;旧 Host 按能力选择 remote 或 wire,调用后不换通道重试。
4308
+ * 受理状态不明时返回 ok:false,不重复创建会话。
4309
+ * @author ddj 2026年10月28号
4310
+ * @author ddj 2026年09月23号
4311
+ * @param ctx 客户端服务上下文(remote.session + sessions,仅回落用)
4312
+ * @param cwd 新会话工作区目录(与当前会话同工作区)
4313
+ * @returns 新会话结果(含 id)
4314
+ */
4315
+ async function newDraftSession(ctx, cwd) {
4316
+ try {
4317
+ const sessions = ctx.get("sessions");
4318
+ if (typeof sessions?.create === "function") {
4319
+ const created = await sessions.create(cwd ? { cwd } : void 0);
4320
+ const id = typeof created === "string" && created ? created : typeof created?.id === "string" ? created.id : typeof created?.sessionId === "string" ? created.sessionId : typeof created?.session?.id === "string" ? created.session.id : "";
4321
+ if (id) return {
4322
+ ok: true,
4323
+ id
4324
+ };
4325
+ }
4326
+ if (typeof sessions?.create === "function") return { ok: false };
4327
+ } catch {
4328
+ return { ok: false };
4329
+ }
4330
+ try {
4331
+ const remote = ctx.get("remote.session");
4332
+ if (typeof remote?.create === "function") {
4333
+ const payload = cwd ? { cwd } : {};
4334
+ const receipt = rpcOk(await remote.create(slotArg(payload)));
4335
+ const created = receipt.value;
4336
+ if (receipt.ok && typeof created?.sessionId === "string") return {
4337
+ ok: true,
4338
+ id: created.sessionId
4339
+ };
4340
+ return { ok: false };
4341
+ }
4342
+ } catch {
4343
+ return { ok: false };
4344
+ }
4345
+ try {
4346
+ const id = await apiSessionCreate(cwd);
4347
+ if (id) return {
4348
+ ok: true,
4349
+ id
4350
+ };
4351
+ } catch {}
4352
+ return { ok: false };
4353
+ }
4354
+ /**
4355
+ * 解析会话输入门面;缺失时返回 undefined(动作侧守卫降级)。
4356
+ * @author ddj 2026年08月25号
4357
+ * @param ctx 客户端服务上下文
4358
+ * @param sessionId 会话 id
4359
+ * @returns SessionInput 或 undefined
4360
+ */
4361
+ function inputFor(ctx, sessionId) {
4362
+ if (!sessionId) return void 0;
4363
+ try {
4364
+ const sessions = ctx.get("sessions");
4365
+ const conversation = ctx.get("conversation");
4366
+ const actx = typeof sessions?.binding === "function" ? sessions.binding(sessionId)?.ctx : sessions?.scope?.(sessionId);
4367
+ const shell = actx && conversation?.input?.for?.(actx);
4368
+ return shell && typeof shell.insertReference === "function" ? shell : void 0;
4369
+ } catch {
4370
+ return;
4371
+ }
4372
+ }
4373
+ /** 取当前草稿快照(长度/版本/chip 列表);无输入门面时返回 null。
4374
+ * @author ddj 2026年08月25号 / 2026年09月11号 */
4375
+ function draftCursor(input) {
4376
+ try {
4377
+ const s = input?.state?.getSnapshot?.();
4378
+ if (!s) return null;
4379
+ return {
4380
+ draft: String(s.draft ?? ""),
4381
+ draftRev: Number(s.draftRev ?? 0),
4382
+ occurrences: Array.isArray(s.occurrences) ? s.occurrences : void 0
4383
+ };
4384
+ } catch {
4385
+ return null;
4386
+ }
4387
+ }
4388
+ /** 从 sessions 列表快照读会话工作区目录(与 index.ts 既有读取方式一致)。
4389
+ * @author ddj 2026年08月25号 */
4390
+ function cwdOf(ctx, sessionId) {
4391
+ if (!sessionId) return void 0;
4392
+ try {
4393
+ return ctx.get("sessions")?.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd;
4394
+ } catch {
4395
+ return;
4396
+ }
4397
+ }
4398
+ /** 输入门面插入引用(拦截异常视作未应用,走降级)。
4399
+ * @author ddj 2026年08月25号 */
4400
+ function safeInsert(input, reference, span) {
4401
+ try {
4402
+ return input.insertReference(reference, span) === true;
4403
+ } catch {
4404
+ return false;
4405
+ }
4406
+ }
4407
+ /** 输入门面插入纯文本(官方 plain-text 通道;不存在或异常视作未应用)。
4408
+ * @author ddj 2026年09月11号 */
4409
+ function safeInsertText(input, text, span) {
4410
+ if (typeof input.insertText !== "function") return false;
4411
+ try {
4412
+ return input.insertText(text, span) === true;
4413
+ } catch {
4414
+ return false;
4415
+ }
4416
+ }
4417
+ /**
4418
+ * 读输入框当前光标(detect 坐标);门面缺失/异常返回 null(由调用方回落到文档末尾)。
4419
+ * @author ddj 2026年09月11号
4420
+ * @param input 输入门面
4421
+ * @returns caretSpan 结果或 null
4422
+ */
4423
+ function caretOf(input) {
4424
+ if (typeof input.caretSpan !== "function") return null;
4425
+ try {
4426
+ return input.caretSpan() ?? null;
4427
+ } catch {
4428
+ return null;
4429
+ }
4430
+ }
4431
+ /**
4432
+ * 创建「添加到对话」动作集(apply 阶段构建一次,随 props 传给 EditorView)。
4433
+ *
4434
+ * 插入位置 = 输入框**当前光标**(`caretSpan()`,detect 坐标;无光标提示时回落文档末尾),
4435
+ * 不再固定追加到最末尾。坐标一律用 **detect 投影**(见 detectEndOf 的缺陷说明);
4436
+ * 两条写入通道都失败时**不写任何内容**并返回 'failed' —— 旧实现在此处退回 `setDraft`
4437
+ * 整篇重建,会把既有 chip 全部销毁(连续添加多个引用即触发)。
4438
+ * @author ddj 2026年08月25号 / 2026年09月11号
4439
+ * @param ctx 客户端服务上下文(sessions + conversation)
4440
+ * @returns 动作集
4441
+ */
4442
+ function createAddToConversation(ctx) {
4443
+ const appendReference = async (sessionId, path, range, appearance) => {
4444
+ const input = inputFor(ctx, sessionId);
4445
+ if (!input) return "unavailable";
4446
+ const cur = draftCursor(input);
4447
+ if (!cur) return "unavailable";
4448
+ const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range, appearance);
4449
+ const at = insertOffsetOf(caretOf(input), detectEndOf(cur));
4450
+ const span = {
4451
+ start: at,
4452
+ end: at,
4453
+ draftRev: cur.draftRev
4454
+ };
4455
+ if (safeInsert(input, reference, span)) return "ok";
4456
+ const pad = paddingAt(cur, at);
4457
+ if (safeInsertText(input, (pad.lead ? " " : "") + mention + (pad.tail ? " " : ""), span)) return "busy";
4458
+ return "failed";
4459
+ };
4460
+ /**
4461
+ * 追加纯文本(AI 深度分析任务注入):走官方 plain-text 通道插到当前光标。
4462
+ * 失败语义与 appendReference 一致:宁可不写也不整篇重建(保护既有 chip)。
4463
+ * @author ddj 2026年09月23号
4464
+ * @param sessionId 会话 id
4465
+ * @param text 待注入文本
4466
+ * @returns 注入结果(ok / unavailable / failed)
4467
+ */
4468
+ const appendText = async (sessionId, text) => {
4469
+ const input = inputFor(ctx, sessionId);
4470
+ if (!input) return "unavailable";
4471
+ const cur = draftCursor(input);
4472
+ if (!cur) return "unavailable";
4473
+ const at = insertOffsetOf(caretOf(input), detectEndOf(cur));
4474
+ return safeInsertText(input, text, {
4475
+ start: at,
4476
+ end: at,
4477
+ draftRev: cur.draftRev
4478
+ }) ? "ok" : "failed";
4479
+ };
4480
+ /**
4481
+ * 新建独立会话(02-deep-session-prompt:旁路任务不污染当前对话)。
4482
+ * 闭包持有真服务 ctx(面板 SidebarCtx 无 .get,会话操作必须经此门面);
4483
+ * 新版仅返回已登记身份,视图选择由宿主负责;旧版保留可用的 open/select。
4484
+ * @author ddj 2026年10月28号
4485
+ * @author ddj 2026年09月23号
4486
+ * @param cwd 新会话工作区目录(与当前会话同工作区)
4487
+ * @returns 新会话结果(失败供降级)
4488
+ */
4489
+ const startDraftSession = async (cwd) => {
4490
+ const result = await newDraftSession(ctx, cwd);
4491
+ if (typeof ctx.get("sessions")?.retain === "function") return result;
4492
+ if (result.ok && result.id) try {
4493
+ const sessions = ctx.get("sessions");
4494
+ sessions?.materializeScope?.(result.id);
4495
+ const title = "AI 深度分析 · " + (String(cwd ?? "").split(/[\\/]/).pop() || "SVN 变更");
4496
+ sessions?.manager?.rename?.(result.id, title);
4497
+ sessions?.manager?.select?.(result.id);
4498
+ sessions?.open?.(result.id);
4499
+ } catch {}
4500
+ return result;
4501
+ };
4502
+ /**
4503
+ * 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
4504
+ * 新版持有目标 Controller 到 prompt 结束;旧版按能力选择唯一通道,不跨通道重发。
4505
+ * @author ddj 2026年10月29号
4506
+ * @param sessionId 目标会话 id
4507
+ * @param text 任务文本
4508
+ * @returns 同一次调用的历史boolean语义,不能作为受理事实
4509
+ */
4510
+ const sendTask = async (sessionId, text) => (await sendTaskResult(sessionId, text)).legacyOk;
4511
+ /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns Detailed evidence from exactly one selected transport. */
4512
+ async function sendTaskResult(sessionId, text) {
4513
+ if (!sessionId) return {
4514
+ status: "unavailable",
4515
+ legacyOk: false
4516
+ };
4517
+ try {
4518
+ const sessions = ctx.get("sessions");
4519
+ if (typeof sessions?.retain === "function") return await sendOwned(ctx, sessions, sessionId, text);
4520
+ return await sendLegacy(ctx, sessionId, text);
4521
+ } catch {
4522
+ return {
4523
+ status: "unavailable",
4524
+ legacyOk: false
4525
+ };
4526
+ }
4527
+ }
4528
+ return {
4529
+ appendReference,
4530
+ appendText,
4531
+ startDraftSession,
4532
+ sendTask,
4533
+ sendTaskResult
4534
+ };
4535
+ }
4536
+ //#endregion
4537
+ //#region src/client/openFlow.ts
4538
+ /**
4539
+ * dsh-vscode-mode client — 深链打开规则引擎(资源管理器右键 / Unity 外部编辑器落地)。
4540
+ * 规则:文件夹 ⊂ 某已注册工作区 → 页面内弹窗二选一(使用最近的工作区 / 新建工作区);
4541
+ * 文件夹不在任何工作区 → 直达最近对话所在工作区新增;文件 → 最近对话 + 编辑器展开
4542
+ * (无任何会话 → 以文件父目录注册工作区引导创建)。首路径驱动规则,其余路径:文件进
4543
+ * 编辑器、文件夹补引用。纯函数可单测;编排依赖经 OpenDeps 注入(mock 可测)。
4544
+ * 作者 ddj 2026-09-07
4545
+ */
4546
+ /** 等列表/输入就绪的节奏。 */
4547
+ const READY_TIMEOUT_MS = 15e3;
4548
+ const READY_TICK_MS = 300;
4549
+ const REF_RETRY_MAX = 20;
4550
+ const REF_RETRY_MS = 300;
4551
+ /**
4552
+ * 路径规范化:`\→/`、小写、去尾分隔符(Windows 大小写不敏感前缀匹配用)。
4553
+ * @author ddj 2026年09月07号
4554
+ */
4555
+ function normPath(p) {
4556
+ return String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4557
+ }
4558
+ /**
4559
+ * 目录包含判定:child === root 或 child 位于 root 之下。
4560
+ * @author ddj 2026年09月07号
4561
+ */
4562
+ function containsPath(root, child) {
4563
+ const r = normPath(root);
4564
+ const c = normPath(child);
4565
+ return c === r || c.startsWith(r + "/");
4566
+ }
4567
+ /**
4568
+ * 父目录(文件引导创建工作区用;根路径回退自身)。
4569
+ * @author ddj 2026年09月07号
4570
+ */
4571
+ function dirnameOf(p) {
4572
+ const norm = String(p ?? "").replace(/[\\/]+$/, "");
4573
+ const cut = Math.max(norm.lastIndexOf("/"), norm.lastIndexOf("\\"));
4574
+ return cut > 0 ? norm.slice(0, cut) : norm;
4575
+ }
4576
+ /**
4577
+ * 最近一次对话:排除子代理行,updatedAt 最新优先。
4578
+ * @author ddj 2026年09月07号
4579
+ */
4580
+ function recentSession(byId, ids) {
4581
+ return (ids ?? Object.keys(byId)).map((id) => byId[id]).filter((row) => row && row.origin !== "subagent").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
4582
+ }
4583
+ /**
4584
+ * 文件夹命中的已注册工作区(前缀包含),updatedAt 新者优先。
4585
+ * @author ddj 2026年09月07号
4586
+ */
4587
+ function matchWorkspaces(items, folder) {
4588
+ return items.filter((ws) => containsPath(ws.path, folder));
4589
+ }
4590
+ /**
4591
+ * 规则分派:目录+命中工作区 → choose;目录未命中 → folderNew(以该文件夹为根注册新工作区,
4592
+ * 用户确认:一律取右键的文件夹本身);文件有会话 → fileRecent;文件无会话 → bootstrap;
4593
+ * 路径缺失 → abort。
4594
+ * @author ddj 2026年09月08号
4595
+ */
4596
+ function pickRule(kind, matched, hasSession) {
4597
+ if (kind === "missing") return "abort";
4598
+ if (kind === "directory") return matched ? "choose" : "folderNew";
4599
+ return hasSession ? "fileRecent" : "bootstrap";
4600
+ }
4601
+ /**
4602
+ * 深链打开编排入口:等列表就绪 → 判型 → 规则分派 → 编排 create/open/引用/编辑器。
4603
+ * @author ddj 2026年09月07号
4604
+ * @param ctx 客户端根上下文(sessions/workspaces 服务)
4605
+ * @param params 深链参数(首路径驱动规则)
4606
+ * @param overrides 依赖覆盖(测试注入)
4607
+ */
4608
+ async function openDeepLink(ctx, params, overrides = {}) {
4609
+ const deps = realDeps(ctx, overrides);
4610
+ if (!await waitListsReady(deps)) {
4611
+ deps.notify("DSH 会话列表未就绪,请稍后重试");
4612
+ return false;
4613
+ }
4614
+ const primary = params.paths[0];
4615
+ const kind = await deps.stat(primary).catch(() => "missing");
4616
+ const sess = deps.sessions();
4617
+ const rule = pickRule(kind, matchWorkspaces(deps.workspaces().items, primary).length > 0, Boolean(recentSession(sess.byId, sess.ids)));
4618
+ try {
4619
+ if (rule === "abort") {
4620
+ deps.notify("路径不存在:" + primary);
4621
+ return false;
4622
+ }
4623
+ if (rule === "choose") await runChoose(deps, primary, params);
4624
+ else if (rule === "folderNew") await runFolderNew(deps, primary, params);
4625
+ else if (rule === "fileRecent") await runFileRecent(deps, primary, params);
4626
+ else await runFileBootstrap(deps, primary, params);
4627
+ return true;
4628
+ } catch (error) {
4629
+ deps.notify("打开失败:" + String(error?.message ?? error));
4630
+ return false;
4631
+ }
4632
+ }
4633
+ /** 等待会话与工作区列表就绪(上限 15s;超时按当前态判定)。 */
4634
+ async function waitListsReady(deps) {
4635
+ for (let waited = 0; waited < READY_TIMEOUT_MS; waited += READY_TICK_MS) {
4636
+ if (deps.sessions().ready && deps.workspaces().ready) return true;
4637
+ await sleep(deps, READY_TICK_MS);
4638
+ }
4639
+ return deps.sessions().ready && deps.workspaces().ready;
4640
+ }
4641
+ /** 规则 1:命中工作区 → 弹窗分派(取消即中止)。 */
4642
+ async function runChoose(deps, folder, params) {
4643
+ const best = bestMatch(deps, matchWorkspaces(deps.workspaces().items, folder));
4644
+ const choice = await deps.choose(best?.title ?? "工作区", folder);
4645
+ if (choice === null) throw new Error("用户取消打开");
4646
+ if (choice === "create") return runFolderNew(deps, folder, params);
4647
+ const sessionId = await spawnInWorkspace(deps, (best ?? deps.workspaces().items[0]).workspaceId, void 0);
4648
+ await settleRef(deps, sessionId, folder, "folder");
4649
+ await openChecked(deps, sessionId, null);
4650
+ await openRest(deps, sessionId, params, 1);
4651
+ }
4652
+ /** 规则 3a:文件 → 所属工作区优先(有会话打开其最近会话;无会话新建 + 文件引用);无匹配 → 最近对话。 */
4653
+ async function runFileRecent(deps, file, params) {
4654
+ const ws = pickFileWorkspace(deps, file);
4655
+ if (ws) {
4656
+ const anchor = recentSessionIn(deps, ws);
4657
+ if (anchor) {
4658
+ deps.openSession(anchor.id);
4659
+ await openChecked(deps, anchor.id, file, params.line, params.column);
4660
+ await openRest(deps, anchor.id, params, 1);
4661
+ return;
4662
+ }
4663
+ const sessionId = await spawnInWorkspace(deps, ws.workspaceId, void 0);
4664
+ await settleRef(deps, sessionId, file, "file");
4665
+ await openChecked(deps, sessionId, file, params.line, params.column);
4666
+ await openRest(deps, sessionId, params, 1);
4667
+ return;
4668
+ }
4669
+ const anchor = recentSession(deps.sessions().byId, deps.sessions().ids);
4670
+ if (!anchor) return runFileBootstrap(deps, file, params);
4671
+ deps.openSession(anchor.id);
4672
+ await openChecked(deps, anchor.id, file, params.line, params.column);
4673
+ await openRest(deps, anchor.id, params, 1);
4674
+ }
4675
+ /**
4676
+ * 文件所属工作区:前缀命中的工作区里,优先取有会话且最近活跃的;都无会话取路径最具体的。
4677
+ * @author ddj 2026年09月08号
4678
+ */
4679
+ function pickFileWorkspace(deps, file) {
4680
+ const matches = matchWorkspaces(deps.workspaces().items, file);
4681
+ if (!matches.length) return void 0;
4682
+ const withSessions = matches.filter((ws) => (ws.sessionIds ?? []).some((id) => Boolean(deps.sessions().byId[id])));
4683
+ if (!withSessions.length) return [...matches].sort((a, b) => normPath(b.path).length - normPath(a.path).length)[0];
4684
+ return withSessions.sort((a, b) => latestSessionAt(deps, b) - latestSessionAt(deps, a))[0];
4685
+ }
4686
+ /** 工作区内会话的最近活跃时间(无成员 → 0)。 */
4687
+ function latestSessionAt(deps, ws) {
4688
+ const byId = deps.sessions().byId;
4689
+ return (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0);
4690
+ }
4691
+ /** 工作区内最近一次会话(排除子代理与不在列表中的陈旧行)。 */
4692
+ function recentSessionIn(deps, ws) {
4693
+ const byId = deps.sessions().byId;
4694
+ return (ws.sessionIds ?? []).map((id) => byId[id]).filter((row) => row && row.origin !== "subagent").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
4695
+ }
4696
+ /** 规则 3b:文件 + 无任何会话 → 父目录注册工作区 + 新对话 + 文件引用 + 编辑器打开。 */
4697
+ async function runFileBootstrap(deps, file, params) {
4698
+ const sessionId = await spawnInWorkspace(deps, (await deps.createWorkspace(dirnameOf(file))).workspaceId, void 0);
4699
+ await settleRef(deps, sessionId, file, "file");
4700
+ await openChecked(deps, sessionId, file, params.line, params.column);
4701
+ await openRest(deps, sessionId, params, 1);
4702
+ }
4703
+ /** 规则 1.2:以文件夹注册新工作区 + 新对话 + 文件夹引用 + 编辑页。 */
4704
+ async function runFolderNew(deps, folder, params) {
4705
+ const sessionId = await spawnInWorkspace(deps, (await deps.createWorkspace(folder)).workspaceId, void 0);
4706
+ await settleRef(deps, sessionId, folder, "folder");
4707
+ await openChecked(deps, sessionId, null);
4708
+ await openRest(deps, sessionId, params, 1);
4709
+ }
4710
+ /** 在指定工作区(或回退 cwd)新建对话并置为当前;返回新会话 id。 */
4711
+ async function spawnInWorkspace(deps, workspaceId, fallbackCwd) {
4712
+ const sessionId = await deps.createSession(workspaceId ? { workspaceId } : fallbackCwd ? { cwd: fallbackCwd } : {});
4713
+ deps.openSession(sessionId);
4714
+ return sessionId;
4715
+ }
4716
+ /** 命中工作区集中「最近」的一个(其成员会话 updatedAt 最新者优先)。 */
4717
+ function bestMatch(deps, matches) {
4718
+ const byId = deps.sessions().byId;
4719
+ const score = (ws) => {
4720
+ const sessionLatest = (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0);
4721
+ return Math.max(sessionLatest, Date.parse(ws.updatedAt ?? "") || 0);
4722
+ };
4723
+ return [...matches].sort((a, b) => score(b) - score(a))[0];
4724
+ }
4725
+ /** 引用插入轮询:输入门面就绪前每 300ms 重试(appendReference 忙态已降级视为成功)。 */
4726
+ async function settleRef(deps, sessionId, path, appearance) {
4727
+ for (let attempt = 0; attempt < REF_RETRY_MAX; attempt++) {
4728
+ if (await deps.reference(sessionId, path, appearance)) return;
4729
+ await sleep(deps, REF_RETRY_MS);
4730
+ }
4731
+ throw new Error("引用插入未完成");
4732
+ }
4733
+ /** 其余路径:文件进编辑器(步进),文件夹补文件夹引用。 */
4734
+ async function openRest(deps, sessionId, params, from) {
4735
+ const rest = params.paths.slice(from);
4736
+ for (const path of rest) {
4737
+ const kind = await deps.stat(path).catch(() => "missing");
4738
+ if (kind === "file") await openChecked(deps, sessionId ?? void 0, path);
4739
+ else if (kind === "directory" && sessionId) await settleRef(deps, sessionId, path, "folder");
4740
+ else throw new Error("路径不存在:" + path);
4741
+ }
4742
+ }
4743
+ /** 可注入 sleep。 */
4744
+ function sleep(deps, ms) {
4745
+ return new Promise((resolve) => deps.schedule(resolve, ms));
4746
+ }
4747
+ /** 真实依赖装配(全部来自 ctx 注入服务 + 既有 RPC/弹窗/编辑器入口)。 */
4748
+ function realDeps(ctx, overrides = {}) {
4749
+ const service = (name) => {
4750
+ const get = ctx?.get;
4751
+ return typeof get === "function" ? get.call(ctx, name) : void 0;
4752
+ };
4753
+ const add = createAddToConversation(ctx);
4754
+ return {
4755
+ stat: async (path) => {
4756
+ const result = await rpc("edrv.externalStat", { path });
4757
+ return result.ok ? result.kind : "missing";
4758
+ },
4759
+ workspaces: () => {
4760
+ const snap = service("workspaces")?.list?.getSnapshot?.() ?? {};
4761
+ return {
4762
+ items: snap.items ?? [],
4763
+ ready: snap.phase === "ready"
4764
+ };
4765
+ },
4766
+ sessions: () => {
4767
+ const snap = service("sessions")?.list?.getSnapshot?.() ?? {};
4768
+ return {
4769
+ ids: snap.ids ?? [],
4770
+ byId: snap.byId ?? {},
4771
+ ready: snap.phase !== "pending"
4772
+ };
4773
+ },
4774
+ createSession: async (opts) => {
4775
+ const sessions = service("sessions");
4776
+ if (typeof sessions?.create === "function") try {
4777
+ const created = await sessions.create(opts);
4778
+ const id = typeof created === "string" && created ? created : typeof created?.id === "string" ? created.id : typeof created?.sessionId === "string" ? created.sessionId : "";
4779
+ if (id) return id;
4780
+ } catch {}
4781
+ const draft = await newDraftSession(ctx, opts.cwd);
4782
+ if (draft.ok && draft.id) return draft.id;
4783
+ throw new Error("新建会话失败");
4784
+ },
4785
+ createWorkspace: async (path) => {
4786
+ const ws = await service("workspaces").create({ path });
4787
+ return {
4788
+ workspaceId: ws.workspaceId,
4789
+ title: ws.title
4790
+ };
4791
+ },
4792
+ openSession: (id) => {
4793
+ try {
4794
+ const sessions = service("sessions");
4795
+ sessions?.materializeScope?.(id);
4796
+ sessions?.manager?.select?.(id);
4797
+ sessions?.open?.(id);
4798
+ } catch {}
4799
+ },
4800
+ reference: async (sessionId, path, appearance) => {
4801
+ const outcome = await add.appendReference(sessionId, path, void 0, appearance);
4802
+ return outcome === "ok" || outcome === "busy";
4803
+ },
4804
+ choose: (title, folder) => chooseWorkspace(title, folder),
4805
+ openEditor: requestOpen,
4806
+ schedule: (fn, ms) => setTimeout(fn, ms),
4807
+ notify: (text) => toastDom(text),
4808
+ ...overrides
4809
+ };
4810
+ }
4811
+ /** @private @author ddj 2026年09月28号
4812
+ * Await actual editor completion and propagate failure to the request result.
4813
+ * @param deps Open dependencies.
4814
+ * @param sessionId Target session.
4815
+ * @param path Target file or editor mount.
4816
+ * @param line Optional line.
4817
+ * @param column Optional column.
4818
+ */
4819
+ async function openChecked(deps, sessionId, path, line, column) {
4820
+ if (!await deps.openEditor(path, line, column, sessionId)) throw new Error("编辑器打开未完成:" + (path ?? "工作区"));
4821
+ }
4822
+ //#endregion
4823
+ //#region src/client/externalOpen.ts
4824
+ /**
4825
+ * dsh-vscode-mode client — 外部深链落地(Windows 右键菜单 / Unity 外部编辑器)。
4826
+ * 启动时解析 location.search 的 edrvOpen 深链:跨源 referrer 守卫防外部网页诱导;
4827
+ * 处理完剥离 URL 参数防刷新重开;执行委托 openFlow 打开规则引擎(工作区/对话智能路由)。
4828
+ * 另挂 3s 移交轮询(兼活跃心跳):launcher 投递的打开请求由已打开页面就地执行,避免重复开新页。
4829
+ * 作者 ddj 2026-09-07 / 2026-09-08
4830
+ */
4831
+ /** toast 自隐时长。 */
4832
+ const TOAST_MS = 5e3;
4833
+ /** 深链移交轮询周期(兼活跃心跳)。 */
4834
+ const HANDOFF_POLL_MS = 3e3;
4835
+ /**
4836
+ * 页面隐藏时的降频倍数(3s × 5 = 15s)。
4837
+ * 必须仍短于 host 租约(src/externalHandoff.ts 的 LEASE_MS = 30s):心跳一旦超期,
4838
+ * host 判定该页面已死,launcher 会重复开新页——比多几次 RPC 更糟。
4839
+ */
4840
+ const HIDDEN_POLL_EVERY = 5;
4841
+ /**
4842
+ * 装配外部深链:深链参数缺失时仅保持移交轮询(零额外开销)。
4843
+ * @author ddj 2026年09月07号
4844
+ * @param ctx 客户端根上下文(sessions/workspaces 服务)
4845
+ * @param options 可注入选项
4846
+ */
4847
+ function setupExtOpen(ctx, options = {}) {
4848
+ const dispose = startPresencePoll(ctx);
4849
+ const params = parseOpenParams(options.search ?? (typeof location !== "undefined" ? location.search : ""));
4850
+ if (!params) return dispose;
4851
+ const referrer = options.referrer ?? (typeof document !== "undefined" ? document.referrer : "");
4852
+ if (!referrerAllowed(referrer, options.origin ?? (typeof location !== "undefined" ? location.origin : ""))) {
4853
+ log$2.warn("已忽略跨源深链请求(referrer=" + referrer + ")");
4854
+ return dispose;
4855
+ }
4856
+ stripCurrentUrl();
4857
+ openDeepLink(ctx, params).catch((error) => {
4858
+ log$2.warn("深链打开失败:" + String(error));
4859
+ toastDom("深链打开失败:" + String(error?.message ?? error));
4860
+ });
4861
+ return dispose;
4862
+ }
4863
+ /**
4864
+ * 深链移交轮询:可见时每 3s 向 host 领取待打开请求(兼活跃心跳,launcher 据此决定是否开新页);
4865
+ * 页面隐藏时降到 15s 一次——无人看页面时移交不必保持 3s 精度,但心跳不能停(超期会让 host 判页面已死)。
4866
+ * 页面级单例(window 标记防 HMR 重复);领取到即执行打开规则。
4867
+ * @author ddj 2026年09月08号 / 2026年10月15号
4868
+ * @param ctx 客户端根上下文
4869
+ */
4870
+ function startPresencePoll(ctx) {
4871
+ if (typeof window === "undefined") return () => {};
4872
+ const marker = window;
4873
+ if (typeof marker.__edrvExtPoll === "function") marker.__edrvExtPoll();
4874
+ const generatedId = createRequestId();
4875
+ if (!generatedId) {
4876
+ log$2.warn("安全随机源不可用,已停用外部打开心跳");
4877
+ return () => {};
4878
+ }
4879
+ const clientId = generatedId;
4880
+ let disposed = false;
4881
+ let polling = false;
4882
+ let executing = false;
4883
+ let ticks = 0;
4884
+ /**
4885
+ * 页面隐藏时按倍数跳轮;可见时每次都跑并清零计数。
4886
+ * @author ddj 2026年10月15号
4887
+ * @returns 是否跳过本轮
4888
+ */
4889
+ function hiddenSkip() {
4890
+ if (typeof document === "undefined" || document.visibilityState !== "hidden") {
4891
+ ticks = 0;
4892
+ return false;
4893
+ }
4894
+ ticks += 1;
4895
+ return ticks % HIDDEN_POLL_EVERY !== 0;
4896
+ }
4897
+ /** @private @author ddj 2026年09月28号 Open all requested paths before acknowledging this exact lease. */
4898
+ async function execute(open) {
4899
+ executing = true;
4900
+ try {
4901
+ const success = await openDeepLink(ctx, open);
4902
+ if (!disposed) await rpc("edrv.external.ack", {
4903
+ token: open.token,
4904
+ leaseId: open.leaseId,
4905
+ clientId,
4906
+ success,
4907
+ error: success ? void 0 : "编辑器打开失败或取消"
4908
+ });
4909
+ } finally {
4910
+ executing = false;
4911
+ }
4912
+ }
4913
+ /** @private @author ddj 2026年09月28号 Poll while opening to renew the lease without accepting a second request. */
4914
+ async function tick() {
4915
+ if (disposed || polling) return;
4916
+ polling = true;
4917
+ try {
4918
+ const result = await rpc("edrv.external.pending", {
4919
+ clientId,
4920
+ busy: executing
4921
+ });
4922
+ if (!disposed && result.ok && result.open && !executing) execute(result.open).catch(() => {});
4923
+ } finally {
4924
+ polling = false;
4925
+ }
4926
+ }
4927
+ const timer = window.setInterval(() => {
4928
+ if (hiddenSkip()) return;
4929
+ tick().catch(() => {});
4930
+ }, HANDOFF_POLL_MS);
4931
+ /** @private @author ddj 2026年09月28号 Stop heartbeats and fail outstanding editor waits on plugin disposal. */
4932
+ function dispose() {
4933
+ disposed = true;
4934
+ window.clearInterval(timer);
4935
+ disposeOpenWait();
4936
+ if (marker.__edrvExtPoll === dispose) delete marker.__edrvExtPoll;
4937
+ }
4938
+ marker.__edrvExtPoll = dispose;
4939
+ tick().catch(() => {});
4940
+ return dispose;
4941
+ }
4942
+ /**
4943
+ * 跨源守卫:referrer 为空(launcher/Unity 直开)或同源 → 允许。
4944
+ * @author ddj 2026年09月07号
4945
+ * @param referrer 文档 referrer
4946
+ * @param origin 当前页面源
4947
+ * @returns 是否允许处理深链
4948
+ */
4949
+ function referrerAllowed(referrer, origin) {
4950
+ if (!referrer) return true;
4951
+ try {
4952
+ return new URL(referrer).origin === origin;
4953
+ } catch {
4954
+ return false;
4955
+ }
4956
+ }
4957
+ /**
4958
+ * 剥离 URL 中的深链参数(保留其余参数与 hash,防刷新重开)。
4959
+ * @author ddj 2026年09月07号
4960
+ * @param href 完整 URL
4961
+ * @returns 清理后的 URL(pathname+search+hash)
4962
+ */
4963
+ function stripOpenHref(href) {
4964
+ const url = new URL(href);
4965
+ for (const key of EDRV_PARAM_KEYS) url.searchParams.delete(key);
4966
+ return url.pathname + url.search + url.hash;
4967
+ }
4968
+ /** 清理当前页面 URL(best-effort,失败不阻塞打开)。 */
4969
+ function stripCurrentUrl() {
4970
+ try {
4971
+ if (typeof location === "undefined" || typeof history === "undefined") return;
4972
+ const next = stripOpenHref(location.href);
4973
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, "", next);
4974
+ } catch {}
4975
+ }
4976
+ /**
4977
+ * 轻量 toast(编辑器未挂载也能提示;5s 自隐;openFlow 通知与错误呈现共用)。
4978
+ * @author ddj 2026年09月07号
4979
+ * @param text 提示文本
4980
+ */
4981
+ function toastDom(text) {
4982
+ if (typeof document === "undefined") return;
4983
+ const toast = document.createElement("div");
4984
+ toast.textContent = text;
4985
+ toast.setAttribute("style", [
4986
+ "position:fixed",
4987
+ "left:50%",
4988
+ "bottom:28px",
4989
+ "transform:translateX(-50%)",
4990
+ "z-index:99999",
4991
+ "padding:8px 14px",
4992
+ "border-radius:8px",
4993
+ "max-width:80vw",
4994
+ "background:#2a2a2a",
4995
+ "color:#ddd",
4996
+ "border:1px solid #444",
4997
+ "font-size:12px",
4998
+ "box-shadow:0 4px 16px rgba(0,0,0,.35)"
4999
+ ].join(";"));
5000
+ document.body?.appendChild(toast);
5001
+ setTimeout(() => toast.remove(), TOAST_MS);
5002
+ }
5003
+ //#endregion
3724
5004
  //#region src/client/ui/ReviewAllConfirm.ts
3725
5005
  /**
3726
5006
  * @public
@@ -3731,7 +5011,7 @@ window.__ModuleLoader__.load({
3731
5011
  */
3732
5012
  function ReviewAllConfirm(props) {
3733
5013
  const action = props.reject ? "Undo All" : "Keep All";
3734
- const warning = props.reject ? "撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。" : "只标记待审修改为保留,不改写文件。";
5014
+ const warning = props.reject ? "撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。" : "只标记待审修改为保留,不改写文件;个别条目若已变化或需在活跃端判定会被跳过并留在列表里,可逐个处理。";
3735
5015
  const footer = react$1.default.createElement(react$1.default.Fragment, null, react$1.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, {
3736
5016
  variant: "ghost",
3737
5017
  onClick: props.onCancel,
@@ -4214,6 +5494,34 @@ window.__ModuleLoader__.load({
4214
5494
  return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key);
4215
5495
  }
4216
5496
  /**
5497
+ * @author ddj 2026年11月3号
5498
+ * @description: 本页待审条目占用的路径键:路径本身与其身份各一份。
5499
+ * @param page 审查页
5500
+ * @returns 待审覆盖的路径键集合
5501
+ */
5502
+ function pendingCoverKeys(page) {
5503
+ const keys = /* @__PURE__ */ new Set();
5504
+ for (const item of page?.items ?? []) {
5505
+ if (!item.id.startsWith("pending:")) continue;
5506
+ keys.add(diffPathKey(item.currentPath || item.path));
5507
+ keys.add(diffPathKey(item.id.slice(8)));
5508
+ }
5509
+ return keys;
5510
+ }
5511
+ /**
5512
+ * @public
5513
+ * @author ddj 2026年11月3号
5514
+ * @description: 取本页"已有权威通道在管"的路径键:官方汇总覆盖集 + 待审条目。
5515
+ * 编辑器与差异汇总必须同口径,否则同一改动在列表里被压掉、在编辑器里却仍然画得出来并可以点。
5516
+ * @param page 审查页
5517
+ * @returns 路径比较键集合
5518
+ */
5519
+ function coveredPathKeys(page) {
5520
+ const keys = pendingCoverKeys(page);
5521
+ for (const path of page?.overview?.coveredPaths ?? []) keys.add(diffPathKey(path));
5522
+ return keys;
5523
+ }
5524
+ /**
4217
5525
  * @author ddj 2026年10月30号
4218
5526
  * @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
4219
5527
  * @param page 审查页
@@ -4221,12 +5529,9 @@ window.__ModuleLoader__.load({
4221
5529
  */
4222
5530
  function pendingRowsOf(page) {
4223
5531
  const rows = [];
4224
- const covered = /* @__PURE__ */ new Set();
4225
5532
  for (const item of page?.items ?? []) {
4226
5533
  if (!item.id.startsWith("pending:")) continue;
4227
5534
  const path = item.currentPath || item.path;
4228
- covered.add(diffPathKey(path));
4229
- covered.add(diffPathKey(item.id.slice(8)));
4230
5535
  rows.push({
4231
5536
  path,
4232
5537
  label: item.display || path,
@@ -4239,7 +5544,7 @@ window.__ModuleLoader__.load({
4239
5544
  }
4240
5545
  return {
4241
5546
  rows,
4242
- covered
5547
+ covered: pendingCoverKeys(page)
4243
5548
  };
4244
5549
  }
4245
5550
  /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
@@ -145912,10 +147217,10 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
145912
147217
  var require_useMergeRef = /* @__PURE__ */ __commonJSMin(((exports) => {
145913
147218
  Object.defineProperty(exports, "__esModule", { value: true });
145914
147219
  exports.useMergeRefs = void 0;
145915
- var React$69 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
147220
+ var React$68 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
145916
147221
  var assignRef_1 = require_assignRef();
145917
147222
  var useRef_1 = require_useRef();
145918
- var useIsomorphicLayoutEffect = typeof window !== "undefined" ? React$69.useLayoutEffect : React$69.useEffect;
147223
+ var useIsomorphicLayoutEffect = typeof window !== "undefined" ? React$68.useLayoutEffect : React$68.useEffect;
145919
147224
  var currentValues = /* @__PURE__ */ new WeakMap();
145920
147225
  /**
145921
147226
  * Merges two or more refs together providing a single interface to set their value
@@ -146195,7 +147500,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146195
147500
  Object.defineProperty(exports, "__esModule", { value: true });
146196
147501
  exports.sidecar = void 0;
146197
147502
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
146198
- var React$68 = tslib_1.__importStar(require("react"));
147503
+ var React$67 = tslib_1.__importStar(require("react"));
146199
147504
  var hook_1 = require_hook$1();
146200
147505
  function sidecar(importer, errorComponent) {
146201
147506
  var ErrorCase = function() {
@@ -146204,7 +147509,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146204
147509
  return function Sidecar(props) {
146205
147510
  var _a = (0, hook_1.useSidecar)(importer, props.sideCar), Car = _a[0];
146206
147511
  if (_a[1] && errorComponent) return ErrorCase;
146207
- return Car ? React$68.createElement(Car, tslib_1.__assign({}, props)) : null;
147512
+ return Car ? React$67.createElement(Car, tslib_1.__assign({}, props)) : null;
146208
147513
  };
146209
147514
  }
146210
147515
  exports.sidecar = sidecar;
@@ -146319,7 +147624,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146319
147624
  Object.defineProperty(exports, "__esModule", { value: true });
146320
147625
  exports.renderCar = void 0;
146321
147626
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
146322
- var React$67 = tslib_1.__importStar(require("react"));
147627
+ var React$66 = tslib_1.__importStar(require("react"));
146323
147628
  var react_1 = require("react");
146324
147629
  function renderCar(WrappedComponent, defaults) {
146325
147630
  function State(_a) {
@@ -146332,9 +147637,9 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146332
147637
  });
146333
147638
  return null;
146334
147639
  }, []);
146335
- return React$67.createElement(WrappedComponent, tslib_1.__assign({}, props, { children: renderTarget }));
147640
+ return React$66.createElement(WrappedComponent, tslib_1.__assign({}, props, { children: renderTarget }));
146336
147641
  }
146337
- var Children = React$67.memo(function(_a) {
147642
+ var Children = React$66.memo(function(_a) {
146338
147643
  var stateRef = _a.stateRef, defaultState = _a.defaultState, children = _a.children;
146339
147644
  var _b = (0, react_1.useState)(defaultState.current), state = _b[0], setState = _b[1];
146340
147645
  (0, react_1.useEffect)(function() {
@@ -146345,14 +147650,14 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146345
147650
  return true;
146346
147651
  });
146347
147652
  return function Combiner(props) {
146348
- var defaultState = React$67.useRef(defaults(props));
146349
- var ref = React$67.useRef(function(state) {
147653
+ var defaultState = React$66.useRef(defaults(props));
147654
+ var ref = React$66.useRef(function(state) {
146350
147655
  return defaultState.current = state;
146351
147656
  });
146352
- return React$67.createElement(React$67.Fragment, null, React$67.createElement(State, {
147657
+ return React$66.createElement(React$66.Fragment, null, React$66.createElement(State, {
146353
147658
  stateRef: ref,
146354
147659
  props
146355
- }), React$67.createElement(Children, {
147660
+ }), React$66.createElement(Children, {
146356
147661
  stateRef: ref,
146357
147662
  defaultState,
146358
147663
  children: props.children
@@ -146367,13 +147672,13 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146367
147672
  Object.defineProperty(exports, "__esModule", { value: true });
146368
147673
  exports.exportSidecar = void 0;
146369
147674
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
146370
- var React$66 = tslib_1.__importStar(require("react"));
147675
+ var React$65 = tslib_1.__importStar(require("react"));
146371
147676
  var SideCar = function(_a) {
146372
147677
  var sideCar = _a.sideCar, rest = tslib_1.__rest(_a, ["sideCar"]);
146373
147678
  if (!sideCar) throw new Error("Sidecar: please provide `sideCar` property to import the right car");
146374
147679
  var Target = sideCar.read();
146375
147680
  if (!Target) throw new Error("Sidecar medium not found");
146376
- return React$66.createElement(Target, tslib_1.__assign({}, rest));
147681
+ return React$65.createElement(Target, tslib_1.__assign({}, rest));
146377
147682
  };
146378
147683
  SideCar.isSideCarExport = true;
146379
147684
  function exportSidecar(medium, exported) {
@@ -146449,7 +147754,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146449
147754
  Object.defineProperty(exports, "__esModule", { value: true });
146450
147755
  exports.RemoveScroll = void 0;
146451
147756
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
146452
- var React$65 = tslib_1.__importStar(require("react"));
147757
+ var React$64 = tslib_1.__importStar(require("react"));
146453
147758
  var constants_1 = require_constants();
146454
147759
  var use_callback_ref_1 = require_es5$5();
146455
147760
  var medium_1 = require_medium();
@@ -146457,9 +147762,9 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146457
147762
  /**
146458
147763
  * Removes scrollbar from the page and contain the scroll within the Lock
146459
147764
  */
146460
- var RemoveScroll = React$65.forwardRef(function(props, parentRef) {
146461
- var ref = React$65.useRef(null);
146462
- var _a = React$65.useState({
147765
+ var RemoveScroll = React$64.forwardRef(function(props, parentRef) {
147766
+ var ref = React$64.useRef(null);
147767
+ var _a = React$64.useState({
146463
147768
  onScrollCapture: nothing,
146464
147769
  onWheelCapture: nothing,
146465
147770
  onTouchMoveCapture: nothing
@@ -146482,7 +147787,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146482
147787
  var SideCar = sideCar;
146483
147788
  var containerRef = (0, use_callback_ref_1.useMergeRefs)([ref, parentRef]);
146484
147789
  var containerProps = tslib_1.__assign(tslib_1.__assign({}, rest), callbacks);
146485
- return React$65.createElement(React$65.Fragment, null, enabled && React$65.createElement(SideCar, {
147790
+ return React$64.createElement(React$64.Fragment, null, enabled && React$64.createElement(SideCar, {
146486
147791
  sideCar: medium_1.effectCar,
146487
147792
  removeScrollBar,
146488
147793
  shards,
@@ -146493,7 +147798,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146493
147798
  allowPinchZoom: !!allowPinchZoom,
146494
147799
  lockRef: ref,
146495
147800
  gapMode
146496
- }), forwardProps ? React$65.cloneElement(React$65.Children.only(children), tslib_1.__assign(tslib_1.__assign({}, containerProps), { ref: containerRef })) : React$65.createElement(Container, tslib_1.__assign({}, containerProps, {
147801
+ }), forwardProps ? React$64.cloneElement(React$64.Children.only(children), tslib_1.__assign(tslib_1.__assign({}, containerProps), { ref: containerRef })) : React$64.createElement(Container, tslib_1.__assign({}, containerProps, {
146497
147802
  className,
146498
147803
  ref: containerRef
146499
147804
  }), children));
@@ -146572,7 +147877,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146572
147877
  var require_hook = /* @__PURE__ */ __commonJSMin(((exports) => {
146573
147878
  Object.defineProperty(exports, "__esModule", { value: true });
146574
147879
  exports.styleHookSingleton = void 0;
146575
- var React$64 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
147880
+ var React$63 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
146576
147881
  var singleton_1 = require_singleton();
146577
147882
  /**
146578
147883
  * creates a hook to control style singleton
@@ -146586,7 +147891,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146586
147891
  var styleHookSingleton = function() {
146587
147892
  var sheet = (0, singleton_1.stylesheetSingleton)();
146588
147893
  return function(styles, isDynamic) {
146589
- React$64.useEffect(function() {
147894
+ React$63.useEffect(function() {
146590
147895
  sheet.add(styles);
146591
147896
  return function() {
146592
147897
  sheet.remove();
@@ -146691,7 +147996,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146691
147996
  var require_component = /* @__PURE__ */ __commonJSMin(((exports) => {
146692
147997
  Object.defineProperty(exports, "__esModule", { value: true });
146693
147998
  exports.RemoveScrollBar = exports.useLockAttribute = exports.lockAttribute = void 0;
146694
- var React$63 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
147999
+ var React$62 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports)).__importStar(require("react"));
146695
148000
  var react_style_singleton_1 = require_es5$2();
146696
148001
  var constants_1 = require_constants();
146697
148002
  var utils_1 = require_utils();
@@ -146711,7 +148016,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146711
148016
  return isFinite(counter) ? counter : 0;
146712
148017
  };
146713
148018
  var useLockAttribute = function() {
146714
- React$63.useEffect(function() {
148019
+ React$62.useEffect(function() {
146715
148020
  document.body.setAttribute(exports.lockAttribute, (getCurrentUseCounter() + 1).toString());
146716
148021
  return function() {
146717
148022
  var newCounter = getCurrentUseCounter() - 1;
@@ -146727,10 +148032,10 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146727
148032
  var RemoveScrollBar = function(_a) {
146728
148033
  var noRelative = _a.noRelative, noImportant = _a.noImportant, _b = _a.gapMode, gapMode = _b === void 0 ? "margin" : _b;
146729
148034
  (0, exports.useLockAttribute)();
146730
- var gap = React$63.useMemo(function() {
148035
+ var gap = React$62.useMemo(function() {
146731
148036
  return (0, utils_1.getGapWidth)(gapMode);
146732
148037
  }, [gapMode]);
146733
- return React$63.createElement(Style, { styles: getStyles(gap, !noRelative, gapMode, !noImportant ? "!important" : "") });
148038
+ return React$62.createElement(Style, { styles: getStyles(gap, !noRelative, gapMode, !noImportant ? "!important" : "") });
146734
148039
  };
146735
148040
  exports.RemoveScrollBar = RemoveScrollBar;
146736
148041
  }));
@@ -146892,7 +148197,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146892
148197
  Object.defineProperty(exports, "__esModule", { value: true });
146893
148198
  exports.RemoveScrollSideCar = exports.getDeltaXY = exports.getTouchXY = void 0;
146894
148199
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
146895
- var React$62 = tslib_1.__importStar(require("react"));
148200
+ var React$61 = tslib_1.__importStar(require("react"));
146896
148201
  var react_remove_scroll_bar_1 = require_es5$1();
146897
148202
  var react_style_singleton_1 = require_es5$2();
146898
148203
  var aggresiveCapture_1 = require_aggresiveCapture();
@@ -146917,16 +148222,16 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146917
148222
  var idCounter = 0;
146918
148223
  var lockStack = [];
146919
148224
  function RemoveScrollSideCar(props) {
146920
- var shouldPreventQueue = React$62.useRef([]);
146921
- var touchStartRef = React$62.useRef([0, 0]);
146922
- var activeAxis = React$62.useRef();
146923
- var id = React$62.useState(idCounter++)[0];
146924
- var Style = React$62.useState(react_style_singleton_1.styleSingleton)[0];
146925
- var lastProps = React$62.useRef(props);
146926
- React$62.useEffect(function() {
148225
+ var shouldPreventQueue = React$61.useRef([]);
148226
+ var touchStartRef = React$61.useRef([0, 0]);
148227
+ var activeAxis = React$61.useRef();
148228
+ var id = React$61.useState(idCounter++)[0];
148229
+ var Style = React$61.useState(react_style_singleton_1.styleSingleton)[0];
148230
+ var lastProps = React$61.useRef(props);
148231
+ React$61.useEffect(function() {
146927
148232
  lastProps.current = props;
146928
148233
  }, [props]);
146929
- React$62.useEffect(function() {
148234
+ React$61.useEffect(function() {
146930
148235
  if (props.inert) {
146931
148236
  document.body.classList.add("block-interactivity-".concat(id));
146932
148237
  var allow_1 = tslib_1.__spreadArray([props.lockRef.current], (props.shards || []).map(extractRef), true).filter(Boolean);
@@ -146945,7 +148250,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146945
148250
  props.lockRef.current,
146946
148251
  props.shards
146947
148252
  ]);
146948
- var shouldCancelEvent = React$62.useCallback(function(event, parent) {
148253
+ var shouldCancelEvent = React$61.useCallback(function(event, parent) {
146949
148254
  if ("touches" in event && event.touches.length === 2 || event.type === "wheel" && event.ctrlKey) return !lastProps.current.allowPinchZoom;
146950
148255
  var touch = (0, exports.getTouchXY)(event);
146951
148256
  var touchStart = touchStartRef.current;
@@ -146971,7 +148276,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146971
148276
  var cancelingAxis = activeAxis.current || currentAxis;
146972
148277
  return (0, handleScroll_1.handleScroll)(cancelingAxis, parent, event, cancelingAxis === "h" ? deltaX : deltaY, true);
146973
148278
  }, []);
146974
- var shouldPrevent = React$62.useCallback(function(_event) {
148279
+ var shouldPrevent = React$61.useCallback(function(_event) {
146975
148280
  var event = _event;
146976
148281
  if (!lockStack.length || lockStack[lockStack.length - 1] !== Style) return;
146977
148282
  var delta = "deltaY" in event ? (0, exports.getDeltaXY)(event) : (0, exports.getTouchXY)(event);
@@ -146991,7 +148296,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
146991
148296
  }
146992
148297
  }
146993
148298
  }, []);
146994
- var shouldCancel = React$62.useCallback(function(name, delta, target, should) {
148299
+ var shouldCancel = React$61.useCallback(function(name, delta, target, should) {
146995
148300
  var event = {
146996
148301
  name,
146997
148302
  delta,
@@ -147006,17 +148311,17 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
147006
148311
  });
147007
148312
  }, 1);
147008
148313
  }, []);
147009
- var scrollTouchStart = React$62.useCallback(function(event) {
148314
+ var scrollTouchStart = React$61.useCallback(function(event) {
147010
148315
  touchStartRef.current = (0, exports.getTouchXY)(event);
147011
148316
  activeAxis.current = void 0;
147012
148317
  }, []);
147013
- var scrollWheel = React$62.useCallback(function(event) {
148318
+ var scrollWheel = React$61.useCallback(function(event) {
147014
148319
  shouldCancel(event.type, (0, exports.getDeltaXY)(event), event.target, shouldCancelEvent(event, props.lockRef.current));
147015
148320
  }, []);
147016
- var scrollTouchMove = React$62.useCallback(function(event) {
148321
+ var scrollTouchMove = React$61.useCallback(function(event) {
147017
148322
  shouldCancel(event.type, (0, exports.getTouchXY)(event), event.target, shouldCancelEvent(event, props.lockRef.current));
147018
148323
  }, []);
147019
- React$62.useEffect(function() {
148324
+ React$61.useEffect(function() {
147020
148325
  lockStack.push(Style);
147021
148326
  props.setCallbacks({
147022
148327
  onScrollCapture: scrollWheel,
@@ -147036,7 +148341,7 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
147036
148341
  };
147037
148342
  }, []);
147038
148343
  var removeScrollBar = props.removeScrollBar, inert = props.inert;
147039
- return React$62.createElement(React$62.Fragment, null, inert ? React$62.createElement(Style, { styles: generateStyle(id) }) : null, removeScrollBar ? React$62.createElement(react_remove_scroll_bar_1.RemoveScrollBar, {
148344
+ return React$61.createElement(React$61.Fragment, null, inert ? React$61.createElement(Style, { styles: generateStyle(id) }) : null, removeScrollBar ? React$61.createElement(react_remove_scroll_bar_1.RemoveScrollBar, {
147040
148345
  noRelative: props.noRelative,
147041
148346
  gapMode: props.gapMode
147042
148347
  }) : null);
@@ -147068,11 +148373,11 @@ For more info please visit our website: https://redi.wzhu.dev/en-US/docs/faq#imp
147068
148373
  var require_Combination = /* @__PURE__ */ __commonJSMin(((exports) => {
147069
148374
  Object.defineProperty(exports, "__esModule", { value: true });
147070
148375
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
147071
- var React$61 = tslib_1.__importStar(require("react"));
148376
+ var React$60 = tslib_1.__importStar(require("react"));
147072
148377
  var UI_1 = require_UI();
147073
148378
  var sidecar_1 = tslib_1.__importDefault(require_sidecar());
147074
- var ReactRemoveScroll = React$61.forwardRef(function(props, ref) {
147075
- return React$61.createElement(UI_1.RemoveScroll, tslib_1.__assign({}, props, {
148379
+ var ReactRemoveScroll = React$60.forwardRef(function(props, ref) {
148380
+ return React$60.createElement(UI_1.RemoveScroll, tslib_1.__assign({}, props, {
147076
148381
  ref,
147077
148382
  sideCar: sidecar_1.default
147078
148383
  }));
@@ -382739,6 +384044,8 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
382739
384044
  const SIDECAR_KEEP_TITLE = "标记这些历史改动为已看过:只改记录,不改写磁盘文件,也不影响官方审查。";
382740
384045
  /** 非官方 Undo 的作用域说明:走原有回滚校验写回磁盘,但不影响官方记录。 */
382741
384046
  const SIDECAR_UNDO_TITLE = "撤销这些历史改动(写回受回滚校验保护):只影响这份历史记录,不影响官方审查。";
384047
+ /** 非官方 Keep 之后的完成态:该通道 Keep 不写盘,不能沿用「已采纳」这种会让人以为改过文件的措辞。 */
384048
+ const SIDECAR_KEEP_DONE = "已标记为看过(未改动文件)";
382742
384049
  /** 当前端没有该会话时,差异栏里给中性说明而不是红色报错。 */
382743
384050
  const SESSION_DOWN_NOTICE = "这个会话没在当前端激活,官方审查数据暂时读不到;下面的历史改动仍可查看、Keep、Undo。";
382744
384051
  /**
@@ -384506,641 +385813,6 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
384506
385813
  localStorage.setItem(key, text);
384507
385814
  } catch (error) {}
384508
385815
  }
384509
- //#endregion
384510
- //#region src/client/addToConversation.ts
384511
- /**
384512
- * dsh-vscode-mode client — 把文件/选中内容注入会话对话输入框(composer draft)。
384513
- * 复用 DSH 官方输入门面:ctx.conversation.input.for(sessions.scope(sessionId)) → SessionInput。
384514
- * 引用 chip:insertReference(reference source,发送时序列化为 @path 文本);忙态降级为纯文本 setDraft。
384515
- * 纯函数部分 node 可单测。
384516
- * 作者 ddj 2026-08-25
384517
- */
384518
- /** 把追加引用结果映射为可读文案(ok 用 okText;busy 提示已降级纯文本;unavailable 提示不可用)。
384519
- * @author ddj 2026年09月03号 / 2026年09月11号
384520
- * @param outcome 追加结果状态
384521
- * @param okText 成功文案(如「已添加文件引用」/「已添加文件夹引用」)
384522
- * @returns 状态栏/通知用文案
384523
- */
384524
- function statusOfAdd(outcome, okText) {
384525
- if (outcome === "ok") return okText;
384526
- if (outcome === "busy") return okText + "(已降级纯文本)";
384527
- if (outcome === "failed") return "添加引用失败(输入框忙或未就绪,请重试)";
384528
- return "无法添加到对话(无会话或输入框不可用)";
384529
- }
384530
- /** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
384531
- const REF_SOURCE = "reference";
384532
- /**
384533
- * 把 clipboard 投影的末尾折算成 **detect 投影**的末尾。
384534
- *
384535
- * ⚠️ 两个投影对 chip 的计长不同,混用会直接毁掉已插入的引用(真实缺陷,勿改回 `draft.length`):
384536
- * DSH 的 `$composerLayout()` 中 chip 在 detect 投影里只贡献 **1 个 U+FFFC**,
384537
- * 在 clipboard 投影里贡献 `clipboardText` 全文
384538
- * (`pushLeaf('chip', kid, '', kid.getTextContent())`)。
384539
- * 而 `insertReference(ref, span)` 的 span 走 detect 坐标并经 `selectSpan` 校验:
384540
- * `span.end > layout.detectLength` 时返回 null → 插入失败。
384541
- *
384542
- * 旧实现直接用 `draft.length`(clipboard 长度)当 detect 起点:草稿里一旦有 chip,
384543
- * 该值就超出 detect 长度 → 插入被拒 → 落入 `setDraft` 兜底,
384544
- * 而 `setDraft` 会整篇重建并剔除 U+FFFC(`text.replace(REFERENCE_PLACEHOLDER_RE, '')`),
384545
- * 于是**既有 chip 被销毁、引用退化为重复的纯文本**(即「连续添加多个引用显示异常」)。
384546
- *
384547
- * 换算:每个 chip 在 clipboard 中多算了 `length - 1`,逐个扣回即得 detect 末尾。
384548
- * 无 occurrences(旧版 DSH)时退化为 `draft.length`,与旧行为一致。
384549
- * @author ddj 2026年09月11号
384550
- * @param snapshot 输入快照
384551
- * @returns detect 投影中的文档末尾偏移
384552
- */
384553
- function detectEndOf(snapshot) {
384554
- const total = String(snapshot?.draft ?? "").length;
384555
- const occurrences = snapshot?.occurrences;
384556
- if (!Array.isArray(occurrences) || !occurrences.length) return total;
384557
- let clipped = 0;
384558
- for (const occurrence of occurrences) {
384559
- const length = Number(occurrence?.length ?? 0);
384560
- if (!Number.isFinite(length) || length <= 1) continue;
384561
- clipped += length - 1;
384562
- }
384563
- return Math.max(0, total - clipped);
384564
- }
384565
- /** chip 列表按 clipboard 偏移升序(官方已排序,此处防御性归一,丢弃非法项)。 */
384566
- function chipsOf(snapshot) {
384567
- const occurrences = snapshot?.occurrences;
384568
- if (!Array.isArray(occurrences) || !occurrences.length) return [];
384569
- const out = [];
384570
- for (const occurrence of occurrences) {
384571
- const offset = Number(occurrence?.offset ?? NaN);
384572
- const length = Number(occurrence?.length ?? NaN);
384573
- if (!Number.isFinite(offset) || !Number.isFinite(length) || length <= 1) continue;
384574
- out.push({
384575
- offset: Math.max(0, offset),
384576
- length
384577
- });
384578
- }
384579
- return out.sort((a, b) => a.offset - b.offset);
384580
- }
384581
- /**
384582
- * detect 偏移 → clipboard 偏移(paddingAt 用:draft 是 clipboard 投影,不能直接按下标取字符)。
384583
- * 每个位于该偏移之前的 chip 在 clipboard 中多占 `length − 1`,累计补上即可。
384584
- * @author ddj 2026年09月11号
384585
- * @param snapshot 输入快照
384586
- * @param detectOffset detect 投影偏移
384587
- * @returns clipboard 投影偏移(已夹到 draft 范围内)
384588
- */
384589
- function detectToClipboardOf(snapshot, detectOffset) {
384590
- const total = String(snapshot?.draft ?? "").length;
384591
- const target = Math.max(0, Math.min(Number(detectOffset) || 0, detectEndOf(snapshot)));
384592
- let shift = 0;
384593
- for (const chip of chipsOf(snapshot)) {
384594
- if (chip.offset - shift >= target) break;
384595
- shift += chip.length - 1;
384596
- }
384597
- return Math.max(0, Math.min(target + shift, total));
384598
- }
384599
- /**
384600
- * 插入点两侧是否需要补空格(仅纯文本通道需要;chip 由 facade 自行补尾随空格)。
384601
- *
384602
- * 在 detect 坐标的任意位置求值,故**末尾追加的行为与旧实现逐字符一致**
384603
- * (末尾前为非空白 → 前后各补一个空格;末尾前已是空白 → 只补尾随空格),
384604
- * 同时正确支持「插在文本中间」不再贴字。
384605
- * @author ddj 2026年09月11号
384606
- * @param snapshot 输入快照
384607
- * @param detectOffset detect 投影插入点
384608
- * @returns 是否需要前导/尾随空格
384609
- */
384610
- function paddingAt(snapshot, detectOffset) {
384611
- const draft = String(snapshot?.draft ?? "");
384612
- const at = detectToClipboardOf(snapshot, detectOffset);
384613
- const before = at > 0 ? draft.slice(at - 1, at) : "";
384614
- const after = at < draft.length ? draft.slice(at, at + 1) : "";
384615
- return {
384616
- lead: before !== "" && !/\s/.test(before),
384617
- tail: after === "" || !/\s/.test(after)
384618
- };
384619
- }
384620
- /**
384621
- * 解析插入点(detect 坐标):优先用输入框**当前光标/选区**(`caretSpan()`,
384622
- * 官方契约即 detect 坐标,无选区时回落文档末尾),并夹到 `[0, detectLength]`。
384623
- *
384624
- * 选区非塌缩时取 `end`(右边界)—— 插入而非替换,绝不删除用户已选中的内容。
384625
- * caret 缺失/非法(旧版 facade 无该方法、抛异常、NaN 等)一律回落到文档末尾。
384626
- * @author ddj 2026年09月11号
384627
- * @param caret caretSpan() 的返回值(可为 null/非法)
384628
- * @param detectLength detect 投影文档长度
384629
- * @returns detect 投影插入偏移
384630
- */
384631
- function insertOffsetOf(caret, detectLength) {
384632
- const max = Math.max(0, Number(detectLength) || 0);
384633
- if (!caret || typeof caret !== "object") return max;
384634
- const span = caret;
384635
- const raw = Number(span.end);
384636
- const value = Number.isFinite(raw) ? raw : Number(span.start);
384637
- if (!Number.isFinite(value)) return max;
384638
- return Math.max(0, Math.min(value, max));
384639
- }
384640
- /**
384641
- * 生成 DSH @file 语法引用串:cwd 相对化、\ → /、含空白时按 @"path" 语法加引号。
384642
- * cwd 外/无法相对化的路径回退原路径。
384643
- * @author ddj 2026年08月25号
384644
- * @param path 打开的文件路径(相对 cwd 或绝对)
384645
- * @param cwd 会话工作区目录(可选)
384646
- * @returns 引用串(如 @src/index.ts 或 @"a b.ts")
384647
- */
384648
- function mentionOf(path, cwd) {
384649
- const raw = String(path);
384650
- let rel = raw;
384651
- if (cwd) {
384652
- const base = String(cwd).replace(/[\\/]+$/, "");
384653
- const up = raw.replace(/\\/g, "/");
384654
- const baseUp = base.replace(/\\/g, "/");
384655
- if (up.startsWith(baseUp + "/")) rel = up.slice(baseUp.length + 1);
384656
- else if (up === baseUp) rel = "";
384657
- }
384658
- rel = rel.replace(/\\/g, "/").replace(/^\.\//, "");
384659
- if (!rel) rel = raw.replace(/\\/g, "/");
384660
- if (/[\u0000-\u001f\u007f-\u009f"]/u.test(rel)) return "@" + raw;
384661
- return /\s/u.test(rel) ? `@"${rel}"` : `@${rel}`;
384662
- }
384663
- /** 引用标签:文件名 + 可选行区间(chip 内展示,不带 @ 前缀)。
384664
- * @author ddj 2026年08月25号 */
384665
- function labelOf(path, range) {
384666
- const base = String(path).split(/[\\/]/).pop() || String(path);
384667
- if (!range || range.endLine < range.startLine) return base;
384668
- return range.startLine === range.endLine ? `${base} L${range.startLine}` : `${base} L${range.startLine}-${range.endLine}`;
384669
- }
384670
- /** 引用 ref:引用串 + 可选行区间(序列化直出,agent 据此读取对应片段)。
384671
- * @author ddj 2026年08月25号 */
384672
- function refOf(mention, range) {
384673
- if (!range || range.endLine < range.startLine) return mention;
384674
- return range.startLine === range.endLine ? `${mention} L${range.startLine}` : `${mention} L${range.startLine}-${range.endLine}`;
384675
- }
384676
- /** 构造 DSH 文件引用插入载荷,模型文本、剪贴板投影与降级文本共用完整行区间。
384677
- * @public
384678
- * @author ddj 2026年10月28号
384679
- * @returns 引用载荷与含可选行区间的完整引用文本
384680
- * @param path 目标路径(相对 cwd 或绝对)
384681
- * @param cwd 会话工作区目录(可选)
384682
- * @param range 行区间(可选)
384683
- * @param appearance 引用外观:'file'(默认)| 'folder'
384684
- */
384685
- function buildFileRef(path, cwd, range, appearance = "file") {
384686
- const mention = refOf(mentionOf(path, cwd), range);
384687
- return {
384688
- mention,
384689
- reference: {
384690
- source: REF_SOURCE,
384691
- ref: mention,
384692
- label: labelOf(path, range),
384693
- appearance,
384694
- clipboardText: mention
384695
- }
384696
- };
384697
- }
384698
- /** @private @author ddj 2026年10月29号 @param receipt Raw selected-channel receipt. @returns Explicit admission evidence and the historical boolean. */
384699
- function taskReceipt(receipt) {
384700
- const ok = receipt?.ok;
384701
- if (ok === true) return {
384702
- status: "accepted",
384703
- legacyOk: true
384704
- };
384705
- if (ok === false) return {
384706
- status: "rejected",
384707
- legacyOk: false
384708
- };
384709
- return {
384710
- status: "unknown",
384711
- legacyOk: false
384712
- };
384713
- }
384714
- /** @private @author ddj 2026年10月29号 @param ctx Lifecycle owner. @param sessions Public Controller. @param id Fixed target. @param text Task body. @returns Admission evidence without switching transport. @sideEffects Retains and releases exactly one generation. */
384715
- async function sendOwned(ctx, sessions, id, text) {
384716
- const controller = new AbortController();
384717
- const reference = sessions.retain(id, {
384718
- source: "controllerOperation",
384719
- signal: controller.signal
384720
- });
384721
- let released = false;
384722
- let cleanupFailed = false;
384723
- /** @private @author ddj 2026年10月29号 Cancels and releases this exact generation once; remembers unload cleanup failures. */
384724
- function release() {
384725
- if (released) return;
384726
- released = true;
384727
- controller.abort();
384728
- try {
384729
- reference.release();
384730
- } catch (error) {
384731
- cleanupFailed = true;
384732
- throw error;
384733
- }
384734
- }
384735
- let remove;
384736
- let started = false;
384737
- let result = {
384738
- status: "unavailable",
384739
- legacyOk: false
384740
- };
384741
- try {
384742
- remove = ctx.effect?.(() => release, "vscode-mode: task session reference");
384743
- const binding = await reference.ready;
384744
- if (controller.signal.aborted) result = {
384745
- status: "cancelled",
384746
- legacyOk: false
384747
- };
384748
- else {
384749
- started = true;
384750
- result = taskReceipt(await binding.session.prompt([{
384751
- type: "text",
384752
- text
384753
- }], "queue", controller.signal));
384754
- }
384755
- } catch {
384756
- result = {
384757
- status: started ? "unknown" : "unavailable",
384758
- legacyOk: false
384759
- };
384760
- }
384761
- const cancelled = controller.signal.aborted;
384762
- try {
384763
- release();
384764
- } catch {
384765
- cleanupFailed = true;
384766
- }
384767
- try {
384768
- remove?.();
384769
- } catch {
384770
- cleanupFailed = true;
384771
- }
384772
- if (cleanupFailed) result = {
384773
- status: started ? "unknown" : "unavailable",
384774
- legacyOk: false
384775
- };
384776
- if (cancelled) result = {
384777
- status: "cancelled",
384778
- legacyOk: result.legacyOk
384779
- };
384780
- return result;
384781
- }
384782
- /** @private @author ddj 2026年10月29号 @param ctx Service owner. @param id Fixed target. @param text Task body. @returns One legacy-channel attempt; no cross-channel retry. */
384783
- async function sendLegacy(ctx, id, text) {
384784
- const input = inputFor(ctx, id);
384785
- if (typeof input?.setDraft === "function" && typeof input.submit === "function") {
384786
- let submitted = false;
384787
- try {
384788
- input.setDraft(text);
384789
- submitted = true;
384790
- input.submit();
384791
- return {
384792
- status: "submitted",
384793
- legacyOk: true
384794
- };
384795
- } catch {
384796
- return {
384797
- status: submitted ? "unknown" : "unavailable",
384798
- legacyOk: false
384799
- };
384800
- }
384801
- }
384802
- const remote = ctx.get("remote.session");
384803
- if (typeof remote?.prompt !== "function") return httpTaskResult(id, text);
384804
- try {
384805
- const payload = {
384806
- sessionId: id,
384807
- mode: "queue",
384808
- content: [{
384809
- type: "text",
384810
- text
384811
- }]
384812
- };
384813
- const receipt = await remote.prompt(slotArg(payload));
384814
- return taskReceipt(receipt?.result ?? receipt);
384815
- } catch {
384816
- return {
384817
- status: "unknown",
384818
- legacyOk: false
384819
- };
384820
- }
384821
- }
384822
- /** apiproxy wire 端点(POST /api/<method>;浏览器同源 fetch 自带 cookie 鉴权;实证 401=未鉴权而非 404)。 */
384823
- const SESSION_CREATE_PATH = "/api/session/create";
384824
- /** apiproxy wire 端点:发送任务(session/prompt;点击即发送方案)。 */
384825
- const SESSION_PROMPT_PATH = "/api/session/prompt";
384826
- /** mint RPC 请求 id(RpcId 品牌类型运行时即普通字符串)。 */
384827
- function mintRpcId() {
384828
- try {
384829
- return globalThis.crypto?.randomUUID?.() ?? "rpc-" + Date.now();
384830
- } catch {
384831
- return "rpc-" + Date.now();
384832
- }
384833
- }
384834
- /**
384835
- * wire 槽实参组装(gateway `invoke` 铁证:`request.args[parameter.wire]` 具名槽,
384836
- * `session/prompt` 的槽名 = "request",值 = RpcRequest `{rpcId, payload}`)。
384837
- * @author ddj 2026年09月24号
384838
- * @param payload 业务载荷
384839
- * @returns 具名槽实参 `{ args: { request: RpcRequest } }`
384840
- */
384841
- function slotArg(payload) {
384842
- return { args: { request: {
384843
- rpcId: mintRpcId(),
384844
- payload
384845
- } } };
384846
- }
384847
- /**
384848
- * RPC 返回解析(照官方 dsh-client-runtime 调用形态:`const { result } = await api.sessions.create(...)`)。
384849
- * 官方 api.sessions 面返回**信封** `{ rpcId, result: RpcResult }`——须解 .result;
384850
- * 兼容裸 RpcResult 形态(个别补丁面直接回 {ok, value})。
384851
- * @author ddj 2026年09月23号
384852
- * @param res 信封或裸 RpcResult
384853
- * @returns 归一后的 { ok, value }
384854
- */
384855
- function rpcOk(res) {
384856
- const r = res?.result ?? res;
384857
- return {
384858
- ok: r?.ok === true,
384859
- value: r?.value
384860
- };
384861
- }
384862
- /**
384863
- * wire 直调 session/create(主路径):ClientRequest 信封 → ServerResponse.result(RpcResult 形态)。
384864
- * 契约以当前安装版 dsh-host-apiproxy 为准(api/sessions.d.ts「Method signatures are the
384865
- * source of truth」):`create({cwd}) → {sessionId}`;信封 = `{type:'client-request', rpcId,
384866
- * method, payload}` → `{result: {ok:true, value:{sessionId}} | {ok:false, error}}`。
384867
- * @author ddj 2026年09月23号
384868
- * @param cwd 新会话工作区目录
384869
- * @returns 新会话 id(失败空串)
384870
- */
384871
- async function apiSessionCreate(cwd) {
384872
- const json = await (await fetch(appUrl(SESSION_CREATE_PATH), {
384873
- method: "POST",
384874
- headers: { "content-type": "application/json" },
384875
- body: JSON.stringify({
384876
- type: "client-request",
384877
- rpcId: mintRpcId(),
384878
- method: "session/create",
384879
- payload: cwd ? { cwd } : {}
384880
- })
384881
- })).json().catch(() => null);
384882
- const id = json?.result?.value?.sessionId;
384883
- return json?.result?.ok === true && typeof id === "string" ? id : "";
384884
- }
384885
- /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns HTTP receipt evidence; errors after request entry are never safe rejection. */
384886
- async function httpTaskResult(sessionId, text) {
384887
- try {
384888
- const payload = {
384889
- sessionId,
384890
- mode: "queue",
384891
- content: [{
384892
- type: "text",
384893
- text
384894
- }]
384895
- };
384896
- return taskReceipt((await (await fetch(appUrl(SESSION_PROMPT_PATH), {
384897
- method: "POST",
384898
- headers: { "content-type": "application/json" },
384899
- body: JSON.stringify({
384900
- type: "client-request",
384901
- rpcId: mintRpcId(),
384902
- method: "session/prompt",
384903
- payload: slotArg(payload)
384904
- })
384905
- })).json().catch(() => null))?.result);
384906
- } catch {
384907
- return {
384908
- status: "unknown",
384909
- legacyOk: false
384910
- };
384911
- }
384912
- }
384913
- /**
384914
- * 新建独立会话承载旁路任务(02-deep-session-prompt:深度分析不污染当前对话)。
384915
- * 优先公开 sessions.create;旧 Host 按能力选择 remote 或 wire,调用后不换通道重试。
384916
- * 受理状态不明时返回 ok:false,不重复创建会话。
384917
- * @author ddj 2026年10月28号
384918
- * @author ddj 2026年09月23号
384919
- * @param ctx 客户端服务上下文(remote.session + sessions,仅回落用)
384920
- * @param cwd 新会话工作区目录(与当前会话同工作区)
384921
- * @returns 新会话结果(含 id)
384922
- */
384923
- async function newDraftSession(ctx, cwd) {
384924
- try {
384925
- const sessions = ctx.get("sessions");
384926
- if (typeof sessions?.create === "function") {
384927
- const created = await sessions.create(cwd ? { cwd } : void 0);
384928
- const id = typeof created === "string" && created ? created : typeof created?.id === "string" ? created.id : typeof created?.sessionId === "string" ? created.sessionId : typeof created?.session?.id === "string" ? created.session.id : "";
384929
- if (id) return {
384930
- ok: true,
384931
- id
384932
- };
384933
- }
384934
- if (typeof sessions?.create === "function") return { ok: false };
384935
- } catch {
384936
- return { ok: false };
384937
- }
384938
- try {
384939
- const remote = ctx.get("remote.session");
384940
- if (typeof remote?.create === "function") {
384941
- const payload = cwd ? { cwd } : {};
384942
- const receipt = rpcOk(await remote.create(slotArg(payload)));
384943
- const created = receipt.value;
384944
- if (receipt.ok && typeof created?.sessionId === "string") return {
384945
- ok: true,
384946
- id: created.sessionId
384947
- };
384948
- return { ok: false };
384949
- }
384950
- } catch {
384951
- return { ok: false };
384952
- }
384953
- try {
384954
- const id = await apiSessionCreate(cwd);
384955
- if (id) return {
384956
- ok: true,
384957
- id
384958
- };
384959
- } catch {}
384960
- return { ok: false };
384961
- }
384962
- /**
384963
- * 解析会话输入门面;缺失时返回 undefined(动作侧守卫降级)。
384964
- * @author ddj 2026年08月25号
384965
- * @param ctx 客户端服务上下文
384966
- * @param sessionId 会话 id
384967
- * @returns SessionInput 或 undefined
384968
- */
384969
- function inputFor(ctx, sessionId) {
384970
- if (!sessionId) return void 0;
384971
- try {
384972
- const sessions = ctx.get("sessions");
384973
- const conversation = ctx.get("conversation");
384974
- const actx = typeof sessions?.binding === "function" ? sessions.binding(sessionId)?.ctx : sessions?.scope?.(sessionId);
384975
- const shell = actx && conversation?.input?.for?.(actx);
384976
- return shell && typeof shell.insertReference === "function" ? shell : void 0;
384977
- } catch {
384978
- return;
384979
- }
384980
- }
384981
- /** 取当前草稿快照(长度/版本/chip 列表);无输入门面时返回 null。
384982
- * @author ddj 2026年08月25号 / 2026年09月11号 */
384983
- function draftCursor(input) {
384984
- try {
384985
- const s = input?.state?.getSnapshot?.();
384986
- if (!s) return null;
384987
- return {
384988
- draft: String(s.draft ?? ""),
384989
- draftRev: Number(s.draftRev ?? 0),
384990
- occurrences: Array.isArray(s.occurrences) ? s.occurrences : void 0
384991
- };
384992
- } catch {
384993
- return null;
384994
- }
384995
- }
384996
- /** 从 sessions 列表快照读会话工作区目录(与 index.ts 既有读取方式一致)。
384997
- * @author ddj 2026年08月25号 */
384998
- function cwdOf(ctx, sessionId) {
384999
- if (!sessionId) return void 0;
385000
- try {
385001
- return ctx.get("sessions")?.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd;
385002
- } catch {
385003
- return;
385004
- }
385005
- }
385006
- /** 输入门面插入引用(拦截异常视作未应用,走降级)。
385007
- * @author ddj 2026年08月25号 */
385008
- function safeInsert(input, reference, span) {
385009
- try {
385010
- return input.insertReference(reference, span) === true;
385011
- } catch {
385012
- return false;
385013
- }
385014
- }
385015
- /** 输入门面插入纯文本(官方 plain-text 通道;不存在或异常视作未应用)。
385016
- * @author ddj 2026年09月11号 */
385017
- function safeInsertText(input, text, span) {
385018
- if (typeof input.insertText !== "function") return false;
385019
- try {
385020
- return input.insertText(text, span) === true;
385021
- } catch {
385022
- return false;
385023
- }
385024
- }
385025
- /**
385026
- * 读输入框当前光标(detect 坐标);门面缺失/异常返回 null(由调用方回落到文档末尾)。
385027
- * @author ddj 2026年09月11号
385028
- * @param input 输入门面
385029
- * @returns caretSpan 结果或 null
385030
- */
385031
- function caretOf(input) {
385032
- if (typeof input.caretSpan !== "function") return null;
385033
- try {
385034
- return input.caretSpan() ?? null;
385035
- } catch {
385036
- return null;
385037
- }
385038
- }
385039
- /**
385040
- * 创建「添加到对话」动作集(apply 阶段构建一次,随 props 传给 EditorView)。
385041
- *
385042
- * 插入位置 = 输入框**当前光标**(`caretSpan()`,detect 坐标;无光标提示时回落文档末尾),
385043
- * 不再固定追加到最末尾。坐标一律用 **detect 投影**(见 detectEndOf 的缺陷说明);
385044
- * 两条写入通道都失败时**不写任何内容**并返回 'failed' —— 旧实现在此处退回 `setDraft`
385045
- * 整篇重建,会把既有 chip 全部销毁(连续添加多个引用即触发)。
385046
- * @author ddj 2026年08月25号 / 2026年09月11号
385047
- * @param ctx 客户端服务上下文(sessions + conversation)
385048
- * @returns 动作集
385049
- */
385050
- function createAddToConversation(ctx) {
385051
- const appendReference = async (sessionId, path, range, appearance) => {
385052
- const input = inputFor(ctx, sessionId);
385053
- if (!input) return "unavailable";
385054
- const cur = draftCursor(input);
385055
- if (!cur) return "unavailable";
385056
- const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range, appearance);
385057
- const at = insertOffsetOf(caretOf(input), detectEndOf(cur));
385058
- const span = {
385059
- start: at,
385060
- end: at,
385061
- draftRev: cur.draftRev
385062
- };
385063
- if (safeInsert(input, reference, span)) return "ok";
385064
- const pad = paddingAt(cur, at);
385065
- if (safeInsertText(input, (pad.lead ? " " : "") + mention + (pad.tail ? " " : ""), span)) return "busy";
385066
- return "failed";
385067
- };
385068
- /**
385069
- * 追加纯文本(AI 深度分析任务注入):走官方 plain-text 通道插到当前光标。
385070
- * 失败语义与 appendReference 一致:宁可不写也不整篇重建(保护既有 chip)。
385071
- * @author ddj 2026年09月23号
385072
- * @param sessionId 会话 id
385073
- * @param text 待注入文本
385074
- * @returns 注入结果(ok / unavailable / failed)
385075
- */
385076
- const appendText = async (sessionId, text) => {
385077
- const input = inputFor(ctx, sessionId);
385078
- if (!input) return "unavailable";
385079
- const cur = draftCursor(input);
385080
- if (!cur) return "unavailable";
385081
- const at = insertOffsetOf(caretOf(input), detectEndOf(cur));
385082
- return safeInsertText(input, text, {
385083
- start: at,
385084
- end: at,
385085
- draftRev: cur.draftRev
385086
- }) ? "ok" : "failed";
385087
- };
385088
- /**
385089
- * 新建独立会话(02-deep-session-prompt:旁路任务不污染当前对话)。
385090
- * 闭包持有真服务 ctx(面板 SidebarCtx 无 .get,会话操作必须经此门面);
385091
- * 新版仅返回已登记身份,视图选择由宿主负责;旧版保留可用的 open/select。
385092
- * @author ddj 2026年10月28号
385093
- * @author ddj 2026年09月23号
385094
- * @param cwd 新会话工作区目录(与当前会话同工作区)
385095
- * @returns 新会话结果(失败供降级)
385096
- */
385097
- const startDraftSession = async (cwd) => {
385098
- const result = await newDraftSession(ctx, cwd);
385099
- if (typeof ctx.get("sessions")?.retain === "function") return result;
385100
- if (result.ok && result.id) try {
385101
- const sessions = ctx.get("sessions");
385102
- sessions?.materializeScope?.(result.id);
385103
- const title = "AI 深度分析 · " + (String(cwd ?? "").split(/[\\/]/).pop() || "SVN 变更");
385104
- sessions?.manager?.rename?.(result.id, title);
385105
- sessions?.manager?.select?.(result.id);
385106
- sessions?.open?.(result.id);
385107
- } catch {}
385108
- return result;
385109
- };
385110
- /**
385111
- * 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
385112
- * 新版持有目标 Controller 到 prompt 结束;旧版按能力选择唯一通道,不跨通道重发。
385113
- * @author ddj 2026年10月29号
385114
- * @param sessionId 目标会话 id
385115
- * @param text 任务文本
385116
- * @returns 同一次调用的历史boolean语义,不能作为受理事实
385117
- */
385118
- const sendTask = async (sessionId, text) => (await sendTaskResult(sessionId, text)).legacyOk;
385119
- /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns Detailed evidence from exactly one selected transport. */
385120
- async function sendTaskResult(sessionId, text) {
385121
- if (!sessionId) return {
385122
- status: "unavailable",
385123
- legacyOk: false
385124
- };
385125
- try {
385126
- const sessions = ctx.get("sessions");
385127
- if (typeof sessions?.retain === "function") return await sendOwned(ctx, sessions, sessionId, text);
385128
- return await sendLegacy(ctx, sessionId, text);
385129
- } catch {
385130
- return {
385131
- status: "unavailable",
385132
- legacyOk: false
385133
- };
385134
- }
385135
- }
385136
- return {
385137
- appendReference,
385138
- appendText,
385139
- startDraftSession,
385140
- sendTask,
385141
- sendTaskResult
385142
- };
385143
- }
385144
385816
  /**
385145
385817
  * 由选区文本推导搜索种子。
385146
385818
  * @author ddj 2026年09月18号
@@ -394041,10 +394713,11 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
394041
394713
  const layoutRef = react$1.default.useRef(layout);
394042
394714
  layoutRef.current = layout;
394043
394715
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t));
394716
+ const coveredPaths = react$1.default.useMemo(() => coveredPathKeys(review.page), [review.page]);
394044
394717
  const currentRecords = react$1.default.useMemo(() => {
394045
394718
  const list = [];
394046
394719
  for (const rec of Object.values(records)) {
394047
- if (rec.archived === true || officialPath(rec.path, review.page)) continue;
394720
+ if (rec.archived === true || coveredPaths.has(diffPathKey(rec.path))) continue;
394048
394721
  if (sameFile(rec.path, active)) list.push(rec);
394049
394722
  }
394050
394723
  return list;
@@ -394052,7 +394725,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
394052
394725
  records,
394053
394726
  active,
394054
394727
  cwd,
394055
- review.page
394728
+ coveredPaths
394056
394729
  ]);
394057
394730
  const reviewOpsRef = react$1.default.useRef(/* @__PURE__ */ new Map());
394058
394731
  const settledRef = react$1.default.useRef(/* @__PURE__ */ new Set());
@@ -397043,11 +397716,13 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397043
397716
  dbg(sessionId, `[DEBUG diffAction] stage=reply trace=${traceId} method=decideBatch items=${items.length} rpcMs=${(performance.now() - start).toFixed(1)} ok=${res?.ok === true}`);
397044
397717
  } catch (e) {}
397045
397718
  if (!res || !res.ok || !Array.isArray(res.results)) {
397046
- setError(res?.error ? String(res.error) : "批量处理失败");
397719
+ const failure = res?.error ? String(res.error) : "批量处理失败";
397720
+ setError(failure);
397047
397721
  return {
397048
397722
  ok: 0,
397049
397723
  fail: items.length,
397050
- stale: 0
397724
+ stale: 0,
397725
+ error: failure
397051
397726
  };
397052
397727
  }
397053
397728
  let ok = 0;
@@ -397066,11 +397741,13 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397066
397741
  stale
397067
397742
  };
397068
397743
  }).catch((e) => {
397069
- setError("批量处理异常:" + String(e));
397744
+ const failure = "批量处理异常:" + String(e);
397745
+ setError(failure);
397070
397746
  return {
397071
397747
  ok: 0,
397072
397748
  fail: items.length,
397073
- stale: 0
397749
+ stale: 0,
397750
+ error: failure
397074
397751
  };
397075
397752
  });
397076
397753
  };
@@ -397239,6 +397916,27 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397239
397916
  if (lifetime?.live) setReviewBatchBusy(false);
397240
397917
  }
397241
397918
  }
397919
+ /**
397920
+ * 批量失败提示:Host 原文优先,只有拿不到原因时才按计数兜底。
397921
+ * @author ddj 2026年11月3号
397922
+ * @param result actMany 的结果
397923
+ * @returns 提示文案;没有失败返回 null
397924
+ */
397925
+ const batchError = (result) => {
397926
+ if (!result.error) return batchFailText(result);
397927
+ if (isSessionMissingError(result.error)) return "这个会话没在当前端激活,Keep/Undo 没有执行(记录未改动);在会话里发一条消息激活后重试";
397928
+ return result.error;
397929
+ };
397930
+ /**
397931
+ * 批量结果没有 Host 原文时的计数兜底文案。
397932
+ * @author ddj 2026年11月3号
397933
+ * @param result actMany 的结果
397934
+ * @returns 提示文案;没有失败返回 null
397935
+ */
397936
+ const batchFailText = (result) => {
397937
+ if (!result.fail) return null;
397938
+ return result.stale ? `${result.fail} 处未处理(其中 ${result.stale} 处已不在文件中,只更新了记录),可刷新后重试` : `${result.fail} 处未处理,可刷新后重试`;
397939
+ };
397242
397940
  const acceptFile = () => {
397243
397941
  if (missingNow && !officialDetail) return decideMissing(false);
397244
397942
  if (officialDetail) return decideOfficial(false);
@@ -397252,8 +397950,9 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397252
397950
  paths: active ? [active] : void 0,
397253
397951
  kind: "structure"
397254
397952
  });
397255
- setStatus(decideStatus("已采纳 ", result));
397256
- if (result.fail) setError(result.fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
397953
+ setStatus(result.ok ? `${SIDECAR_KEEP_DONE}(${result.ok} 处)` : decideStatus("已采纳 ", result));
397954
+ const message = batchError(result);
397955
+ if (message) setError(message);
397257
397956
  }).catch(() => {
397258
397957
  batchBusyRef.current = false;
397259
397958
  });
@@ -397272,7 +397971,8 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397272
397971
  kind: "structure"
397273
397972
  });
397274
397973
  setStatus(decideStatus("已不采纳 ", result));
397275
- if (result.fail) setError(result.fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
397974
+ const message = batchError(result);
397975
+ if (message) setError(message);
397276
397976
  }).catch(() => {
397277
397977
  batchBusyRef.current = false;
397278
397978
  });
@@ -397280,7 +397980,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397280
397980
  const allPending = react$1.default.useMemo(() => {
397281
397981
  const out = [];
397282
397982
  for (const rec of Object.values(records)) {
397283
- if (rec.superseded === true || officialPath(rec.path, review.page)) continue;
397983
+ if (rec.superseded === true || coveredPaths.has(diffPathKey(rec.path))) continue;
397284
397984
  const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : [];
397285
397985
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : [];
397286
397986
  for (let i = 0; i < hunks.length; i++) if ((perHunk.length ? perHunk[i] : rec.decisions.call) === "pending" && !noopHunk(rec, hunks[i])) out.push({
@@ -397369,7 +398069,8 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397369
398069
  kind: "structure"
397370
398070
  });
397371
398071
  setStatus(decideStatus(reject ? "已不采纳 " : "已采纳 ", result) + `;删除文件 ${handled.size} 个`);
397372
- if (result.fail) setError(result.fail + " 处差异处理失败,可刷新后重试");
398072
+ const message = batchError(result);
398073
+ if (message) setError(message);
397373
398074
  } catch (cause) {
397374
398075
  if (lifetime?.live) setError(String(cause));
397375
398076
  } finally {
@@ -397414,6 +398115,88 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397414
398115
  setStatus(`官方待审列表正在载入,载入完成后请再次点击${action}`);
397415
398116
  return false;
397416
398117
  }
398118
+ /** @private @author ddj 2026年11月03号 @description 批量通道的一次性完整快照(分页口径的 pending 只是页内计数)。 @returns 完整待审页 */
398119
+ async function batchSnapshot() {
398120
+ const res = await rpc("edrv.review.list", {
398121
+ sessionId,
398122
+ all: true
398123
+ });
398124
+ if (!res.ok) throw new Error(res.error);
398125
+ return res.page;
398126
+ }
398127
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 逐条 Undo 的精确部分完成(冲突处停止) */
398128
+ function undoAllBatch(token) {
398129
+ return runReviewBatch({
398130
+ snapshot: batchSnapshot,
398131
+ detail: async (id) => {
398132
+ const res = await rpc("edrv.review.detail", {
398133
+ sessionId,
398134
+ id
398135
+ });
398136
+ if (!res.ok) throw new Error(res.error);
398137
+ return res.detail;
398138
+ },
398139
+ decide: (detail, undo, operationId) => rpc("edrv.review.decide", {
398140
+ sessionId,
398141
+ id: detail.item.id,
398142
+ seq: detail.item.seq,
398143
+ revision: detail.item.revision,
398144
+ operationId,
398145
+ scope: "file",
398146
+ decision: undo ? "rejected" : "accepted"
398147
+ }),
398148
+ guard: () => guardReviewAll(true, token),
398149
+ operation: batchOperation
398150
+ }, true);
398151
+ }
398152
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 一次 RPC 的批量 Keep 结果(跳过项不算失败) */
398153
+ function keepAllBatch(token) {
398154
+ return runKeepAll({
398155
+ snapshot: batchSnapshot,
398156
+ keepAll: (items, batchId) => rpc("edrv.review.keepAll", {
398157
+ sessionId,
398158
+ batchId,
398159
+ items
398160
+ }),
398161
+ guard: () => guardReviewAll(false, token)
398162
+ });
398163
+ }
398164
+ /**
398165
+ * @private
398166
+ * @author ddj 2026年11月03号
398167
+ * @description: 批量结果落界面:刷新列表、状态栏计数;Keep 的跳过只做提示不当错误,
398168
+ * Undo 保持「遇冲突停止」口径。
398169
+ * @param result 批量结果
398170
+ * @param reject 本次是 Undo 还是 Keep
398171
+ * @param undoPath 起批时的活动文件
398172
+ * @param undoModel 起批时的编辑器模型
398173
+ * @param undoVersion 起批时的模型版本
398174
+ * @returns 官方段是否完全成功(legacy 通道是否继续)
398175
+ */
398176
+ function finishReviewAll(result, reject, undoPath, undoModel, undoVersion) {
398177
+ refreshReview(sessionId);
398178
+ if (result.paths.length) emitRefresh({
398179
+ scope,
398180
+ paths: result.paths,
398181
+ kind: "structure"
398182
+ });
398183
+ if (reject) {
398184
+ if (result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true);
398185
+ setStatus(`Undo All:官方记录已完成 ${result.completed} / ${result.total}`);
398186
+ if (result.error) setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`);
398187
+ return !result.error;
398188
+ }
398189
+ const skipped = result.skipped ?? [];
398190
+ setStatus(`Keep All:已保留 ${result.completed} / ${result.total}` + (skipped.length ? `,跳过 ${skipped.length} 条` : ""));
398191
+ if (result.error) setError(`Keep All 未全部完成:${result.error}`);
398192
+ if (skipped.length) toastDom(skippedTip(skipped));
398193
+ return !result.error;
398194
+ }
398195
+ /** @private @author ddj 2026年11月03号 @param skipped 被跳过的条目 @returns 最多 3 条、余量汇总的提示文案 */
398196
+ function skippedTip(skipped) {
398197
+ const head = skipped.slice(0, 3).map((item) => `${item.display}:${item.reason}`).join(";");
398198
+ return skipped.length > 3 ? `${head};等 ${skipped.length} 条` : head;
398199
+ }
397417
398200
  /** @private @author ddj 2026年10月27号 @param reject Undo or Keep all workspace pending records, including previous rounds. @returns Explicit partial results; legacy proceeds only when official stage fully succeeds. */
397418
398201
  async function actReviewAll(reject) {
397419
398202
  if (batchBusyRef.current) return;
@@ -397427,48 +398210,9 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
397427
398210
  setReviewBatchBusy(true);
397428
398211
  setStatus(reject ? "正在检查并 Undo 全部待审记录…" : "正在 Keep 全部待审记录…");
397429
398212
  try {
397430
- const result = await runReviewBatch({
397431
- snapshot: async () => {
397432
- const res = await rpc("edrv.review.list", {
397433
- sessionId,
397434
- all: true
397435
- });
397436
- if (!res.ok) throw new Error(res.error);
397437
- return res.page;
397438
- },
397439
- detail: async (id) => {
397440
- const res = await rpc("edrv.review.detail", {
397441
- sessionId,
397442
- id
397443
- });
397444
- if (!res.ok) throw new Error(res.error);
397445
- return res.detail;
397446
- },
397447
- decide: (detail, undo, operationId) => rpc("edrv.review.decide", {
397448
- sessionId,
397449
- id: detail.item.id,
397450
- seq: detail.item.seq,
397451
- revision: detail.item.revision,
397452
- operationId,
397453
- scope: "file",
397454
- decision: undo ? "rejected" : "accepted"
397455
- }),
397456
- guard: () => guardReviewAll(reject, token),
397457
- operation: batchOperation
397458
- }, reject);
398213
+ const result = reject ? await undoAllBatch(token) : await keepAllBatch(token);
397459
398214
  if (!token?.live) return;
397460
- refreshReview(sessionId);
397461
- if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true);
397462
- if (result.paths.length) emitRefresh({
397463
- scope,
397464
- paths: result.paths,
397465
- kind: "structure"
397466
- });
397467
- setStatus(`${reject ? "Undo All" : "Keep All"}:官方记录已完成 ${result.completed} / ${result.total}`);
397468
- if (result.error) {
397469
- setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`);
397470
- return;
397471
- }
398215
+ if (!finishReviewAll(result, reject, undoPath, undoModel, undoVersion)) return;
397472
398216
  guardReviewAll(reject, token);
397473
398217
  if (allPending.length) {
397474
398218
  batchBusyRef.current = false;
@@ -403456,577 +404200,6 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
403456
404200
  }
403457
404201
  }
403458
404202
  //#endregion
403459
- //#region src/shared/externalOpen.ts
403460
- /**
403461
- * dsh-vscode-mode shared — 外部深链 URL 契约(Windows launcher / Unity 包 / client 共用)。
403462
- * 形态:?<FLAG>=1&<PATHS_KEY>=<enc1>[,<enc2>…] [&<LINE_KEY>=N] [&<COLUMN_KEY>=M]
403463
- * 每个路径独立 percent-encode(encodeURIComponent / Uri.EscapeDataString)后以 `,` 连接:
403464
- * 编码结果不含裸逗号(Windows 路径本身不允许逗号,编码兜底其余字符),URLSearchParams
403465
- * 取值做一次标准解码即得 `enc1,enc2`,按 `,` split 即路径,无需二次 decode。
403466
- * Purity rule: no node/react imports(URLSearchParams 为标准全局)。
403467
- * 作者 ddj 2026-09-07
403468
- */
403469
- /** 深链开关参数名(值恒为 '1')。 */
403470
- const EDRV_OPEN_FLAG = "edrvOpen";
403471
- /** 路径列表参数名(逗号分隔的已编码路径)。 */
403472
- const EDRV_PATHS_KEY = "edrvPaths";
403473
- /** 目标行参数名(1-based,可选,作用于首个路径)。 */
403474
- const EDRV_LINE_KEY = "edrvLine";
403475
- /** 目标列参数名(1-based,可选,作用于首个路径)。 */
403476
- const EDRV_COLUMN_KEY = "edrvColumn";
403477
- /** 深链参数键全集(URL 清理用)。 */
403478
- const EDRV_PARAM_KEYS = [
403479
- EDRV_OPEN_FLAG,
403480
- EDRV_PATHS_KEY,
403481
- EDRV_LINE_KEY,
403482
- EDRV_COLUMN_KEY
403483
- ];
403484
- /**
403485
- * 解析深链查询串:非深链/路径为空 → null;行列仅接受正整数,非法忽略。
403486
- * @author ddj 2026年09月07号
403487
- * @param search location.search(含或不含前导 ? 均可)
403488
- * @returns 深链参数或 null
403489
- */
403490
- function parseOpenParams(search) {
403491
- const params = new URLSearchParams(search ?? "");
403492
- if (params.get("edrvOpen") !== "1") return null;
403493
- const paths = (params.get("edrvPaths") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
403494
- if (!paths.length) return null;
403495
- return {
403496
- paths,
403497
- line: positiveIntOf(params.get(EDRV_LINE_KEY)),
403498
- column: positiveIntOf(params.get(EDRV_COLUMN_KEY))
403499
- };
403500
- }
403501
- /**
403502
- * 正整数解析(非法/非正 → undefined)。
403503
- * @author ddj 2026年09月07号
403504
- * @param raw 参数原文(可空)
403505
- * @returns 正整数或 undefined
403506
- */
403507
- function positiveIntOf(raw) {
403508
- const value = Number(raw);
403509
- return Number.isInteger(value) && value > 0 ? value : void 0;
403510
- }
403511
- //#endregion
403512
- //#region src/client/chooseDialog.ts
403513
- /**
403514
- * dsh-vscode-mode client — 工作区选择弹窗(深链打开规则 1)。
403515
- * 复用与「添加 MCP」一致的 vsm-mcp-modal / vsm-mcp-dialog 模态(页面居中卡片 + 遮罩 + 主题
403516
- * token),React portal 挂 body;Esc / 遮罩点击 / 取消 → null(中止流程不改任何状态)。
403517
- * 作者 ddj 2026-09-07
403518
- */
403519
- /** 选择弹窗(vsm-mcp-modal 遮罩 + vsm-mcp-dialog 卡片,Esc/遮罩点击取消)。 */
403520
- function ChoiceModal({ title, folder, done }) {
403521
- react$1.default.useEffect(() => {
403522
- const onKey = (event) => {
403523
- if (event.key === "Escape") done(null);
403524
- };
403525
- window.addEventListener("keydown", onKey);
403526
- return () => window.removeEventListener("keydown", onKey);
403527
- }, []);
403528
- return react$1.default.createElement("div", {
403529
- className: "vsm-mcp-modal",
403530
- onMouseDown: (event) => {
403531
- if (event.target === event.currentTarget) done(null);
403532
- }
403533
- }, react$1.default.createElement("div", { className: "vsm-mcp-dialog" }, react$1.default.createElement("h3", null, "在 DSH 中打开"), react$1.default.createElement("label", null, "文件夹 " + folder + " 位于已注册的工作区「" + title + "」内,选择打开方式:"), react$1.default.createElement("div", { className: "vsm-mcp-dialog-actions" }, react$1.default.createElement("button", { onClick: () => done(null) }, "取消"), react$1.default.createElement("button", { onClick: () => done("create") }, "新建工作区"), react$1.default.createElement("button", {
403534
- className: "vsm-primary",
403535
- onClick: () => done("recent")
403536
- }, "使用最近的工作区"))));
403537
- }
403538
- /**
403539
- * 弹出工作区选择弹窗(深链打开规则 1)。
403540
- * @author ddj 2026年09月07号
403541
- * @param title 匹配到的工作区标题
403542
- * @param folder 文件夹路径
403543
- * @returns 用户选择;null = 取消/中止
403544
- */
403545
- function chooseWorkspace(title, folder) {
403546
- return new Promise((resolve) => {
403547
- const host = document.createElement("div");
403548
- document.body.appendChild(host);
403549
- const root = (0, react_dom_client.createRoot)(host);
403550
- const done = (choice) => {
403551
- root.unmount();
403552
- host.remove();
403553
- resolve(choice);
403554
- };
403555
- root.render(react$1.default.createElement(ChoiceModal, {
403556
- title,
403557
- folder,
403558
- done
403559
- }));
403560
- });
403561
- }
403562
- //#endregion
403563
- //#region src/client/openFlow.ts
403564
- /**
403565
- * dsh-vscode-mode client — 深链打开规则引擎(资源管理器右键 / Unity 外部编辑器落地)。
403566
- * 规则:文件夹 ⊂ 某已注册工作区 → 页面内弹窗二选一(使用最近的工作区 / 新建工作区);
403567
- * 文件夹不在任何工作区 → 直达最近对话所在工作区新增;文件 → 最近对话 + 编辑器展开
403568
- * (无任何会话 → 以文件父目录注册工作区引导创建)。首路径驱动规则,其余路径:文件进
403569
- * 编辑器、文件夹补引用。纯函数可单测;编排依赖经 OpenDeps 注入(mock 可测)。
403570
- * 作者 ddj 2026-09-07
403571
- */
403572
- /** 等列表/输入就绪的节奏。 */
403573
- const READY_TIMEOUT_MS = 15e3;
403574
- const READY_TICK_MS = 300;
403575
- const REF_RETRY_MAX = 20;
403576
- const REF_RETRY_MS = 300;
403577
- /**
403578
- * 路径规范化:`\→/`、小写、去尾分隔符(Windows 大小写不敏感前缀匹配用)。
403579
- * @author ddj 2026年09月07号
403580
- */
403581
- function normPath(p) {
403582
- return String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
403583
- }
403584
- /**
403585
- * 目录包含判定:child === root 或 child 位于 root 之下。
403586
- * @author ddj 2026年09月07号
403587
- */
403588
- function containsPath(root, child) {
403589
- const r = normPath(root);
403590
- const c = normPath(child);
403591
- return c === r || c.startsWith(r + "/");
403592
- }
403593
- /**
403594
- * 父目录(文件引导创建工作区用;根路径回退自身)。
403595
- * @author ddj 2026年09月07号
403596
- */
403597
- function dirnameOf(p) {
403598
- const norm = String(p ?? "").replace(/[\\/]+$/, "");
403599
- const cut = Math.max(norm.lastIndexOf("/"), norm.lastIndexOf("\\"));
403600
- return cut > 0 ? norm.slice(0, cut) : norm;
403601
- }
403602
- /**
403603
- * 最近一次对话:排除子代理行,updatedAt 最新优先。
403604
- * @author ddj 2026年09月07号
403605
- */
403606
- function recentSession(byId, ids) {
403607
- return (ids ?? Object.keys(byId)).map((id) => byId[id]).filter((row) => row && row.origin !== "subagent").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
403608
- }
403609
- /**
403610
- * 文件夹命中的已注册工作区(前缀包含),updatedAt 新者优先。
403611
- * @author ddj 2026年09月07号
403612
- */
403613
- function matchWorkspaces(items, folder) {
403614
- return items.filter((ws) => containsPath(ws.path, folder));
403615
- }
403616
- /**
403617
- * 规则分派:目录+命中工作区 → choose;目录未命中 → folderNew(以该文件夹为根注册新工作区,
403618
- * 用户确认:一律取右键的文件夹本身);文件有会话 → fileRecent;文件无会话 → bootstrap;
403619
- * 路径缺失 → abort。
403620
- * @author ddj 2026年09月08号
403621
- */
403622
- function pickRule(kind, matched, hasSession) {
403623
- if (kind === "missing") return "abort";
403624
- if (kind === "directory") return matched ? "choose" : "folderNew";
403625
- return hasSession ? "fileRecent" : "bootstrap";
403626
- }
403627
- /**
403628
- * 深链打开编排入口:等列表就绪 → 判型 → 规则分派 → 编排 create/open/引用/编辑器。
403629
- * @author ddj 2026年09月07号
403630
- * @param ctx 客户端根上下文(sessions/workspaces 服务)
403631
- * @param params 深链参数(首路径驱动规则)
403632
- * @param overrides 依赖覆盖(测试注入)
403633
- */
403634
- async function openDeepLink(ctx, params, overrides = {}) {
403635
- const deps = realDeps(ctx, overrides);
403636
- if (!await waitListsReady(deps)) {
403637
- deps.notify("DSH 会话列表未就绪,请稍后重试");
403638
- return false;
403639
- }
403640
- const primary = params.paths[0];
403641
- const kind = await deps.stat(primary).catch(() => "missing");
403642
- const sess = deps.sessions();
403643
- const rule = pickRule(kind, matchWorkspaces(deps.workspaces().items, primary).length > 0, Boolean(recentSession(sess.byId, sess.ids)));
403644
- try {
403645
- if (rule === "abort") {
403646
- deps.notify("路径不存在:" + primary);
403647
- return false;
403648
- }
403649
- if (rule === "choose") await runChoose(deps, primary, params);
403650
- else if (rule === "folderNew") await runFolderNew(deps, primary, params);
403651
- else if (rule === "fileRecent") await runFileRecent(deps, primary, params);
403652
- else await runFileBootstrap(deps, primary, params);
403653
- return true;
403654
- } catch (error) {
403655
- deps.notify("打开失败:" + String(error?.message ?? error));
403656
- return false;
403657
- }
403658
- }
403659
- /** 等待会话与工作区列表就绪(上限 15s;超时按当前态判定)。 */
403660
- async function waitListsReady(deps) {
403661
- for (let waited = 0; waited < READY_TIMEOUT_MS; waited += READY_TICK_MS) {
403662
- if (deps.sessions().ready && deps.workspaces().ready) return true;
403663
- await sleep(deps, READY_TICK_MS);
403664
- }
403665
- return deps.sessions().ready && deps.workspaces().ready;
403666
- }
403667
- /** 规则 1:命中工作区 → 弹窗分派(取消即中止)。 */
403668
- async function runChoose(deps, folder, params) {
403669
- const best = bestMatch(deps, matchWorkspaces(deps.workspaces().items, folder));
403670
- const choice = await deps.choose(best?.title ?? "工作区", folder);
403671
- if (choice === null) throw new Error("用户取消打开");
403672
- if (choice === "create") return runFolderNew(deps, folder, params);
403673
- const sessionId = await spawnInWorkspace(deps, (best ?? deps.workspaces().items[0]).workspaceId, void 0);
403674
- await settleRef(deps, sessionId, folder, "folder");
403675
- await openChecked(deps, sessionId, null);
403676
- await openRest(deps, sessionId, params, 1);
403677
- }
403678
- /** 规则 3a:文件 → 所属工作区优先(有会话打开其最近会话;无会话新建 + 文件引用);无匹配 → 最近对话。 */
403679
- async function runFileRecent(deps, file, params) {
403680
- const ws = pickFileWorkspace(deps, file);
403681
- if (ws) {
403682
- const anchor = recentSessionIn(deps, ws);
403683
- if (anchor) {
403684
- deps.openSession(anchor.id);
403685
- await openChecked(deps, anchor.id, file, params.line, params.column);
403686
- await openRest(deps, anchor.id, params, 1);
403687
- return;
403688
- }
403689
- const sessionId = await spawnInWorkspace(deps, ws.workspaceId, void 0);
403690
- await settleRef(deps, sessionId, file, "file");
403691
- await openChecked(deps, sessionId, file, params.line, params.column);
403692
- await openRest(deps, sessionId, params, 1);
403693
- return;
403694
- }
403695
- const anchor = recentSession(deps.sessions().byId, deps.sessions().ids);
403696
- if (!anchor) return runFileBootstrap(deps, file, params);
403697
- deps.openSession(anchor.id);
403698
- await openChecked(deps, anchor.id, file, params.line, params.column);
403699
- await openRest(deps, anchor.id, params, 1);
403700
- }
403701
- /**
403702
- * 文件所属工作区:前缀命中的工作区里,优先取有会话且最近活跃的;都无会话取路径最具体的。
403703
- * @author ddj 2026年09月08号
403704
- */
403705
- function pickFileWorkspace(deps, file) {
403706
- const matches = matchWorkspaces(deps.workspaces().items, file);
403707
- if (!matches.length) return void 0;
403708
- const withSessions = matches.filter((ws) => (ws.sessionIds ?? []).some((id) => Boolean(deps.sessions().byId[id])));
403709
- if (!withSessions.length) return [...matches].sort((a, b) => normPath(b.path).length - normPath(a.path).length)[0];
403710
- return withSessions.sort((a, b) => latestSessionAt(deps, b) - latestSessionAt(deps, a))[0];
403711
- }
403712
- /** 工作区内会话的最近活跃时间(无成员 → 0)。 */
403713
- function latestSessionAt(deps, ws) {
403714
- const byId = deps.sessions().byId;
403715
- return (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0);
403716
- }
403717
- /** 工作区内最近一次会话(排除子代理与不在列表中的陈旧行)。 */
403718
- function recentSessionIn(deps, ws) {
403719
- const byId = deps.sessions().byId;
403720
- return (ws.sessionIds ?? []).map((id) => byId[id]).filter((row) => row && row.origin !== "subagent").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
403721
- }
403722
- /** 规则 3b:文件 + 无任何会话 → 父目录注册工作区 + 新对话 + 文件引用 + 编辑器打开。 */
403723
- async function runFileBootstrap(deps, file, params) {
403724
- const sessionId = await spawnInWorkspace(deps, (await deps.createWorkspace(dirnameOf(file))).workspaceId, void 0);
403725
- await settleRef(deps, sessionId, file, "file");
403726
- await openChecked(deps, sessionId, file, params.line, params.column);
403727
- await openRest(deps, sessionId, params, 1);
403728
- }
403729
- /** 规则 1.2:以文件夹注册新工作区 + 新对话 + 文件夹引用 + 编辑页。 */
403730
- async function runFolderNew(deps, folder, params) {
403731
- const sessionId = await spawnInWorkspace(deps, (await deps.createWorkspace(folder)).workspaceId, void 0);
403732
- await settleRef(deps, sessionId, folder, "folder");
403733
- await openChecked(deps, sessionId, null);
403734
- await openRest(deps, sessionId, params, 1);
403735
- }
403736
- /** 在指定工作区(或回退 cwd)新建对话并置为当前;返回新会话 id。 */
403737
- async function spawnInWorkspace(deps, workspaceId, fallbackCwd) {
403738
- const sessionId = await deps.createSession(workspaceId ? { workspaceId } : fallbackCwd ? { cwd: fallbackCwd } : {});
403739
- deps.openSession(sessionId);
403740
- return sessionId;
403741
- }
403742
- /** 命中工作区集中「最近」的一个(其成员会话 updatedAt 最新者优先)。 */
403743
- function bestMatch(deps, matches) {
403744
- const byId = deps.sessions().byId;
403745
- const score = (ws) => {
403746
- const sessionLatest = (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0);
403747
- return Math.max(sessionLatest, Date.parse(ws.updatedAt ?? "") || 0);
403748
- };
403749
- return [...matches].sort((a, b) => score(b) - score(a))[0];
403750
- }
403751
- /** 引用插入轮询:输入门面就绪前每 300ms 重试(appendReference 忙态已降级视为成功)。 */
403752
- async function settleRef(deps, sessionId, path, appearance) {
403753
- for (let attempt = 0; attempt < REF_RETRY_MAX; attempt++) {
403754
- if (await deps.reference(sessionId, path, appearance)) return;
403755
- await sleep(deps, REF_RETRY_MS);
403756
- }
403757
- throw new Error("引用插入未完成");
403758
- }
403759
- /** 其余路径:文件进编辑器(步进),文件夹补文件夹引用。 */
403760
- async function openRest(deps, sessionId, params, from) {
403761
- const rest = params.paths.slice(from);
403762
- for (const path of rest) {
403763
- const kind = await deps.stat(path).catch(() => "missing");
403764
- if (kind === "file") await openChecked(deps, sessionId ?? void 0, path);
403765
- else if (kind === "directory" && sessionId) await settleRef(deps, sessionId, path, "folder");
403766
- else throw new Error("路径不存在:" + path);
403767
- }
403768
- }
403769
- /** 可注入 sleep。 */
403770
- function sleep(deps, ms) {
403771
- return new Promise((resolve) => deps.schedule(resolve, ms));
403772
- }
403773
- /** 真实依赖装配(全部来自 ctx 注入服务 + 既有 RPC/弹窗/编辑器入口)。 */
403774
- function realDeps(ctx, overrides = {}) {
403775
- const service = (name) => {
403776
- const get = ctx?.get;
403777
- return typeof get === "function" ? get.call(ctx, name) : void 0;
403778
- };
403779
- const add = createAddToConversation(ctx);
403780
- return {
403781
- stat: async (path) => {
403782
- const result = await rpc("edrv.externalStat", { path });
403783
- return result.ok ? result.kind : "missing";
403784
- },
403785
- workspaces: () => {
403786
- const snap = service("workspaces")?.list?.getSnapshot?.() ?? {};
403787
- return {
403788
- items: snap.items ?? [],
403789
- ready: snap.phase === "ready"
403790
- };
403791
- },
403792
- sessions: () => {
403793
- const snap = service("sessions")?.list?.getSnapshot?.() ?? {};
403794
- return {
403795
- ids: snap.ids ?? [],
403796
- byId: snap.byId ?? {},
403797
- ready: snap.phase !== "pending"
403798
- };
403799
- },
403800
- createSession: async (opts) => {
403801
- const sessions = service("sessions");
403802
- if (typeof sessions?.create === "function") try {
403803
- const created = await sessions.create(opts);
403804
- const id = typeof created === "string" && created ? created : typeof created?.id === "string" ? created.id : typeof created?.sessionId === "string" ? created.sessionId : "";
403805
- if (id) return id;
403806
- } catch {}
403807
- const draft = await newDraftSession(ctx, opts.cwd);
403808
- if (draft.ok && draft.id) return draft.id;
403809
- throw new Error("新建会话失败");
403810
- },
403811
- createWorkspace: async (path) => {
403812
- const ws = await service("workspaces").create({ path });
403813
- return {
403814
- workspaceId: ws.workspaceId,
403815
- title: ws.title
403816
- };
403817
- },
403818
- openSession: (id) => {
403819
- try {
403820
- const sessions = service("sessions");
403821
- sessions?.materializeScope?.(id);
403822
- sessions?.manager?.select?.(id);
403823
- sessions?.open?.(id);
403824
- } catch {}
403825
- },
403826
- reference: async (sessionId, path, appearance) => {
403827
- const outcome = await add.appendReference(sessionId, path, void 0, appearance);
403828
- return outcome === "ok" || outcome === "busy";
403829
- },
403830
- choose: (title, folder) => chooseWorkspace(title, folder),
403831
- openEditor: requestOpen,
403832
- schedule: (fn, ms) => setTimeout(fn, ms),
403833
- notify: (text) => toastDom(text),
403834
- ...overrides
403835
- };
403836
- }
403837
- /** @private @author ddj 2026年09月28号
403838
- * Await actual editor completion and propagate failure to the request result.
403839
- * @param deps Open dependencies.
403840
- * @param sessionId Target session.
403841
- * @param path Target file or editor mount.
403842
- * @param line Optional line.
403843
- * @param column Optional column.
403844
- */
403845
- async function openChecked(deps, sessionId, path, line, column) {
403846
- if (!await deps.openEditor(path, line, column, sessionId)) throw new Error("编辑器打开未完成:" + (path ?? "工作区"));
403847
- }
403848
- //#endregion
403849
- //#region src/client/externalOpen.ts
403850
- /**
403851
- * dsh-vscode-mode client — 外部深链落地(Windows 右键菜单 / Unity 外部编辑器)。
403852
- * 启动时解析 location.search 的 edrvOpen 深链:跨源 referrer 守卫防外部网页诱导;
403853
- * 处理完剥离 URL 参数防刷新重开;执行委托 openFlow 打开规则引擎(工作区/对话智能路由)。
403854
- * 另挂 3s 移交轮询(兼活跃心跳):launcher 投递的打开请求由已打开页面就地执行,避免重复开新页。
403855
- * 作者 ddj 2026-09-07 / 2026-09-08
403856
- */
403857
- /** toast 自隐时长。 */
403858
- const TOAST_MS = 5e3;
403859
- /** 深链移交轮询周期(兼活跃心跳)。 */
403860
- const HANDOFF_POLL_MS = 3e3;
403861
- /**
403862
- * 页面隐藏时的降频倍数(3s × 5 = 15s)。
403863
- * 必须仍短于 host 租约(src/externalHandoff.ts 的 LEASE_MS = 30s):心跳一旦超期,
403864
- * host 判定该页面已死,launcher 会重复开新页——比多几次 RPC 更糟。
403865
- */
403866
- const HIDDEN_POLL_EVERY = 5;
403867
- /**
403868
- * 装配外部深链:深链参数缺失时仅保持移交轮询(零额外开销)。
403869
- * @author ddj 2026年09月07号
403870
- * @param ctx 客户端根上下文(sessions/workspaces 服务)
403871
- * @param options 可注入选项
403872
- */
403873
- function setupExtOpen(ctx, options = {}) {
403874
- const dispose = startPresencePoll(ctx);
403875
- const params = parseOpenParams(options.search ?? (typeof location !== "undefined" ? location.search : ""));
403876
- if (!params) return dispose;
403877
- const referrer = options.referrer ?? (typeof document !== "undefined" ? document.referrer : "");
403878
- if (!referrerAllowed(referrer, options.origin ?? (typeof location !== "undefined" ? location.origin : ""))) {
403879
- log$2.warn("已忽略跨源深链请求(referrer=" + referrer + ")");
403880
- return dispose;
403881
- }
403882
- stripCurrentUrl();
403883
- openDeepLink(ctx, params).catch((error) => {
403884
- log$2.warn("深链打开失败:" + String(error));
403885
- toastDom("深链打开失败:" + String(error?.message ?? error));
403886
- });
403887
- return dispose;
403888
- }
403889
- /**
403890
- * 深链移交轮询:可见时每 3s 向 host 领取待打开请求(兼活跃心跳,launcher 据此决定是否开新页);
403891
- * 页面隐藏时降到 15s 一次——无人看页面时移交不必保持 3s 精度,但心跳不能停(超期会让 host 判页面已死)。
403892
- * 页面级单例(window 标记防 HMR 重复);领取到即执行打开规则。
403893
- * @author ddj 2026年09月08号 / 2026年10月15号
403894
- * @param ctx 客户端根上下文
403895
- */
403896
- function startPresencePoll(ctx) {
403897
- if (typeof window === "undefined") return () => {};
403898
- const marker = window;
403899
- if (typeof marker.__edrvExtPoll === "function") marker.__edrvExtPoll();
403900
- const generatedId = createRequestId();
403901
- if (!generatedId) {
403902
- log$2.warn("安全随机源不可用,已停用外部打开心跳");
403903
- return () => {};
403904
- }
403905
- const clientId = generatedId;
403906
- let disposed = false;
403907
- let polling = false;
403908
- let executing = false;
403909
- let ticks = 0;
403910
- /**
403911
- * 页面隐藏时按倍数跳轮;可见时每次都跑并清零计数。
403912
- * @author ddj 2026年10月15号
403913
- * @returns 是否跳过本轮
403914
- */
403915
- function hiddenSkip() {
403916
- if (typeof document === "undefined" || document.visibilityState !== "hidden") {
403917
- ticks = 0;
403918
- return false;
403919
- }
403920
- ticks += 1;
403921
- return ticks % HIDDEN_POLL_EVERY !== 0;
403922
- }
403923
- /** @private @author ddj 2026年09月28号 Open all requested paths before acknowledging this exact lease. */
403924
- async function execute(open) {
403925
- executing = true;
403926
- try {
403927
- const success = await openDeepLink(ctx, open);
403928
- if (!disposed) await rpc("edrv.external.ack", {
403929
- token: open.token,
403930
- leaseId: open.leaseId,
403931
- clientId,
403932
- success,
403933
- error: success ? void 0 : "编辑器打开失败或取消"
403934
- });
403935
- } finally {
403936
- executing = false;
403937
- }
403938
- }
403939
- /** @private @author ddj 2026年09月28号 Poll while opening to renew the lease without accepting a second request. */
403940
- async function tick() {
403941
- if (disposed || polling) return;
403942
- polling = true;
403943
- try {
403944
- const result = await rpc("edrv.external.pending", {
403945
- clientId,
403946
- busy: executing
403947
- });
403948
- if (!disposed && result.ok && result.open && !executing) execute(result.open).catch(() => {});
403949
- } finally {
403950
- polling = false;
403951
- }
403952
- }
403953
- const timer = window.setInterval(() => {
403954
- if (hiddenSkip()) return;
403955
- tick().catch(() => {});
403956
- }, HANDOFF_POLL_MS);
403957
- /** @private @author ddj 2026年09月28号 Stop heartbeats and fail outstanding editor waits on plugin disposal. */
403958
- function dispose() {
403959
- disposed = true;
403960
- window.clearInterval(timer);
403961
- disposeOpenWait();
403962
- if (marker.__edrvExtPoll === dispose) delete marker.__edrvExtPoll;
403963
- }
403964
- marker.__edrvExtPoll = dispose;
403965
- tick().catch(() => {});
403966
- return dispose;
403967
- }
403968
- /**
403969
- * 跨源守卫:referrer 为空(launcher/Unity 直开)或同源 → 允许。
403970
- * @author ddj 2026年09月07号
403971
- * @param referrer 文档 referrer
403972
- * @param origin 当前页面源
403973
- * @returns 是否允许处理深链
403974
- */
403975
- function referrerAllowed(referrer, origin) {
403976
- if (!referrer) return true;
403977
- try {
403978
- return new URL(referrer).origin === origin;
403979
- } catch {
403980
- return false;
403981
- }
403982
- }
403983
- /**
403984
- * 剥离 URL 中的深链参数(保留其余参数与 hash,防刷新重开)。
403985
- * @author ddj 2026年09月07号
403986
- * @param href 完整 URL
403987
- * @returns 清理后的 URL(pathname+search+hash)
403988
- */
403989
- function stripOpenHref(href) {
403990
- const url = new URL(href);
403991
- for (const key of EDRV_PARAM_KEYS) url.searchParams.delete(key);
403992
- return url.pathname + url.search + url.hash;
403993
- }
403994
- /** 清理当前页面 URL(best-effort,失败不阻塞打开)。 */
403995
- function stripCurrentUrl() {
403996
- try {
403997
- if (typeof location === "undefined" || typeof history === "undefined") return;
403998
- const next = stripOpenHref(location.href);
403999
- if (next !== location.pathname + location.search + location.hash) history.replaceState(null, "", next);
404000
- } catch {}
404001
- }
404002
- /**
404003
- * 轻量 toast(编辑器未挂载也能提示;5s 自隐;openFlow 通知与错误呈现共用)。
404004
- * @author ddj 2026年09月07号
404005
- * @param text 提示文本
404006
- */
404007
- function toastDom(text) {
404008
- if (typeof document === "undefined") return;
404009
- const toast = document.createElement("div");
404010
- toast.textContent = text;
404011
- toast.setAttribute("style", [
404012
- "position:fixed",
404013
- "left:50%",
404014
- "bottom:28px",
404015
- "transform:translateX(-50%)",
404016
- "z-index:99999",
404017
- "padding:8px 14px",
404018
- "border-radius:8px",
404019
- "max-width:80vw",
404020
- "background:#2a2a2a",
404021
- "color:#ddd",
404022
- "border:1px solid #444",
404023
- "font-size:12px",
404024
- "box-shadow:0 4px 16px rgba(0,0,0,.35)"
404025
- ].join(";"));
404026
- document.body?.appendChild(toast);
404027
- setTimeout(() => toast.remove(), TOAST_MS);
404028
- }
404029
- //#endregion
404030
404203
  //#region src/client/compatSummary.ts
404031
404204
  /**
404032
404205
  * Classify the API generation without importing the host's node-only version probe.
@@ -411139,6 +411312,107 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411139
411312
  function clearFileClip() {
411140
411313
  clip = null;
411141
411314
  }
411315
+ //#endregion
411316
+ //#region src/client/downloadSink.ts
411317
+ /**
411318
+ * 让用户选择保存目录。
411319
+ * @author ddj 2026年11月03号
411320
+ * @returns 所选目录 / 回落原因 / 'cancelled'(用户取消,整批终止)
411321
+ * @sideEffects 弹出系统目录选择器,成功后可能触发一次写权限请求
411322
+ */
411323
+ async function pickSaveSink() {
411324
+ if (globalThis.isSecureContext === false) return {
411325
+ kind: "browser",
411326
+ reason: "insecure"
411327
+ };
411328
+ const picker = pickerOf();
411329
+ if (!picker) return {
411330
+ kind: "browser",
411331
+ reason: "unsupported"
411332
+ };
411333
+ let dir;
411334
+ try {
411335
+ dir = await picker({
411336
+ id: "vsm-download",
411337
+ mode: "readwrite",
411338
+ startIn: "downloads"
411339
+ });
411340
+ } catch (error) {
411341
+ return isAbort(error) ? "cancelled" : {
411342
+ kind: "browser",
411343
+ reason: "denied"
411344
+ };
411345
+ }
411346
+ return await ensureWritable(dir) ? {
411347
+ kind: "dir",
411348
+ dir
411349
+ } : {
411350
+ kind: "browser",
411351
+ reason: "denied"
411352
+ };
411353
+ }
411354
+ /**
411355
+ * 取回 `showDirectoryPicker`(不安全上下文视为没有)。
411356
+ * @author ddj 2026年11月03号
411357
+ * @returns 绑定到 globalThis 的选目录函数;不可用为 undefined
411358
+ */
411359
+ function pickerOf() {
411360
+ const win = globalThis;
411361
+ if (win.isSecureContext === false) return void 0;
411362
+ return typeof win.showDirectoryPicker === "function" ? win.showDirectoryPicker.bind(win) : void 0;
411363
+ }
411364
+ /**
411365
+ * 确认目录可写(部分浏览器选择后仍需显式请求一次 readwrite 权限)。
411366
+ * @author ddj 2026年11月03号
411367
+ * @param dir 目录句柄
411368
+ * @returns 已获得写权限为 true
411369
+ * @throws 权限查询本身抛错时向上透传(由调用方按回落处理)
411370
+ */
411371
+ async function ensureWritable(dir) {
411372
+ if (!dir.queryPermission) return true;
411373
+ if (await dir.queryPermission({ mode: "readwrite" }) === "granted") return true;
411374
+ if (!dir.requestPermission) return false;
411375
+ return await dir.requestPermission({ mode: "readwrite" }) === "granted";
411376
+ }
411377
+ /**
411378
+ * 把字节写到所选目录下的相对路径处(自动逐级建子目录)。
411379
+ * @author ddj 2026年11月03号
411380
+ * @param dir 目标目录句柄
411381
+ * @param relPath 工作区相对路径(分隔符 `/`;越界段 `..` 会被丢弃)
411382
+ * @param bytes 原始字节
411383
+ * @sideEffects 在用户所选目录内创建目录与文件并覆盖同名文件
411384
+ * @throws 磁盘写入失败或文件名非法时透传底层错误
411385
+ */
411386
+ async function writeFileToDir(dir, relPath, bytes) {
411387
+ const parts = pathSegments(relPath);
411388
+ const fileName = parts.pop() ?? "download";
411389
+ let parent = dir;
411390
+ for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true });
411391
+ const writable = await (await parent.getFileHandle(fileName, { create: true })).createWritable();
411392
+ try {
411393
+ await writable.write(bytes);
411394
+ } finally {
411395
+ await writable.close();
411396
+ }
411397
+ }
411398
+ /**
411399
+ * 相对路径切段(过滤空段、`.` 与越界 `..`,兼容反斜杠)。
411400
+ * @author ddj 2026年11月03号
411401
+ * @param relPath 工作区相对路径
411402
+ * @returns 安全的路径段(末段为文件名)
411403
+ */
411404
+ function pathSegments(relPath) {
411405
+ return relPath.split(/[\\/]+/).map((part) => part.trim()).filter((part) => part !== "" && part !== "." && part !== "..");
411406
+ }
411407
+ /**
411408
+ * 判断是否用户取消(AbortError),不依赖 DOMException 构造函数的存在。
411409
+ * @author ddj 2026年11月03号
411410
+ * @param error 捕获值
411411
+ * @returns 取消为 true
411412
+ */
411413
+ function isAbort(error) {
411414
+ return typeof error === "object" && error !== null && error.name === "AbortError";
411415
+ }
411142
411416
  /**
411143
411417
  * 初始状态:根目录默认展开,等待首层 listDir 填充。
411144
411418
  * @author ddj 2026年11月03号
@@ -411513,7 +411787,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411513
411787
  dialogClass: "edrv-dl-dialog",
411514
411788
  width: "min(640px, calc(100vw - 40px))",
411515
411789
  onClose
411516
- }, react$1.default.createElement("h3", null, "下载到本机"), react$1.default.createElement("div", { className: "edrv-dl-sub" }, root ? "/" + root : "工作区根目录"), renderTree({
411790
+ }, react$1.default.createElement("h3", null, "下载到本机"), react$1.default.createElement("div", { className: "edrv-dl-sub" }, root ? "/" + root : "工作区根目录"), react$1.default.createElement("div", { className: "edrv-dl-hint" }, "点下载后会先选择保存文件夹,文件按相对路径写入"), renderTree({
411517
411791
  state,
411518
411792
  root,
411519
411793
  rows,
@@ -411565,7 +411839,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411565
411839
  className: "edrv-dl-go",
411566
411840
  disabled: !picked.length,
411567
411841
  onClick: () => onConfirm(picked)
411568
- }, "下载 " + picked.length + " 个文件"));
411842
+ }, "选择位置并下载 " + picked.length + " 个文件"));
411569
411843
  }
411570
411844
  /**
411571
411845
  * 目录行:三角展开 / 收起,名称后带加载或错误提示。
@@ -411723,16 +411997,17 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411723
411997
  };
411724
411998
  }
411725
411999
  /**
411726
- * 下载单个文件并交给浏览器落盘。
412000
+ * 下载单个文件并落盘(所选目录则按相对路径写入,否则交给浏览器 `<a download>`)。
411727
412001
  * @author ddj 2026年11月03号
411728
412002
  * @param path 工作区相对路径
411729
- * @param opts 会话 / 取消选项
412003
+ * @param opts 会话 / 取消 / 落盘选项
411730
412004
  * @returns 路径与字节数
411731
- * @throws 读取失败透传主机错误
412005
+ * @throws 读取失败透传主机错误;目录写入失败透传底层错误
411732
412006
  */
411733
412007
  async function downloadOne(path, opts = {}) {
411734
412008
  const file = await fetchDownload(path, opts.sessionId);
411735
- downloadBytes(file.bytes, baseNameOf$1(path) || "download", file.mime);
412009
+ if (opts.sink?.kind === "dir") await writeFileToDir(opts.sink.dir, path, file.bytes);
412010
+ else downloadBytes(file.bytes, baseNameOf$1(path) || "download", file.mime);
411736
412011
  return {
411737
412012
  path,
411738
412013
  size: file.size
@@ -411787,22 +412062,43 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411787
412062
  });
411788
412063
  }
411789
412064
  /**
411790
- * 批量下载并统一反馈(开始一条、结束一条,避免逐文件刷屏)。
412065
+ * 批量下载并统一反馈(先选保存目录,再逐文件落盘;开始一条、结束一条,避免逐文件刷屏)。
411791
412066
  * @author ddj 2026年11月03号
411792
412067
  * @param paths 勾选路径
411793
412068
  * @param ctx 面板上下文
412069
+ * @sideEffects 弹出目录选择器;用户取消则整批中止
411794
412070
  */
411795
412071
  async function runBatch(paths, ctx) {
411796
412072
  if (!paths.length) return;
412073
+ const sink = await pickSaveSink();
412074
+ if (sink === "cancelled") {
412075
+ ctx?.notify?.("已取消下载");
412076
+ return;
412077
+ }
412078
+ if (sink.kind === "browser") ctx?.notify?.(fallbackText(sink.reason));
411797
412079
  if (paths.length > 1) ctx?.notify?.("正在下载 " + paths.length + " 个文件…");
411798
412080
  try {
411799
- const out = await downloadMany(paths, { sessionId: ctx?.sessionId });
412081
+ const out = await downloadMany(paths, {
412082
+ sessionId: ctx?.sessionId,
412083
+ sink
412084
+ });
411800
412085
  ctx?.notify?.(batchText(out));
411801
412086
  } catch (error) {
411802
412087
  ctx?.notify?.("下载失败:" + textOfError(error));
411803
412088
  }
411804
412089
  }
411805
412090
  /**
412091
+ * 目录选择不可用时的提示文案(说明文件去了浏览器默认下载目录)。
412092
+ * @author ddj 2026年11月03号
412093
+ * @param reason 回落原因
412094
+ * @returns 单行提示文案
412095
+ */
412096
+ function fallbackText(reason) {
412097
+ if (reason === "unsupported") return "浏览器不支持选择保存目录,已保存到默认下载目录";
412098
+ if (reason === "insecure") return "当前页面不是安全上下文(需 https 或 localhost),已保存到默认下载目录";
412099
+ return "未获得目录写入权限,已保存到默认下载目录";
412100
+ }
412101
+ /**
411806
412102
  * 批量结果口播文案。
411807
412103
  * @author ddj 2026年11月03号
411808
412104
  * @param out 批量结果