@xrn1997/dsh-session-delete 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/lib/client.js +1496 -985
  3. package/lib/index.js +821 -710
  4. package/lib/types/client/batch-bar.d.ts +2 -1
  5. package/lib/types/client/batch-confirm.d.ts +1 -1
  6. package/lib/types/client/busy-gate.d.ts +6 -0
  7. package/lib/types/client/copy.d.ts +2 -2
  8. package/lib/types/client/host-rows.d.ts +18 -3
  9. package/lib/types/client/main-view.d.ts +7 -2
  10. package/lib/types/client/orphan-maintenance.d.ts +27 -0
  11. package/lib/types/client/receipt.d.ts +33 -3
  12. package/lib/types/client/remote.d.ts +18 -22
  13. package/lib/types/client/store.d.ts +31 -0
  14. package/lib/types/client/trash-nav.d.ts +30 -0
  15. package/lib/types/client/undo-toast.d.ts +11 -3
  16. package/lib/types/client/use-trash-panel-controller.d.ts +7 -20
  17. package/lib/types/client/use-trash-query.d.ts +20 -9
  18. package/lib/types/client/write-seats.d.ts +52 -0
  19. package/lib/types/client/write-settlement.d.ts +72 -0
  20. package/lib/types/client/write.d.ts +51 -0
  21. package/lib/types/deps.d.ts +2 -2
  22. package/lib/types/index.d.ts +2 -1
  23. package/lib/types/ports/host-port.d.ts +4 -18
  24. package/lib/types/shared/wire.d.ts +7 -0
  25. package/lib/types/trash/detail.d.ts +5 -2
  26. package/lib/types/trash/groups.d.ts +17 -6
  27. package/lib/types/trash/ledger.d.ts +20 -0
  28. package/lib/types/trash/manifest.d.ts +2 -1
  29. package/lib/types/trash/reconcile.d.ts +4 -3
  30. package/lib/types/verbs/batch-carried.d.ts +20 -0
  31. package/lib/types/verbs/delete-session.d.ts +4 -3
  32. package/lib/types/verbs/errors.d.ts +2 -10
  33. package/lib/types/verbs/live-admission.d.ts +13 -0
  34. package/lib/types/verbs/member-journal.d.ts +22 -3
  35. package/lib/types/verbs/member-presence.d.ts +38 -6
  36. package/lib/types/verbs/move-group.d.ts +20 -0
  37. package/lib/types/verbs/orphan-subagents.d.ts +8 -3
  38. package/lib/types/verbs/permanent-removal.d.ts +7 -6
  39. package/lib/types/verbs/purge-trash.d.ts +14 -6
  40. package/lib/types/verbs/restore-session.d.ts +8 -6
  41. package/package.json +3 -2
  42. package/lib/types/client/write-commit.d.ts +0 -10
package/lib/client.js CHANGED
@@ -95,8 +95,8 @@ window.__ModuleLoader__.load({
95
95
  /**
96
96
  * 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
97
97
  *
98
- * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id、`src/client/remote.ts` 的
99
- * 幂等空值与取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
98
+ * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id 与单条幂等空值、
99
+ * `src/client/remote.ts` 的取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
100
100
  *
101
101
  * ## 为什么不是散在各组件里的中文字面量
102
102
  *
@@ -445,6 +445,13 @@ window.__ModuleLoader__.load({
445
445
  * 在这之前每个文件各写一遍「`let` 现场 + `Set<() => void>` + `notify` + `useSyncExternalStore`」,
446
446
  * 各自的 seq / revision 也在分头维护。现在订阅/通告只住这一处。
447
447
  *
448
+ * 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(宿主 DOM)现读,
449
+ * 脏了才重读;订阅脚手架仍住这里,观察者生命周期由调用方挂 `onFirstSubscribe` / `onLastUnsubscribe`。
450
+ *
451
+ * 还有一个**最新票句柄**(`createLatestWins`):并发的异步读里"只有最新那次可以落地"原先在每个
452
+ * 调用方各写一遍自增序号,现在只住这一处。它只拥有 compare-and-set——防抖、轮询、焦点策略仍是
453
+ * 调用方的现场,不搬进来。
454
+ *
448
455
  * 语义(四条,别在调用方另算一遍):
449
456
  * - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
450
457
  * - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
@@ -497,6 +504,55 @@ window.__ModuleLoader__.load({
497
504
  }
498
505
  };
499
506
  }
507
+ function createLazyStore(initial, read, lifecycle = {}) {
508
+ let cached = initial;
509
+ let dirty = true;
510
+ const listeners = /* @__PURE__ */ new Set();
511
+ const notify = () => {
512
+ for (const listener of listeners) listener();
513
+ };
514
+ return {
515
+ get() {
516
+ if (dirty) {
517
+ cached = read();
518
+ dirty = false;
519
+ }
520
+ return cached;
521
+ },
522
+ invalidate() {
523
+ dirty = true;
524
+ notify();
525
+ },
526
+ subscribe(listener) {
527
+ const reattaching = listeners.size === 0;
528
+ listeners.add(listener);
529
+ if (reattaching) {
530
+ dirty = true;
531
+ lifecycle.onFirstSubscribe?.();
532
+ }
533
+ return () => {
534
+ listeners.delete(listener);
535
+ if (listeners.size > 0) return;
536
+ lifecycle.onLastUnsubscribe?.();
537
+ dirty = true;
538
+ };
539
+ }
540
+ };
541
+ }
542
+ function createLatestWins() {
543
+ let seq = 0;
544
+ return {
545
+ issue() {
546
+ const token = ++seq;
547
+ return { get current() {
548
+ return token === seq;
549
+ } };
550
+ },
551
+ invalidate() {
552
+ seq += 1;
553
+ }
554
+ };
555
+ }
500
556
  //#endregion
501
557
  //#region src/client/batch-selection-store.ts
502
558
  /**
@@ -584,12 +640,18 @@ window.__ModuleLoader__.load({
584
640
  //#endregion
585
641
  //#region src/client/host-rows.ts
586
642
  /**
587
- * 宿主会话行的**DOM 适配**(2026-10-20):可见行解析、全选判定、模式内的整行点击守卫。
643
+ * 宿主会话行的**DOM 适配**:可见行解析、全选判定、模式内的整行点击守卫。
588
644
  *
589
645
  * 从 `batch-select.tsx` 拆出来的理由:选中集本身(模式 + 名单)只住 `batch-selection-store.ts`;
590
646
  * 本文件只回答**页面上**的两件事——"现在 DOM 里有哪些可选的会话行"与"在这个行上点一下算不算选中"。
591
647
  * 两边的唯一交界是 `data-row-key`(宿主会话行的稳定标记)和行尾兜底登记的那张 `rowInfo` 表。
592
648
  *
649
+ * **为什么渲染走快照**:可见行是组件树之外的状态(宿主的 DOM)。每个组件各自在渲染期查一遍,
650
+ * 同一帧看到什么就取决于副作用顺序,而且 DOM 变了也不会触发重渲染。这里交给 `useSyncExternalStore`:
651
+ * DOM 观察(`MutationObserver`)与行登记都留在本适配器里,只把缓存标脏并通告订阅者;组件读缓存快照,
652
+ * 干净时拿到同一个引用(React 的 snapshot 契约),脏了才重读。事件路径(全选)仍走 `readVisibleSelectableRows`
653
+ * 的当场读。裁决见 ADR-0015。
654
+ *
593
655
  * **为什么整行点击要抢在 document 捕获阶段**:宿主的会话行自己带 `onClick = open(sessionId)`,
594
656
  * 第三方没有官方的"别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
595
657
  */
@@ -599,14 +661,47 @@ window.__ModuleLoader__.load({
599
661
  * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
600
662
  */
601
663
  const rowInfo = /* @__PURE__ */ new Map();
602
- /** 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。 */
664
+ /**
665
+ * 渲染侧快照 + DOM 观察:订阅脚手架住 `store.ts` 的 `createLazyStore`;这里只提供读法与
666
+ * 观察者生命周期。宿主行的去留只体现在 `data-row-key` 节点上(增删、属性落定),文本变化
667
+ * 不算(标题只认 `rowInfo` 的登记值)。首个订阅者起开始观察,最后一个走了断开。
668
+ */
669
+ const EMPTY_ROWS = [];
670
+ let observer;
671
+ function startObserving() {
672
+ if (observer !== void 0 || typeof MutationObserver === "undefined") return;
673
+ observer = new MutationObserver(() => visibleRows.invalidate());
674
+ observer.observe(document.body, {
675
+ childList: true,
676
+ subtree: true,
677
+ attributes: true,
678
+ attributeFilter: ["data-row-key"]
679
+ });
680
+ }
681
+ function stopObserving() {
682
+ observer?.disconnect();
683
+ observer = void 0;
684
+ }
685
+ const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows, {
686
+ onFirstSubscribe: startObserving,
687
+ onLastUnsubscribe: stopObserving
688
+ });
689
+ /**
690
+ * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
691
+ * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
692
+ */
603
693
  function registerHostRowInfo(sessionId, info) {
694
+ const current = rowInfo.get(sessionId);
695
+ if (current?.title === info.title && current.live === info.live) return;
604
696
  rowInfo.set(sessionId, info);
697
+ visibleRows.invalidate();
605
698
  }
606
699
  /**
607
700
  * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
608
701
  * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
609
702
  * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
703
+ *
704
+ * 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
610
705
  */
611
706
  function readVisibleSelectableRows() {
612
707
  const rows = [];
@@ -626,6 +721,24 @@ window.__ModuleLoader__.load({
626
721
  }
627
722
  return rows;
628
723
  }
724
+ /**
725
+ * `enabled === false` 时的稳定替身:同一个空表、不订阅、不读 DOM。
726
+ * 浮条是 `shell.overlay` 的常驻槽位,模式外若照常订阅,body 上每次变更(以及每次行登记)
727
+ * 都会白叫醒它重渲染一次。
728
+ */
729
+ function getEmptyRowsSnapshot() {
730
+ return EMPTY_ROWS;
731
+ }
732
+ function noopSubscribe() {
733
+ return () => {};
734
+ }
735
+ /**
736
+ * 渲染侧只读快照 + DOM 观察订阅;首个订阅者起开始观察,最后一个走了断开。
737
+ * `enabled === false` 不订阅也不读 DOM——行列表只随多选态存在,模式外不必付观察的成本。
738
+ */
739
+ function useVisibleSelectableRows(enabled) {
740
+ return (0, react.useSyncExternalStore)(enabled ? visibleRows.subscribe : noopSubscribe, enabled ? visibleRows.get : getEmptyRowsSnapshot, enabled ? visibleRows.get : getEmptyRowsSnapshot);
741
+ }
629
742
  /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
630
743
  function selectAllVisible() {
631
744
  addPicks(readVisibleSelectableRows());
@@ -636,10 +749,8 @@ window.__ModuleLoader__.load({
636
749
  * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
637
750
  * - 其余(含 0 条)⇒ 全选。
638
751
  */
639
- function allVisibleSelected() {
640
- const picks = getSelection().picks;
752
+ function areAllVisibleRowsSelected(rows, picks) {
641
753
  if (picks.length === 0) return false;
642
- const rows = readVisibleSelectableRows();
643
754
  if (rows.length === 0) return true;
644
755
  const chosen = new Set(picks.map((item) => item.sessionId));
645
756
  return rows.every((row) => chosen.has(row.sessionId));
@@ -780,9 +891,14 @@ window.__ModuleLoader__.load({
780
891
  }
781
892
  //#endregion
782
893
  //#region src/client/receipt.ts
894
+ /** 浏览器半自己的未分类诊断码(通道打不通 / 非信封 / 单条协议错)。它不过线,线上失败码仍归 `shared/wire.ts`。 */
895
+ const CLIENT_UNKNOWN_CODE = "client/unknown";
783
896
  function missingOutcomeMessage(action, id) {
784
897
  return `批量通道没回这一条的结果:${action} ${id}`;
785
898
  }
899
+ function singleNullMessage(action, id) {
900
+ return `单条动作回了幂等空值:${action} ${id}`;
901
+ }
786
902
  function isFailureVerdict(verdict) {
787
903
  return verdict.kind === "failed" || verdict.kind === "neutral" || verdict.kind === "missing";
788
904
  }
@@ -790,7 +906,7 @@ window.__ModuleLoader__.load({
790
906
  function failureMessage(verdict) {
791
907
  return verdict.kind === "failed" || verdict.kind === "neutral" ? verdict.error.message : verdict.message;
792
908
  }
793
- function decode$1(action, outcome) {
909
+ function decode$1(action, outcome, options) {
794
910
  if (outcome.action !== action) return void 0;
795
911
  const typed = outcome;
796
912
  if (!typed.ok) return action === "orphan-clean" && typed.error.code === SESSION_DELETE_CODES["not-found"] ? {
@@ -803,22 +919,30 @@ window.__ModuleLoader__.load({
803
919
  error: typed.error
804
920
  };
805
921
  const value = typed.value;
806
- return value === null ? {
922
+ if (value === null) return options.single === true ? {
923
+ kind: "failed",
924
+ id: typed.id,
925
+ error: {
926
+ code: CLIENT_UNKNOWN_CODE,
927
+ message: singleNullMessage(action, typed.id)
928
+ }
929
+ } : {
807
930
  kind: "already-gone",
808
931
  id: typed.id
809
- } : {
932
+ };
933
+ return {
810
934
  kind: "ok",
811
935
  id: typed.id,
812
936
  value
813
937
  };
814
938
  }
815
- function readReceipt(action, requestedIds, outcomes) {
939
+ function readReceipt(action, requestedIds, outcomes, options = {}) {
816
940
  const wanted = [...new Set(requestedIds)];
817
941
  const wantedSet = new Set(wanted);
818
942
  const byId = /* @__PURE__ */ new Map();
819
943
  for (const outcome of outcomes) {
820
944
  if (!wantedSet.has(outcome.id) || byId.has(outcome.id)) continue;
821
- const verdict = decode$1(action, outcome);
945
+ const verdict = decode$1(action, outcome, options);
822
946
  if (verdict !== void 0) byId.set(outcome.id, verdict);
823
947
  }
824
948
  const verdicts = wanted.map((id) => byId.get(id) ?? {
@@ -831,6 +955,50 @@ window.__ModuleLoader__.load({
831
955
  byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
832
956
  };
833
957
  }
958
+ /**
959
+ * N=1 的读法:null 折成协议错,类型面因此不含 `already-gone`(下面那次过滤只服务类型收窄,
960
+ * single 读法运行期不会产出它)。`applyOne` 用它之后只剩「按 id 取一条 + 抛失败」。
961
+ */
962
+ function readSingleReceipt(action, id, outcomes) {
963
+ const verdicts = readReceipt(action, [id], outcomes, { single: true }).verdicts.filter((verdict) => verdict.kind !== "already-gone");
964
+ return {
965
+ verdicts,
966
+ byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
967
+ };
968
+ }
969
+ function isRestoreEntry(value) {
970
+ return typeof value === "object" && value !== null && "sessionId" in value;
971
+ }
972
+ function isEntryWithId(value) {
973
+ return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string";
974
+ }
975
+ /**
976
+ * `/apply` 成功支回执里的条目 id(delete / restore 的 `value.id`);purge 的聚合值与 count 的数字值跳过。
977
+ * 撤销名单只收 `ok` 支的条目 id:already-gone 是「已被同批前一条带走」的幂等条,跟着组走,不单列。
978
+ */
979
+ function landedEntryIds(receipt) {
980
+ const ids = [];
981
+ for (const verdict of receipt.verdicts) if (verdict.kind === "ok" && isEntryWithId(verdict.value)) ids.push(verdict.value.id);
982
+ return ids;
983
+ }
984
+ /**
985
+ * restore 回执里真正回盘的会话 id:`ok` 支取条目上的 `sessionId`;`already-gone` 没有行,
986
+ * 由调用方给条目查找兜底。purge / count 的 `ok` 值没有 `sessionId`,会被跳过。
987
+ */
988
+ function restoredSessionIds(receipt, sessionIdOfEntry) {
989
+ const ids = [];
990
+ for (const verdict of receipt.verdicts) {
991
+ if (verdict.kind === "ok") {
992
+ if (isRestoreEntry(verdict.value)) ids.push(verdict.value.sessionId);
993
+ continue;
994
+ }
995
+ if (verdict.kind === "already-gone" && sessionIdOfEntry !== void 0) {
996
+ const sessionId = sessionIdOfEntry(verdict.id);
997
+ if (sessionId !== void 0) ids.push(sessionId);
998
+ }
999
+ }
1000
+ return ids;
1001
+ }
834
1002
  //#endregion
835
1003
  //#region src/client/remote.ts
836
1004
  /**
@@ -878,11 +1046,11 @@ window.__ModuleLoader__.load({
878
1046
  ...reason.details === void 0 ? {} : { details: reason.details }
879
1047
  };
880
1048
  if (reason instanceof Error) return {
881
- code: "client/unknown",
1049
+ code: CLIENT_UNKNOWN_CODE,
882
1050
  message: reason.message
883
1051
  };
884
1052
  return {
885
- code: "client/unknown",
1053
+ code: CLIENT_UNKNOWN_CODE,
886
1054
  message: String(reason)
887
1055
  };
888
1056
  }
@@ -989,23 +1157,18 @@ window.__ModuleLoader__.load({
989
1157
  }
990
1158
  }
991
1159
  /**
992
- * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
1160
+ * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 必然非空)。
993
1161
  *
994
- * 三条判据都在这一处:
1162
+ * 三件事各归一个家:
995
1163
  * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
996
- * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
997
- * - `null` 是批量里"已被同批前一条带走"的幂等值——N=1 永远不会命中它,回 `null` 只能是通道答错了,
998
- * 所以同样按通道错抛,不冒充成功。
999
- *
1000
- * 成/败/幂等/缺失的判读只走 {@link readReceipt}(一个家)。
1164
+ * - 成/败/幂等/缺失与「单条 null 是协议错」的判读只走 {@link readSingleReceipt};
1165
+ * - 这里只把 verdict 折成「拿到值 / 抛失败」。
1001
1166
  */
1002
1167
  async function applyOne(deps, action, id) {
1003
- const outcomes = await deps.applyOutcomes(action, [id]);
1004
- const verdict = readReceipt(action, [id], outcomes).byId.get(id);
1168
+ const verdict = readSingleReceipt(action, id, await deps.applyOutcomes(action, [id])).byId.get(id);
1005
1169
  if (verdict === void 0) throw new Error(missingOutcomeMessage(action, id));
1006
1170
  if (verdict.kind === "failed" || verdict.kind === "neutral") throwRemoteFailure(verdict.error);
1007
1171
  if (verdict.kind === "missing") throw new Error(verdict.message);
1008
- if (verdict.kind === "already-gone") throw new Error(`单条动作回了幂等空值:${action} ${id}`);
1009
1172
  return {
1010
1173
  id,
1011
1174
  action,
@@ -1497,6 +1660,32 @@ window.__ModuleLoader__.load({
1497
1660
  });
1498
1661
  }
1499
1662
  //#endregion
1663
+ //#region src/client/busy-gate.ts
1664
+ /**
1665
+ * 「这一趟还算忙」的一枚闸——面板与两个确认框共用的一份实现(各面各持一枚实例)。
1666
+ *
1667
+ * 为什么收回一处:三个面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有一条:
1668
+ * 任务落地或抛错都把 busy 放回 false;不做引用计数——同一实例同一时刻只跑一趟写动作,多一层计数
1669
+ * 只会把「这一趟什么时候算完」变成第二个判据。
1670
+ *
1671
+ * 边界:闸只拥有 busy 的生命周期,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1672
+ */
1673
+ function useBusyGate() {
1674
+ const [busy, setBusy] = (0, react.useState)(false);
1675
+ const around = (0, react.useCallback)(async (task) => {
1676
+ setBusy(true);
1677
+ try {
1678
+ return await task();
1679
+ } finally {
1680
+ setBusy(false);
1681
+ }
1682
+ }, []);
1683
+ return (0, react.useMemo)(() => ({
1684
+ busy,
1685
+ around
1686
+ }), [busy, around]);
1687
+ }
1688
+ //#endregion
1500
1689
  //#region src/client/ui/Button.tsx
1501
1690
  /**
1502
1691
  * `Button`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Button,出处与三条改写规则
@@ -1671,6 +1860,32 @@ window.__ModuleLoader__.load({
1671
1860
  });
1672
1861
  }
1673
1862
  //#endregion
1863
+ //#region src/client/trash-store.ts
1864
+ /**
1865
+ * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1866
+ *
1867
+ * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(refreshAll)`),而删除发生在
1868
+ * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1869
+ * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1870
+ * 同理。
1871
+ *
1872
+ * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
1873
+ * 订阅/通告那套只住在 `store.ts` 的 `createStore` 里——这些现场都挂在 `shell.overlay`/`main`
1874
+ * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1875
+ *
1876
+ * 语义只有"变过"这一件事:一个递增的修订号(测试收场时归零)。面板把它写进 effect 的依赖里,
1877
+ * 戳一次重读一次;不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1878
+ */
1879
+ const revisionStore = createStore(0);
1880
+ /** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
1881
+ function invalidateTrash() {
1882
+ revisionStore.update((revision) => revision + 1);
1883
+ }
1884
+ /** 供面板订阅。 */
1885
+ function useTrashRevision() {
1886
+ return revisionStore.use();
1887
+ }
1888
+ //#endregion
1674
1889
  //#region src/client/ui/Toast.tsx
1675
1890
  /**
1676
1891
  * `Toast`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Toast,出处与三条改写规则
@@ -1729,12 +1944,130 @@ window.__ModuleLoader__.load({
1729
1944
  }), document.body);
1730
1945
  }
1731
1946
  //#endregion
1947
+ //#region src/client/undo-toast.tsx
1948
+ /**
1949
+ * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
1950
+ *
1951
+ * 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
1952
+ * (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
1953
+ * 宿主的官方配方同形——会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的
1954
+ * `workspace.row-toast` 读它(asar 原文:`inject("shell.overlay", … id: "workspace.row-toast" …
1955
+ * inject: rowToastInjected … }, RowActionToast)`,而
1956
+ * `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
1957
+ * 本文件就是那份 store + 那个常驻组件。
1958
+ *
1959
+ * 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
1960
+ * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1961
+ */
1962
+ const noticeStore = createStore(null);
1963
+ /** 删除成功后发布一条撤销提示(单条)。 */
1964
+ function publishUndoNotice(input) {
1965
+ noticeStore.publish((seq) => ({
1966
+ seq,
1967
+ kind: "one",
1968
+ entryId: input.entryId,
1969
+ title: input.title
1970
+ }));
1971
+ }
1972
+ /** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
1973
+ function publishUndoBatchNotice(input) {
1974
+ noticeStore.publish((seq) => ({
1975
+ seq,
1976
+ kind: "batch",
1977
+ entryIds: input.entryIds,
1978
+ n: input.n
1979
+ }));
1980
+ }
1981
+ /** 整条横幅不再留:撤销成了、停留时间走完,或清空回收站把所有落点一起清了。
1982
+ * 与下面 `dismissUndoNotice` 的分别是「整条不再留」与「按条目匹配」——清空之后没有条目还能撤销,
1983
+ * 所以那一路不需要名单。 */
1984
+ function clearUndoNotice() {
1985
+ noticeStore.set(null);
1986
+ }
1987
+ /**
1988
+ * 某个条目已经在别处恢复:把指向它的横幅熄掉。
1989
+ * 批量里任一命中就清整条——名单少了、计数没变,留着就是一条会骗人的「撤销」动作。
1990
+ */
1991
+ function dismissUndoNotice(entryIds) {
1992
+ const wanted = new Set(entryIds);
1993
+ noticeStore.update((current) => {
1994
+ if (current === null) return current;
1995
+ if (current.kind === "one") return wanted.has(current.entryId) ? null : current;
1996
+ return current.entryIds.some((id) => wanted.has(id)) ? null : current;
1997
+ });
1998
+ }
1999
+ /** 供组件订阅。 */
2000
+ function useUndoNotice() {
2001
+ return noticeStore.use();
2002
+ }
2003
+ /** 提示停留时长。**6 秒** 定死在本行(比 Toast 默认的 3 秒长)——`docs/ui-spec.md` §2 状态 ③ 只指到这里,不抄这个数。 */
2004
+ const UNDO_HOLD_MS = 6e3;
2005
+ /** 这次撤销开始时的那条横幅还在吗——期间被新删除覆盖、或由面板恢复熄灭时,旧请求不许改写它。 */
2006
+ function ownsNotice(current) {
2007
+ return noticeStore.get() === current;
2008
+ }
2009
+ /** 撤销失败:把原因换到同一条横幅上(seq 递增 ⇒ Toast 重挂载、重新计停留)。 */
2010
+ function failNotice(current, message) {
2011
+ if (!ownsNotice(current)) return;
2012
+ noticeStore.publish((seq) => ({
2013
+ ...current,
2014
+ seq,
2015
+ failure: message
2016
+ }));
2017
+ }
2018
+ /** 撤销:单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
2019
+ * null 是协议错);失败就把原因换到同一条横幅上,不静默。 */
2020
+ async function undo(deps, current) {
2021
+ const ids = current.kind === "one" ? [current.entryId] : current.entryIds;
2022
+ if (current.kind === "one" && current.entryId === "") return;
2023
+ const outcome = await restoreFromUndo(deps, {
2024
+ ids,
2025
+ single: current.kind === "one"
2026
+ });
2027
+ if (!outcome.ok) {
2028
+ failNotice(current, outcome.failure.message);
2029
+ return;
2030
+ }
2031
+ const failure = outcome.failures.values().next().value;
2032
+ if (failure !== void 0) {
2033
+ failNotice(current, failure);
2034
+ return;
2035
+ }
2036
+ if (ownsNotice(current)) clearUndoNotice();
2037
+ }
2038
+ /**
2039
+ * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
2040
+ * pill all belong here",见 asar 里 `shell.overlay` 的声明):没有待撤销的事时什么都不渲染。
2041
+ */
2042
+ function UndoToast({ deps, t = zhTranslate }) {
2043
+ const current = useUndoNotice();
2044
+ if (current === null) return null;
2045
+ const { seq, failure } = current;
2046
+ if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
2047
+ text: failure,
2048
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 }),
2049
+ holdMs: UNDO_HOLD_MS,
2050
+ onDone: clearUndoNotice
2051
+ }, seq);
2052
+ const canUndo = current.kind === "one" ? current.entryId !== "" : current.entryIds.length > 0;
2053
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
2054
+ tone: "success",
2055
+ text: current.kind === "one" ? t("undo.moved", { title: current.title === "" ? t("undo.session") : current.title }) : t("undo.movedBatch", { n: current.n }),
2056
+ holdMs: UNDO_HOLD_MS,
2057
+ actions: canUndo ? [{
2058
+ label: t("undo.undo"),
2059
+ onClick: () => void undo(deps, current)
2060
+ }] : void 0,
2061
+ onDone: clearUndoNotice
2062
+ }, seq);
2063
+ }
2064
+ //#endregion
1732
2065
  //#region src/client/main-view.ts
1733
2066
  let client;
1734
- /** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开。 */
2067
+ /** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开;面板恢复用 `forgetMainView` 清掉。 */
1735
2068
  let closedByUs = /* @__PURE__ */ new Set();
1736
- /** 重开排队的最新序号:后一次删除/恢复(或重开)让前一次的等待作废(只开最新那条)。 */
1737
- let openToken = 0;
2069
+ /** 重开排队的最新票:后一次删除/恢复(或重开)签发新票,前一次的等待随之作废(只开最新那条)。 */
2070
+ const reopen = createLatestWins();
1738
2071
  /** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
1739
2072
  function bindMainView(ctx) {
1740
2073
  client = ctx;
@@ -1763,7 +2096,7 @@ window.__ModuleLoader__.load({
1763
2096
  */
1764
2097
  function afterDelete(sessionIds) {
1765
2098
  if (sessionIds.length === 0) return;
1766
- openToken += 1;
2099
+ reopen.invalidate();
1767
2100
  const ui = uiWorkspaceOf();
1768
2101
  if (ui === void 0) return;
1769
2102
  const current = currentSessionOf(ui);
@@ -1777,29 +2110,36 @@ window.__ModuleLoader__.load({
1777
2110
  * 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
1778
2111
  *
1779
2112
  * 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
1780
- * 的成功条数可能多于一条;`openWhenListed` 的 token 守卫保证前一条的等待不会后落地抢焦点。
2113
+ * 的成功条数可能多于一条;`openWhenListed` 的最新票守卫保证前一条的等待不会后落地抢焦点。
1781
2114
  * 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
1782
2115
  */
1783
2116
  function afterRestore(sessionIds) {
1784
2117
  if (sessionIds.length === 0) return;
1785
- openToken += 1;
2118
+ reopen.invalidate();
1786
2119
  const targets = sessionIds.filter((id) => closedByUs.has(id));
1787
2120
  if (targets.length === 0) return;
1788
2121
  for (const id of targets) closedByUs.delete(id);
1789
2122
  openWhenListed(targets[targets.length - 1]);
1790
2123
  }
1791
2124
  /**
2125
+ * 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
2126
+ * 它不动 `reopen`:同一会话不会既在等重开、又躺在回收站里等面板恢复;在等的重开属于另一条动作。
2127
+ */
2128
+ function forgetMainView(sessionIds) {
2129
+ for (const id of sessionIds) closedByUs.delete(id);
2130
+ }
2131
+ /**
1792
2132
  * 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
1793
2133
  * 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——所以先等它出现,
1794
2134
  * 最多 2 秒;期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
1795
2135
  *
1796
- * **token 守卫**(`openToken`):开始等待时取一个序号,立即开与每次 tick 都核对;后一次删除/
1797
- * 恢复(或 `resetMainView`)推进序号,前一次的等待就不再打开、也不再续时。
2136
+ * **最新票守卫**(`reopen`):开始等待时签一张票,立即开与每次 tick 都核对;后一次删除/
2137
+ * 恢复(或 `resetMainView`)签发新票或作废当前票,前一次的等待就不再打开、也不再续时。
1798
2138
  */
1799
2139
  function openWhenListed(sessionId) {
1800
2140
  const ui = uiWorkspaceOf();
1801
2141
  if (ui === void 0) return;
1802
- const token = ++openToken;
2142
+ const ticket = reopen.issue();
1803
2143
  const listReady = () => {
1804
2144
  try {
1805
2145
  return ui.sessions?.list?.getSnapshot()?.byId?.[sessionId] !== void 0;
@@ -1812,7 +2152,7 @@ window.__ModuleLoader__.load({
1812
2152
  return current === void 0 || current === sessionId;
1813
2153
  };
1814
2154
  const open = () => {
1815
- if (token !== openToken) return;
2155
+ if (!ticket.current) return;
1816
2156
  if (!canTakeFocus()) return;
1817
2157
  try {
1818
2158
  ui.openSession?.(sessionId);
@@ -1824,7 +2164,7 @@ window.__ModuleLoader__.load({
1824
2164
  }
1825
2165
  let attempts = 0;
1826
2166
  const tick = () => {
1827
- if (token !== openToken) return;
2167
+ if (!ticket.current) return;
1828
2168
  attempts += 1;
1829
2169
  if (listReady()) {
1830
2170
  open();
@@ -1835,151 +2175,184 @@ window.__ModuleLoader__.load({
1835
2175
  setTimeout(tick, 100);
1836
2176
  }
1837
2177
  //#endregion
1838
- //#region src/client/trash-store.ts
1839
- /**
1840
- * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1841
- *
1842
- * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
1843
- * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1844
- * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1845
- * 同理。
1846
- *
1847
- * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
1848
- * 订阅/通告那套只住在 `store.ts` 的 `createStore` 里——这些现场都挂在 `shell.overlay`/`main`
1849
- * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1850
- *
1851
- * 语义只有"变过"这一件事:一个递增的修订号(测试收场时归零)。面板把它写进 effect 的依赖里,
1852
- * 戳一次重读一次;不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1853
- */
1854
- const revisionStore = createStore(0);
1855
- /** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
1856
- function invalidateTrash() {
1857
- revisionStore.update((revision) => revision + 1);
2178
+ //#region src/client/write-settlement.ts
2179
+ const NO_PRESENTATION = {
2180
+ delete: {},
2181
+ restore: {},
2182
+ purge: {},
2183
+ count: {},
2184
+ "orphan-clean": {}
2185
+ };
2186
+ /** 没有呈现材质的座位用它:抛掉按 action 的联合,调用点不用各自造一个空对象字面量。 */
2187
+ function noPresentation(action) {
2188
+ return NO_PRESENTATION[action];
2189
+ }
2190
+ function settleWrite(success, presentation) {
2191
+ switch (success.action) {
2192
+ case "delete": {
2193
+ const seat = presentation;
2194
+ afterDelete(success.successIds);
2195
+ seat.invalidate?.();
2196
+ if (success.anySuccess) {
2197
+ const [first, ...rest] = landedEntryIds(success.receipt);
2198
+ if (first !== void 0) {
2199
+ const entryIds = [first, ...rest];
2200
+ seat.publishUndo?.({
2201
+ entryIds,
2202
+ n: success.successIds.length
2203
+ });
2204
+ }
2205
+ }
2206
+ return;
2207
+ }
2208
+ case "restore": {
2209
+ const seat = presentation;
2210
+ const restored = restoredSessionIds(success.receipt, seat.sessionIdOfEntry);
2211
+ if (seat.panelSeat !== true) afterRestore(restored);
2212
+ seat.invalidate?.();
2213
+ if (!success.anySuccess) return;
2214
+ seat.dismissUndo?.(success.successIds);
2215
+ if (seat.panelSeat === true) forgetMainView(restored);
2216
+ seat.clearDetailCache?.(success.successIds);
2217
+ return;
2218
+ }
2219
+ case "purge": {
2220
+ const seat = presentation;
2221
+ if (success.anySuccess) {
2222
+ seat.dismissUndo?.(success.successIds);
2223
+ seat.clearDetailCache?.(success.successIds);
2224
+ }
2225
+ return;
2226
+ }
2227
+ case "count":
2228
+ case "orphan-clean": return;
2229
+ default: return success.action;
2230
+ }
1858
2231
  }
1859
- /** 供面板订阅。 */
1860
- function useTrashRevision() {
1861
- return revisionStore.use();
2232
+ //#endregion
2233
+ //#region src/client/write.ts
2234
+ async function runWrite(input) {
2235
+ const lifecycle = async () => {
2236
+ const ids = [...new Set(input.ids)];
2237
+ const outcome = await callRemote(() => input.deps.applyOutcomes(input.action, ids));
2238
+ if (!outcome.ok) {
2239
+ const failures = new Map(ids.map((id) => [id, outcome.failure.message]));
2240
+ const failed = {
2241
+ ok: false,
2242
+ action: input.action,
2243
+ ids,
2244
+ failure: outcome.failure,
2245
+ failures
2246
+ };
2247
+ await input.refresh?.(failed);
2248
+ return failed;
2249
+ }
2250
+ const single = input.single === true && ids.length === 1;
2251
+ const receipt = readReceipt(input.action, ids, outcome.value, { single });
2252
+ const failures = /* @__PURE__ */ new Map();
2253
+ for (const verdict of receipt.verdicts) if (isFailureVerdict(verdict)) failures.set(verdict.id, failureMessage(verdict));
2254
+ const successIds = receipt.verdicts.filter((verdict) => verdict.kind === "ok" || verdict.kind === "already-gone").map((verdict) => verdict.id);
2255
+ const success = {
2256
+ ok: true,
2257
+ action: input.action,
2258
+ ids,
2259
+ receipt,
2260
+ failures,
2261
+ successIds,
2262
+ anySuccess: successIds.length > 0
2263
+ };
2264
+ settleWrite(success, input.presentation ?? noPresentation(input.action));
2265
+ await input.refresh?.(success);
2266
+ return success;
2267
+ };
2268
+ return input.gate === void 0 ? lifecycle() : input.gate.around(lifecycle);
1862
2269
  }
1863
2270
  //#endregion
1864
- //#region src/client/write-commit.ts
1865
- /**
1866
- * 回收站面板之外发生的写入(菜单删除、批量删除、撤销恢复)的**跨槽位收尾顺序**:
1867
- * 主视图收尾 → 失效回收站 → 撤销通告。面板自己的写入,本地 refresh / 缓存的
1868
- * 生命周期仍住在 controller 与 query 里。
1869
- */
1870
- function commitExternalDelete(input) {
1871
- afterDelete(input.sessionIds);
1872
- invalidateTrash();
1873
- input.publishUndo();
2271
+ //#region src/client/write-seats.ts
2272
+ /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。 */
2273
+ async function deleteSessionFromMenu(deps, input, gate) {
2274
+ return (await runWrite({
2275
+ deps,
2276
+ action: "delete",
2277
+ ids: [input.sessionId],
2278
+ single: true,
2279
+ presentation: {
2280
+ invalidate: invalidateTrash,
2281
+ publishUndo: ({ entryIds }) => publishUndoNotice({
2282
+ entryId: entryIds[0],
2283
+ title: input.title
2284
+ })
2285
+ },
2286
+ gate
2287
+ })).failures.get(input.sessionId) ?? null;
1874
2288
  }
1875
- function commitExternalRestore(sessionIds) {
1876
- afterRestore(sessionIds);
1877
- invalidateTrash();
2289
+ /** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
2290
+ * 只收 id:标题是浮条的呈现,不参与这一次写入。 */
2291
+ async function deleteSessionsFromBatch(deps, ids, gate) {
2292
+ return (await runWrite({
2293
+ deps,
2294
+ action: "delete",
2295
+ ids,
2296
+ presentation: {
2297
+ invalidate: invalidateTrash,
2298
+ publishUndo: ({ entryIds, n }) => publishUndoBatchNotice({
2299
+ entryIds,
2300
+ n
2301
+ })
2302
+ },
2303
+ gate
2304
+ })).failures;
1878
2305
  }
1879
- //#endregion
1880
- //#region src/client/undo-toast.tsx
1881
- /**
1882
- * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
1883
- *
1884
- * 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
1885
- * (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
1886
- * 宿主的官方配方同形——会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的
1887
- * `workspace.row-toast` 读它(asar 原文:`inject("shell.overlay", … id: "workspace.row-toast" …
1888
- * inject: rowToastInjected … }, RowActionToast)`,而
1889
- * `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
1890
- * 本文件就是那份 store + 那个常驻组件。
1891
- *
1892
- * 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
1893
- * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1894
- */
1895
- const noticeStore = createStore(null);
1896
- /** 删除成功后发布一条撤销提示(单条)。 */
1897
- function publishUndoNotice(input) {
1898
- noticeStore.publish((seq) => ({
1899
- seq,
1900
- kind: "one",
1901
- entryId: input.entryId,
1902
- title: input.title
1903
- }));
1904
- }
1905
- /** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
1906
- function publishUndoBatchNotice(input) {
1907
- noticeStore.publish((seq) => ({
1908
- seq,
1909
- kind: "batch",
1910
- entryIds: input.entryIds,
1911
- n: input.n
1912
- }));
1913
- }
1914
- /** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
1915
- function clearUndoNotice() {
1916
- noticeStore.set(null);
1917
- }
1918
- /** 供组件订阅。 */
1919
- function useUndoNotice() {
1920
- return noticeStore.use();
2306
+ /** 外部恢复座位(撤销横幅):没有 busy 面,不传闸;主视图重开归 settlement,这里只让清单失效。 */
2307
+ function restoreFromUndo(deps, input) {
2308
+ return runWrite({
2309
+ deps,
2310
+ action: "restore",
2311
+ ids: input.ids,
2312
+ single: input.single,
2313
+ presentation: { invalidate: invalidateTrash }
2314
+ });
1921
2315
  }
1922
- /** 提示停留时长。**6 秒** 定死在本行(比 Toast 默认的 3 秒长)——`docs/ui-spec.md` §2 状态 ③ 只指到这里,不抄这个数。 */
1923
- const UNDO_HOLD_MS = 6e3;
1924
- /** 撤销失败:把原因换到同一条横幅上(seq 递增 ⇒ Toast 重挂载、重新计停留)。 */
1925
- function failNotice(current, message) {
1926
- noticeStore.publish((seq) => ({
1927
- ...current,
1928
- seq,
1929
- failure: message
1930
- }));
2316
+ /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。 */
2317
+ function restoreFromPanel(input) {
2318
+ return runWrite({
2319
+ deps: input.deps,
2320
+ action: "restore",
2321
+ ids: input.ids,
2322
+ single: input.single,
2323
+ presentation: {
2324
+ panelSeat: true,
2325
+ dismissUndo: dismissUndoNotice,
2326
+ clearDetailCache: input.clearDetailCache,
2327
+ sessionIdOfEntry: (entryId) => input.entries?.find((entry) => entry.id === entryId)?.sessionId
2328
+ },
2329
+ gate: input.gate,
2330
+ refresh: input.refreshAfterWrite
2331
+ });
1931
2332
  }
1932
- /** 撤销:单条 `restore(entryId)`、批量**一次** `applyOutcomes('restore', entryIds)`;失败就把原因换到同一条横幅上,不静默。 */
1933
- async function undo(deps, current) {
1934
- if (current.kind === "one" && current.entryId === "") return;
1935
- if (current.kind === "one") {
1936
- const outcome = await callRemote(() => applyOne(deps, "restore", current.entryId));
1937
- if (!outcome.ok) {
1938
- failNotice(current, outcome.failure.message);
1939
- return;
1940
- }
1941
- commitExternalRestore([outcome.value.value.sessionId]);
1942
- } else {
1943
- const outcome = await callRemote(() => deps.applyOutcomes("restore", current.entryIds));
1944
- if (!outcome.ok) {
1945
- failNotice(current, outcome.failure.message);
1946
- return;
1947
- }
1948
- const receipt = readReceipt("restore", current.entryIds, outcome.value);
1949
- commitExternalRestore(receipt.verdicts.flatMap((verdict) => verdict.kind === "ok" && verdict.value !== null ? [verdict.value.sessionId] : []));
1950
- const failure = receipt.verdicts.find(isFailureVerdict);
1951
- if (failure !== void 0) {
1952
- failNotice(current, failureMessage(failure));
1953
- return;
1954
- }
1955
- }
1956
- clearUndoNotice();
2333
+ /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。 */
2334
+ function purgeFromPanel(input) {
2335
+ return runWrite({
2336
+ deps: input.deps,
2337
+ action: "purge",
2338
+ ids: input.ids,
2339
+ presentation: {
2340
+ dismissUndo: dismissUndoNotice,
2341
+ clearDetailCache: input.clearDetailCache
2342
+ },
2343
+ gate: input.gate,
2344
+ refresh: input.refreshAfterWrite
2345
+ });
1957
2346
  }
1958
- /**
1959
- * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
1960
- * pill all belong here",见 asar 里 `shell.overlay` 的声明):没有待撤销的事时什么都不渲染。
1961
- */
1962
- function UndoToast({ deps, t = zhTranslate }) {
1963
- const current = useUndoNotice();
1964
- if (current === null) return null;
1965
- const { seq, failure } = current;
1966
- if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
1967
- text: failure,
1968
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 }),
1969
- holdMs: UNDO_HOLD_MS,
1970
- onDone: clearUndoNotice
1971
- }, seq);
1972
- const canUndo = current.kind === "one" ? current.entryId !== "" : current.entryIds.length > 0;
1973
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
1974
- tone: "success",
1975
- text: current.kind === "one" ? t("undo.moved", { title: current.title === "" ? t("undo.session") : current.title }) : t("undo.movedBatch", { n: current.n }),
1976
- holdMs: UNDO_HOLD_MS,
1977
- actions: canUndo ? [{
1978
- label: t("undo.undo"),
1979
- onClick: () => void undo(deps, current)
1980
- }] : void 0,
1981
- onDone: clearUndoNotice
1982
- }, seq);
2347
+ /** 无主子会话清理座位:不碰回收站失效与撤销,回执判读仍归 `use-trash-query.ts`。 */
2348
+ function cleanOrphansFromPanel(input) {
2349
+ return runWrite({
2350
+ deps: input.deps,
2351
+ action: "orphan-clean",
2352
+ ids: input.ids,
2353
+ gate: input.gate,
2354
+ refresh: input.refreshAfterWrite
2355
+ });
1983
2356
  }
1984
2357
  //#endregion
1985
2358
  //#region src/client/delete-confirm.tsx
@@ -2001,7 +2374,7 @@ window.__ModuleLoader__.load({
2001
2374
  *
2002
2375
  * - **菜单行只发请求**:`DeleteMenuItem` 把 `{ sessionId, title }` 写进本文件的模块级现场;
2003
2376
  * - **确认框由本文件的宿主渲染与提交**:`DeleteConfirmHost` 挂在 `shell.overlay` 上,从现场读请求,
2004
- * 自己持有 busy / failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
2377
+ * 自己持有这趟的闸与 failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
2005
2378
  * 整条链路都在菜单子树之外,菜单关不关都不影响它。
2006
2379
  *
2007
2380
  * 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
@@ -2057,7 +2430,8 @@ window.__ModuleLoader__.load({
2057
2430
  * 用户可以再点一次(不是一次失败就永久锁死)。
2058
2431
  */
2059
2432
  function ConfirmDialog({ request: current, deps, t }) {
2060
- const [busy, setBusy] = (0, react.useState)(false);
2433
+ /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2434
+ const gate = useBusyGate();
2061
2435
  const [failure, setFailure] = (0, react.useState)(null);
2062
2436
  /**
2063
2437
  * 这条会话在盘上带着多少个后代。删除是按组走的(组语义见 `src/trash/groups.ts`),而宿主的客户端本来就把孩子那几行
@@ -2076,22 +2450,15 @@ window.__ModuleLoader__.load({
2076
2450
  };
2077
2451
  }, [deps, current.sessionId]);
2078
2452
  const confirm = async () => {
2079
- setBusy(true);
2080
2453
  setFailure(null);
2081
- const outcome = await callRemote(() => applyOne(deps, "delete", current.sessionId).then((item) => item.value));
2082
- setBusy(false);
2083
- if (!outcome.ok) {
2084
- setFailure(outcome.failure.message);
2454
+ const failure = await deleteSessionFromMenu(deps, {
2455
+ sessionId: current.sessionId,
2456
+ title: current.title
2457
+ }, gate);
2458
+ if (failure !== null) {
2459
+ setFailure(failure);
2085
2460
  return;
2086
2461
  }
2087
- const entry = outcome.value;
2088
- commitExternalDelete({
2089
- sessionIds: [current.sessionId],
2090
- publishUndo: () => publishUndoNotice({
2091
- entryId: entry.id,
2092
- title: current.title
2093
- })
2094
- });
2095
2462
  clearDeleteRequest();
2096
2463
  };
2097
2464
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
@@ -2105,7 +2472,7 @@ window.__ModuleLoader__.load({
2105
2472
  children: t("common.cancel")
2106
2473
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2107
2474
  variant: "primary",
2108
- disabled: busy,
2475
+ disabled: gate.busy,
2109
2476
  onClick: () => void confirm(),
2110
2477
  children: t("dialog.confirm")
2111
2478
  })] }),
@@ -2215,15 +2582,14 @@ window.__ModuleLoader__.load({
2215
2582
  overflow: "auto"
2216
2583
  };
2217
2584
  function BatchConfirmDialog({ request: current, deps, t }) {
2218
- const [busy, setBusy] = (0, react.useState)(false);
2585
+ /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2586
+ const gate = useBusyGate();
2219
2587
  /** 逐条失败原文:`sessionId → 信封的 message`。失败**不关框**(与单删同款)。 */
2220
2588
  const [failures, setFailures] = (0, react.useState)(/* @__PURE__ */ new Map());
2221
2589
  /** 开框那一刻现问的子会话计数;`null` = 有一条读不到 ⇒ 那两半句整句不说(不许拿 0 冒充"没有")。 */
2222
2590
  const [counts, setCounts] = (0, react.useState)(null);
2223
2591
  const ids = current.items.map((item) => item.sessionId);
2224
2592
  const failedIds = [...failures.keys()];
2225
- /** 首轮发全部;重试轮只发失败的那些(成功的已经不在名单里了)。 */
2226
- const pendingIds = failedIds.length > 0 ? failedIds : ids;
2227
2593
  const totalKids = counts === null ? 0 : [...counts.values()].reduce((sum, n) => sum + n, 0);
2228
2594
  (0, react.useEffect)(() => {
2229
2595
  let alive = true;
@@ -2235,43 +2601,8 @@ window.__ModuleLoader__.load({
2235
2601
  };
2236
2602
  }, [deps, current.seq]);
2237
2603
  const run = async () => {
2238
- setBusy(true);
2239
2604
  setFailures(/* @__PURE__ */ new Map());
2240
- const outcome = await callRemote(() => deps.applyOutcomes("delete", pendingIds));
2241
- setBusy(false);
2242
- if (!outcome.ok) {
2243
- setFailures(new Map(pendingIds.map((id) => [id, outcome.failure.message])));
2244
- return;
2245
- }
2246
- const receipt = readReceipt("delete", pendingIds, outcome.value);
2247
- const moved = [];
2248
- const deletedSessionIds = [];
2249
- const failed = /* @__PURE__ */ new Map();
2250
- let okCount = 0;
2251
- for (const verdict of receipt.verdicts) switch (verdict.kind) {
2252
- case "failed":
2253
- case "neutral":
2254
- case "missing":
2255
- failed.set(verdict.id, failureMessage(verdict));
2256
- break;
2257
- case "ok":
2258
- okCount += 1;
2259
- deletedSessionIds.push(verdict.id);
2260
- if (verdict.value !== null) moved.push(verdict.value.id);
2261
- break;
2262
- case "already-gone":
2263
- okCount += 1;
2264
- deletedSessionIds.push(verdict.id);
2265
- }
2266
- commitExternalDelete({
2267
- sessionIds: deletedSessionIds,
2268
- publishUndo: () => {
2269
- if (okCount > 0) publishUndoBatchNotice({
2270
- entryIds: moved,
2271
- n: okCount
2272
- });
2273
- }
2274
- });
2605
+ const failed = await deleteSessionsFromBatch(deps, current.items.map((item) => item.sessionId), gate);
2275
2606
  shrinkPicksToFailures([...failed.keys()]);
2276
2607
  if (failed.size === 0) {
2277
2608
  clearDeleteBatchRequest();
@@ -2291,7 +2622,7 @@ window.__ModuleLoader__.load({
2291
2622
  children: t("common.cancel")
2292
2623
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2293
2624
  variant: "primary",
2294
- disabled: busy,
2625
+ disabled: gate.busy,
2295
2626
  onClick: () => void run(),
2296
2627
  children: failedIds.length > 0 ? t("dialog.batch.retry", { n: failedIds.length }) : t("batch.run", { n: ids.length })
2297
2628
  })] }),
@@ -2339,7 +2670,8 @@ window.__ModuleLoader__.load({
2339
2670
  * all belong here")。
2340
2671
  *
2341
2672
  * 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
2342
- * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与 2026-10-17 `c66f082` 那条 `reloadSeq` 同一个道理)。
2673
+ * 并且带**最新票守卫**:在飞的旧响应不许盖掉新名单的读数(compare-and-set 住 `createLatestWins`,
2674
+ * 防抖本身仍在下面这个 effect 里)。
2343
2675
  * 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
2344
2676
  */
2345
2677
  /** 勾选变化后到发问之间的等待:连勾十条不该发十次 count。 */
@@ -2347,25 +2679,30 @@ window.__ModuleLoader__.load({
2347
2679
  function BatchBar({ deps, t = zhTranslate }) {
2348
2680
  const { selecting, picks } = useSelection();
2349
2681
  useSelectionRowClickGuard();
2682
+ const visibleRows = useVisibleSelectableRows(selecting);
2350
2683
  const [counts, setCounts] = (0, react.useState)(null);
2351
2684
  const [expanded, setExpanded] = (0, react.useState)(false);
2352
- const seqRef = (0, react.useRef)(0);
2685
+ const [latest] = (0, react.useState)(createLatestWins);
2353
2686
  (0, react.useEffect)(() => {
2354
2687
  const ids = picks.map((item) => item.sessionId);
2355
- const seq = seqRef.current += 1;
2688
+ const ticket = latest.issue();
2356
2689
  const timer = setTimeout(() => {
2357
2690
  fetchCounts(deps, ids).then((result) => {
2358
- if (seq === seqRef.current) setCounts(result);
2691
+ if (ticket.current) setCounts(result);
2359
2692
  });
2360
2693
  }, COUNT_DEBOUNCE_MS);
2361
2694
  return () => clearTimeout(timer);
2362
- }, [deps, picks]);
2695
+ }, [
2696
+ deps,
2697
+ latest,
2698
+ picks
2699
+ ]);
2363
2700
  (0, react.useEffect)(() => {
2364
2701
  if (!selecting || picks.length === 0) setExpanded(false);
2365
2702
  }, [selecting, picks.length]);
2366
2703
  if (!selecting) return null;
2367
2704
  const kids = counts === null ? 0 : picks.reduce((sum, item) => sum + (counts.get(item.sessionId) ?? 0), 0);
2368
- const allPicked = allVisibleSelected();
2705
+ const allPicked = areAllVisibleRowsSelected(visibleRows, picks);
2369
2706
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2370
2707
  className: "sd-batch-bar",
2371
2708
  "data-dsh-session-delete": "batch-bar",
@@ -2552,97 +2889,504 @@ window.__ModuleLoader__.load({
2552
2889
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size });
2553
2890
  }
2554
2891
  //#endregion
2555
- //#region node_modules/.pnpm/comma-separated-tokens@2.0.3/node_modules/comma-separated-tokens/index.js
2556
- /**
2557
- * Serialize an array of strings or numbers to comma-separated tokens.
2558
- *
2559
- * @param {Array<string|number>} values
2560
- * List of tokens.
2561
- * @param {Options} [options]
2562
- * Configuration for `stringify` (optional).
2563
- * @returns {string}
2564
- * Comma-separated tokens.
2565
- */
2566
- function stringify$1(values, options) {
2567
- const settings = options || {};
2568
- return (values[values.length - 1] === "" ? [...values, ""] : values).join((settings.padRight ? " " : "") + "," + (settings.padLeft === false ? "" : " ")).trim();
2569
- }
2570
- //#endregion
2571
- //#region node_modules/.pnpm/estree-util-is-identifier-name@3.0.0/node_modules/estree-util-is-identifier-name/lib/index.js
2572
- const nameRe = /^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u;
2573
- const nameReJsx = /^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u;
2574
- /** @type {Options} */
2575
- const emptyOptions$3 = {};
2892
+ //#region src/client/ui/recipe.ts
2576
2893
  /**
2577
- * Checks if the given value is a valid identifier name.
2578
- *
2579
- * @param {string} name
2580
- * Identifier to check.
2581
- * @param {Options | null | undefined} [options]
2582
- * Configuration (optional).
2583
- * @returns {boolean}
2584
- * Whether `name` can be an identifier.
2894
+ * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
2895
+ * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
2896
+ * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
2585
2897
  */
2586
- function name$1(name, options) {
2587
- return ((options || emptyOptions$3).jsx ? nameReJsx : nameRe).test(name);
2588
- }
2898
+ const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
2899
+ /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
2900
+ const ELLIPSIS = {
2901
+ overflow: "hidden",
2902
+ textOverflow: "ellipsis",
2903
+ whiteSpace: "nowrap"
2904
+ };
2589
2905
  //#endregion
2590
- //#region node_modules/.pnpm/hast-util-whitespace@3.0.0/node_modules/hast-util-whitespace/lib/index.js
2906
+ //#region src/client/trash-view.ts
2907
+ /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
2908
+ const CARD = {
2909
+ display: "flex",
2910
+ alignItems: "center",
2911
+ gap: 14,
2912
+ minWidth: 0,
2913
+ padding: "9px 14px",
2914
+ background: "var(--dsw-alias-bg-layer-2)",
2915
+ border: "0.5px solid var(--dsw-alias-border-l4)",
2916
+ borderRadius: "var(--dsw-radius-lg)"
2917
+ };
2918
+ /** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
2919
+ const CARD_SELECTED = {
2920
+ ...CARD,
2921
+ background: "var(--dsw-alias-button-ghost-active-fill)",
2922
+ border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
2923
+ };
2591
2924
  /**
2592
- * @typedef {import('hast').Nodes} Nodes
2925
+ * **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
2926
+ * 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
2593
2927
  */
2594
- const re = /[ \t\n\f\r]/g;
2928
+ const BULK_DANGER = {
2929
+ "--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
2930
+ "--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
2931
+ };
2595
2932
  /**
2596
- * Check if the given value is *inter-element whitespace*.
2597
- *
2598
- * @param {Nodes | string} thing
2599
- * Thing to check (`Node` or `string`).
2600
- * @returns {boolean}
2601
- * Whether the `value` is inter-element whitespace (`boolean`): consisting of
2602
- * zero or more of space, tab (`\t`), line feed (`\n`), carriage return
2603
- * (`\r`), or form feed (`\f`); if a node is passed it must be a `Text` node,
2604
- * whose `value` field is checked.
2933
+ * 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
2934
+ * 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
2935
+ * 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
2936
+ * 字母表只有一份:还原走 `shared/wire.ts`。
2605
2937
  */
2606
- function whitespace(thing) {
2607
- return typeof thing === "object" ? thing.type === "text" ? empty$1(thing.value) : false : empty$1(thing);
2938
+ function projectLabel(projectDir, t) {
2939
+ const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
2940
+ return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
2608
2941
  }
2609
- /**
2610
- * @param {string} value
2611
- * @returns {boolean}
2612
- */
2613
- function empty$1(value) {
2614
- return value.replace(re, "") === "";
2942
+ /** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
2943
+ function groupByProject(entries, t) {
2944
+ const byProject = /* @__PURE__ */ new Map();
2945
+ for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
2946
+ const list = byProject.get(entry.projectDir);
2947
+ if (list === void 0) byProject.set(entry.projectDir, [entry]);
2948
+ else list.push(entry);
2949
+ }
2950
+ return [...byProject].map(([projectDir, list]) => ({
2951
+ projectDir,
2952
+ label: projectLabel(projectDir, t),
2953
+ latest: list[0].deletedAt,
2954
+ entries: list
2955
+ })).sort((a, b) => b.latest - a.latest);
2615
2956
  }
2616
2957
  //#endregion
2617
- //#region node_modules/.pnpm/property-information@7.2.0/node_modules/property-information/lib/util/schema.js
2618
- /**
2619
- * @import {Schema as SchemaType, Space} from 'property-information'
2620
- */
2621
- /** @type {SchemaType} */
2622
- var Schema = class {
2623
- /**
2624
- * @param {SchemaType['property']} property
2625
- * Property.
2626
- * @param {SchemaType['normal']} normal
2627
- * Normal.
2628
- * @param {Space | undefined} [space]
2629
- * Space.
2630
- * @returns
2631
- * Schema.
2632
- */
2633
- constructor(property, normal, space) {
2634
- this.normal = normal;
2635
- this.property = property;
2636
- if (space) this.space = space;
2637
- }
2958
+ //#region src/client/trash-confirm.tsx
2959
+ /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
2960
+ const CARD_LIST = {
2961
+ display: "flex",
2962
+ flexDirection: "column",
2963
+ gap: 6,
2964
+ maxHeight: 280,
2965
+ overflow: "auto",
2966
+ marginBlockEnd: 10
2638
2967
  };
2639
- Schema.prototype.normal = {};
2640
- Schema.prototype.property = {};
2641
- Schema.prototype.space = void 0;
2642
- //#endregion
2643
- //#region node_modules/.pnpm/property-information@7.2.0/node_modules/property-information/lib/util/merge.js
2644
2968
  /**
2645
- * @import {Info, Space} from 'property-information'
2969
+ * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
2970
+ * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
2971
+ * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
2972
+ * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
2973
+ */
2974
+ function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
2975
+ if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
2976
+ open: true,
2977
+ onClose: () => {
2978
+ if (!busy) onCancel();
2979
+ },
2980
+ title: t("trash.confirm.restore", { count: pending.ids.length }),
2981
+ closeLabel: t("common.close"),
2982
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2983
+ variant: "outline",
2984
+ disabled: busy,
2985
+ onClick: onCancel,
2986
+ children: t("common.cancel")
2987
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2988
+ variant: "primary",
2989
+ disabled: busy,
2990
+ onClick: () => onConfirmRestore(pending.ids),
2991
+ children: t("trash.confirm.restoreNow")
2992
+ })] }),
2993
+ children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2994
+ style: CARD_LIST,
2995
+ children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2996
+ style: {
2997
+ ...CARD,
2998
+ flexDirection: "column",
2999
+ alignItems: "stretch",
3000
+ gap: 2,
3001
+ padding: "9px 12px"
3002
+ },
3003
+ children: [
3004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3005
+ style: {
3006
+ ...ELLIPSIS,
3007
+ fontSize: 13.5,
3008
+ lineHeight: "19px"
3009
+ },
3010
+ children: entry.title === "" ? t("common.untitled") : entry.title
3011
+ }),
3012
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3013
+ style: {
3014
+ ...ELLIPSIS,
3015
+ fontSize: 12,
3016
+ lineHeight: "16px",
3017
+ color: "var(--dsw-alias-label-tertiary)"
3018
+ },
3019
+ children: projectLabel(entry.projectDir, t)
3020
+ }),
3021
+ entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3022
+ style: {
3023
+ fontSize: 12,
3024
+ lineHeight: "16px",
3025
+ color: "var(--dsw-alias-label-tertiary)"
3026
+ },
3027
+ children: t("trash.row.workspaceGone")
3028
+ }) : null
3029
+ ]
3030
+ }, entry.id))
3031
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
3032
+ });
3033
+ const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
3034
+ const strong = t("trash.confirm.body.strong");
3035
+ const body = splitOn(t("trash.confirm.body", { strong }), strong);
3036
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3037
+ open: true,
3038
+ onClose: () => {
3039
+ if (!busy) onCancel();
3040
+ },
3041
+ title,
3042
+ closeLabel: t("common.close"),
3043
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3044
+ variant: "outline",
3045
+ disabled: busy,
3046
+ onClick: onCancel,
3047
+ children: t("common.cancel")
3048
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3049
+ variant: "primary",
3050
+ style: BULK_DANGER,
3051
+ disabled: busy,
3052
+ onClick: onConfirmPurge,
3053
+ children: t("trash.confirm.purge")
3054
+ })] }),
3055
+ children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3056
+ style: {
3057
+ ...CARD,
3058
+ flexDirection: "column",
3059
+ alignItems: "stretch",
3060
+ gap: 2,
3061
+ padding: "10px 12px",
3062
+ marginBlockEnd: 10
3063
+ },
3064
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3065
+ style: {
3066
+ ...ELLIPSIS,
3067
+ fontSize: 13.5,
3068
+ lineHeight: "19px"
3069
+ },
3070
+ children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
3071
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3072
+ style: {
3073
+ ...ELLIPSIS,
3074
+ fontSize: 12,
3075
+ lineHeight: "16px",
3076
+ color: "var(--dsw-alias-label-tertiary)"
3077
+ },
3078
+ children: t("trash.confirm.one.meta", {
3079
+ project: projectLabel(pending.entry.projectDir, t),
3080
+ at: absoluteText(pending.entry.deletedAt),
3081
+ size: fileSizeText(pending.entry.releaseBytes)
3082
+ })
3083
+ })]
3084
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
3085
+ body[0],
3086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
3087
+ style: { color: "var(--dsw-alias-state-error-primary)" },
3088
+ children: body[1]
3089
+ }),
3090
+ body[2]
3091
+ ] })]
3092
+ });
3093
+ }
3094
+ /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
3095
+ function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
3096
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3097
+ open: true,
3098
+ onClose: () => {
3099
+ if (!busy) onCancel();
3100
+ },
3101
+ title: t("trash.orphans.confirm", { count: report.totalSessions }),
3102
+ closeLabel: t("common.close"),
3103
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3104
+ variant: "outline",
3105
+ disabled: busy,
3106
+ onClick: onCancel,
3107
+ children: t("common.cancel")
3108
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3109
+ variant: "primary",
3110
+ style: BULK_DANGER,
3111
+ disabled: busy,
3112
+ onClick: onConfirm,
3113
+ children: t("trash.orphans.clean")
3114
+ })] }),
3115
+ children: [
3116
+ report.groups.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3117
+ style: CARD_LIST,
3118
+ children: report.groups.map((group) => {
3119
+ const text = t("trash.orphans.group", {
3120
+ project: projectLabel(group.projectDir, t),
3121
+ count: group.sessions,
3122
+ size: fileSizeText(group.bytes)
3123
+ });
3124
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3125
+ title: text,
3126
+ style: {
3127
+ ...CARD,
3128
+ padding: "9px 12px",
3129
+ fontSize: 13,
3130
+ lineHeight: "19px"
3131
+ },
3132
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3133
+ style: {
3134
+ ...ELLIPSIS,
3135
+ minWidth: 0,
3136
+ flex: "1 1 auto"
3137
+ },
3138
+ children: text
3139
+ })
3140
+ }, group.projectDir);
3141
+ })
3142
+ }),
3143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.orphans.body") }),
3144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3145
+ style: {
3146
+ fontSize: 12,
3147
+ lineHeight: "17px",
3148
+ color: "var(--dsw-alias-label-tertiary)"
3149
+ },
3150
+ children: t("trash.orphans.cacheNote")
3151
+ })
3152
+ ]
3153
+ });
3154
+ }
3155
+ //#endregion
3156
+ //#region src/client/orphan-maintenance.tsx
3157
+ function OrphanMaintenance({ query, busy, t }) {
3158
+ const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3159
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3160
+ orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3161
+ "data-dsh-session-delete": "orphan-banner",
3162
+ style: {
3163
+ ...CARD,
3164
+ alignItems: "flex-start",
3165
+ gap: 12,
3166
+ marginBottom: 10
3167
+ },
3168
+ children: [
3169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3170
+ style: {
3171
+ display: "inline-flex",
3172
+ flex: "none",
3173
+ paddingTop: 2,
3174
+ color: "var(--dsw-alias-state-warn-primary)"
3175
+ },
3176
+ "aria-hidden": "true",
3177
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
3178
+ }),
3179
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3180
+ style: {
3181
+ display: "flex",
3182
+ flexDirection: "column",
3183
+ gap: 2,
3184
+ flex: "1 1 auto",
3185
+ minWidth: 0
3186
+ },
3187
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3188
+ style: {
3189
+ fontSize: 13,
3190
+ lineHeight: "19px"
3191
+ },
3192
+ children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3193
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3194
+ style: {
3195
+ fontSize: 12,
3196
+ lineHeight: "16px",
3197
+ color: "var(--dsw-alias-label-tertiary)"
3198
+ },
3199
+ children: t("trash.orphans.meta", {
3200
+ size: fileSizeText(orphanReport.totalBytes),
3201
+ parents: orphanReport.missingParents
3202
+ })
3203
+ })]
3204
+ }),
3205
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3206
+ variant: "outline",
3207
+ size: "sm",
3208
+ style: { flex: "none" },
3209
+ disabled: busy,
3210
+ onClick: () => {
3211
+ if (busy) return;
3212
+ query.openOrphanPreview();
3213
+ },
3214
+ children: t("trash.orphans.open")
3215
+ })
3216
+ ]
3217
+ }) : null,
3218
+ orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3219
+ role: "status",
3220
+ style: {
3221
+ ...CARD,
3222
+ alignItems: "flex-start",
3223
+ gap: 10,
3224
+ marginBottom: 10,
3225
+ padding: "10px 12px"
3226
+ },
3227
+ children: [
3228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3229
+ style: {
3230
+ display: "inline-flex",
3231
+ flex: "none",
3232
+ color: "var(--dsw-alias-state-error-primary)"
3233
+ },
3234
+ "aria-hidden": "true",
3235
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3236
+ }),
3237
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3238
+ style: {
3239
+ flex: "1 1 auto",
3240
+ minWidth: 0,
3241
+ wordBreak: "break-word",
3242
+ fontSize: 12,
3243
+ lineHeight: "17px",
3244
+ color: "var(--dsw-alias-state-error-primary)"
3245
+ },
3246
+ children: orphanFailure
3247
+ }),
3248
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3249
+ variant: "outline",
3250
+ size: "sm",
3251
+ style: { flex: "none" },
3252
+ disabled: busy,
3253
+ onClick: () => void query.refreshAll(),
3254
+ children: t("common.retry")
3255
+ })
3256
+ ]
3257
+ }) : null,
3258
+ orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3259
+ "data-dsh-session-delete": "orphan-notice",
3260
+ role: "status",
3261
+ style: {
3262
+ ...CARD,
3263
+ alignItems: "flex-start",
3264
+ gap: 10,
3265
+ marginBottom: 10,
3266
+ padding: "10px 12px"
3267
+ },
3268
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3269
+ style: {
3270
+ display: "inline-flex",
3271
+ flex: "none",
3272
+ paddingTop: 2,
3273
+ color: "var(--dsw-alias-label-tertiary)"
3274
+ },
3275
+ "aria-hidden": "true",
3276
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3278
+ style: {
3279
+ flex: "1 1 auto",
3280
+ minWidth: 0,
3281
+ wordBreak: "break-word",
3282
+ fontSize: 12,
3283
+ lineHeight: "17px",
3284
+ color: "var(--dsw-alias-label-secondary)"
3285
+ },
3286
+ children: orphanNotice
3287
+ })]
3288
+ }) : null,
3289
+ orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3290
+ report: orphanReport,
3291
+ busy,
3292
+ t,
3293
+ onCancel: () => query.setOrphanConfirm(false),
3294
+ onConfirm: () => void query.cleanOrphans()
3295
+ }) : null
3296
+ ] });
3297
+ }
3298
+ //#endregion
3299
+ //#region node_modules/.pnpm/comma-separated-tokens@2.0.3/node_modules/comma-separated-tokens/index.js
3300
+ /**
3301
+ * Serialize an array of strings or numbers to comma-separated tokens.
3302
+ *
3303
+ * @param {Array<string|number>} values
3304
+ * List of tokens.
3305
+ * @param {Options} [options]
3306
+ * Configuration for `stringify` (optional).
3307
+ * @returns {string}
3308
+ * Comma-separated tokens.
3309
+ */
3310
+ function stringify$1(values, options) {
3311
+ const settings = options || {};
3312
+ return (values[values.length - 1] === "" ? [...values, ""] : values).join((settings.padRight ? " " : "") + "," + (settings.padLeft === false ? "" : " ")).trim();
3313
+ }
3314
+ //#endregion
3315
+ //#region node_modules/.pnpm/estree-util-is-identifier-name@3.0.0/node_modules/estree-util-is-identifier-name/lib/index.js
3316
+ const nameRe = /^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u;
3317
+ const nameReJsx = /^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u;
3318
+ /** @type {Options} */
3319
+ const emptyOptions$3 = {};
3320
+ /**
3321
+ * Checks if the given value is a valid identifier name.
3322
+ *
3323
+ * @param {string} name
3324
+ * Identifier to check.
3325
+ * @param {Options | null | undefined} [options]
3326
+ * Configuration (optional).
3327
+ * @returns {boolean}
3328
+ * Whether `name` can be an identifier.
3329
+ */
3330
+ function name$1(name, options) {
3331
+ return ((options || emptyOptions$3).jsx ? nameReJsx : nameRe).test(name);
3332
+ }
3333
+ //#endregion
3334
+ //#region node_modules/.pnpm/hast-util-whitespace@3.0.0/node_modules/hast-util-whitespace/lib/index.js
3335
+ /**
3336
+ * @typedef {import('hast').Nodes} Nodes
3337
+ */
3338
+ const re = /[ \t\n\f\r]/g;
3339
+ /**
3340
+ * Check if the given value is *inter-element whitespace*.
3341
+ *
3342
+ * @param {Nodes | string} thing
3343
+ * Thing to check (`Node` or `string`).
3344
+ * @returns {boolean}
3345
+ * Whether the `value` is inter-element whitespace (`boolean`): consisting of
3346
+ * zero or more of space, tab (`\t`), line feed (`\n`), carriage return
3347
+ * (`\r`), or form feed (`\f`); if a node is passed it must be a `Text` node,
3348
+ * whose `value` field is checked.
3349
+ */
3350
+ function whitespace(thing) {
3351
+ return typeof thing === "object" ? thing.type === "text" ? empty$1(thing.value) : false : empty$1(thing);
3352
+ }
3353
+ /**
3354
+ * @param {string} value
3355
+ * @returns {boolean}
3356
+ */
3357
+ function empty$1(value) {
3358
+ return value.replace(re, "") === "";
3359
+ }
3360
+ //#endregion
3361
+ //#region node_modules/.pnpm/property-information@7.2.0/node_modules/property-information/lib/util/schema.js
3362
+ /**
3363
+ * @import {Schema as SchemaType, Space} from 'property-information'
3364
+ */
3365
+ /** @type {SchemaType} */
3366
+ var Schema = class {
3367
+ /**
3368
+ * @param {SchemaType['property']} property
3369
+ * Property.
3370
+ * @param {SchemaType['normal']} normal
3371
+ * Normal.
3372
+ * @param {Space | undefined} [space]
3373
+ * Space.
3374
+ * @returns
3375
+ * Schema.
3376
+ */
3377
+ constructor(property, normal, space) {
3378
+ this.normal = normal;
3379
+ this.property = property;
3380
+ if (space) this.space = space;
3381
+ }
3382
+ };
3383
+ Schema.prototype.normal = {};
3384
+ Schema.prototype.property = {};
3385
+ Schema.prototype.space = void 0;
3386
+ //#endregion
3387
+ //#region node_modules/.pnpm/property-information@7.2.0/node_modules/property-information/lib/util/merge.js
3388
+ /**
3389
+ * @import {Info, Space} from 'property-information'
2646
3390
  */
2647
3391
  /**
2648
3392
  * @param {ReadonlyArray<Schema>} definitions
@@ -24507,21 +25251,29 @@ window.__ModuleLoader__.load({
24507
25251
  });
24508
25252
  });
24509
25253
  //#endregion
24510
- //#region src/client/ui/recipe.ts
25254
+ //#region src/client/trash-detail.tsx
24511
25255
  /**
24512
- * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
24513
- * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
24514
- * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
25256
+ * 回收站的**详情块**(docs/ui-spec.md 的四态)。
25257
+ *
25258
+ * 它**不自己取数**:选中、缓存、失败与重读全在面板控制器(`use-trash-panel-controller.ts`)手里,
25259
+ * 这里只把一个状态画出来——
25260
+ * 所以它能被单独测穿(喂四种状态,断四张形状)。
25261
+ *
25262
+ * 三个不能让步的点:
25263
+ * ① **动作不住在这里**(2026-10-15 真机反馈后定,与 2026-10-01 那版同形):「恢复」「彻底删除…」
25264
+ * 在清单行右侧。搬进详情那一阵子,左栏为了只放标题被压到 316px,读起来就是"左侧太窄"——
25265
+ * 动作回行内之后左栏需要的宽度也回来了(420px,理由写在面板常量处)。
25266
+ * ② **标题优先用清册那一份**:保证「行里叫 A、点开也叫 A」,清册标题是空串才回落到日志里的
25267
+ * `session/title`。
25268
+ * ③ **注入条数与截断条数必须上屏**:正文比日志里的 `user/message` 少一大截是**过滤的结果**,
25269
+ * 页面上要说清少掉的是什么、多少条,不能让它读起来像"这条会话就这么点内容"。
25270
+ *
25271
+ * 皮肤**只由 token 决定**,且 token 与消息行的声明都来自宿主自己那两张表(`MessageItem.module.css` /
25272
+ * `AssistantMarkdown.module.css` / `MarkdownText.module.css`,逐字抄,来源与差异记在 `ui/markdown.css`
25273
+ * 文件头)——字号一律走 `--dsh-content-font-*` 那根轴,不再写宿主同类面的字面量 px。
25274
+ * **助手正文走 Markdown,用户提问走原话**(2026-10-15 真机反馈后改;见 `markdown.tsx` 那三行收口):
25275
+ * 助手的 `text` 段本身就是 markdown 源,用 `pre-wrap` 摆出来就是用户说的"对话内容没渲染、是一整块纯文本"。
24515
25276
  */
24516
- const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
24517
- /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
24518
- const ELLIPSIS = {
24519
- overflow: "hidden",
24520
- textOverflow: "ellipsis",
24521
- whiteSpace: "nowrap"
24522
- };
24523
- //#endregion
24524
- //#region src/client/trash-detail.tsx
24525
25277
  const SHELL = {
24526
25278
  display: "flex",
24527
25279
  flexDirection: "column",
@@ -24943,309 +25695,158 @@ window.__ModuleLoader__.load({
24943
25695
  listWidth,
24944
25696
  max,
24945
25697
  resizing,
24946
- handleProps: {
24947
- role: "separator",
24948
- "aria-orientation": "vertical",
24949
- "aria-label": label,
24950
- "aria-valuenow": listWidth,
24951
- "aria-valuemin": min,
24952
- "aria-valuemax": max,
24953
- tabIndex: 0,
24954
- ...resizing ? { "data-dragging": "" } : {},
24955
- onPointerDown,
24956
- onPointerMove,
24957
- onPointerUp: stop,
24958
- onPointerCancel: stop,
24959
- onKeyDown
24960
- }
24961
- };
24962
- }
24963
- //#endregion
24964
- //#region src/client/use-panel-width.ts
24965
- /**
24966
- * 观测**面板自己那一块**的宽度(回收站的断点全靠它,docs/ui-spec.md)。
24967
- *
24968
- * 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
24969
- * 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
24970
- *
24971
- * 量的是**盒子的 border-box 宽**(`getBoundingClientRect().width`),不是 `contentRect.width`:
24972
- * 后者已经扣掉内边距,而"多宽才分两栏"那个阈值说的是整块盒子——拿扣过内边垫的数去比就是**扣两次**,
24973
- * 结果明明够宽却退化成下钻(2026-10-15 真机反馈后定的判据)。
24974
- *
24975
- * 首帧读一次当前宽度,之后跟着 RO 走;读到 0 就是窄档 ⇒ **窄档是默认、宽档要被证明**。
24976
- * RO 缺席(不可能的宿主)时停在窄档:功能一条不少,只是同屏两栏变成下钻。
24977
- */
24978
- function usePanelWidth(ref) {
24979
- const [width, setWidth] = (0, react.useState)(0);
24980
- (0, react.useEffect)(() => {
24981
- const el = ref.current;
24982
- if (el === null) return;
24983
- setWidth(el.getBoundingClientRect().width);
24984
- const observer = new ResizeObserver((entries) => {
24985
- const target = entries[0]?.target;
24986
- if (target !== void 0) setWidth(target.getBoundingClientRect().width);
24987
- });
24988
- observer.observe(el);
24989
- return () => {
24990
- observer.disconnect();
24991
- };
24992
- }, [ref]);
24993
- return width;
24994
- }
24995
- //#endregion
24996
- //#region src/client/trash-view.ts
24997
- /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
24998
- const CARD = {
24999
- display: "flex",
25000
- alignItems: "center",
25001
- gap: 14,
25002
- minWidth: 0,
25003
- padding: "9px 14px",
25004
- background: "var(--dsw-alias-bg-layer-2)",
25005
- border: "0.5px solid var(--dsw-alias-border-l4)",
25006
- borderRadius: "var(--dsw-radius-lg)"
25007
- };
25008
- /** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
25009
- const CARD_SELECTED = {
25010
- ...CARD,
25011
- background: "var(--dsw-alias-button-ghost-active-fill)",
25012
- border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
25013
- };
25014
- /**
25015
- * **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
25016
- * 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
25017
- */
25018
- const BULK_DANGER = {
25019
- "--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
25020
- "--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
25021
- };
25022
- /**
25023
- * 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
25024
- * 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
25025
- * 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
25026
- * 字母表只有一份:还原走 `shared/wire.ts`。
25027
- */
25028
- function projectLabel(projectDir, t) {
25029
- const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
25030
- return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
25031
- }
25032
- /** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
25033
- function groupByProject(entries, t) {
25034
- const byProject = /* @__PURE__ */ new Map();
25035
- for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
25036
- const list = byProject.get(entry.projectDir);
25037
- if (list === void 0) byProject.set(entry.projectDir, [entry]);
25038
- else list.push(entry);
25039
- }
25040
- return [...byProject].map(([projectDir, list]) => ({
25041
- projectDir,
25042
- label: projectLabel(projectDir, t),
25043
- latest: list[0].deletedAt,
25044
- entries: list
25045
- })).sort((a, b) => b.latest - a.latest);
25698
+ handleProps: {
25699
+ role: "separator",
25700
+ "aria-orientation": "vertical",
25701
+ "aria-label": label,
25702
+ "aria-valuenow": listWidth,
25703
+ "aria-valuemin": min,
25704
+ "aria-valuemax": max,
25705
+ tabIndex: 0,
25706
+ ...resizing ? { "data-dragging": "" } : {},
25707
+ onPointerDown,
25708
+ onPointerMove,
25709
+ onPointerUp: stop,
25710
+ onPointerCancel: stop,
25711
+ onKeyDown
25712
+ }
25713
+ };
25046
25714
  }
25047
25715
  //#endregion
25048
- //#region src/client/trash-confirm.tsx
25049
- /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
25050
- const CARD_LIST = {
25051
- display: "flex",
25052
- flexDirection: "column",
25053
- gap: 6,
25054
- maxHeight: 280,
25055
- overflow: "auto",
25056
- marginBlockEnd: 10
25057
- };
25716
+ //#region src/client/use-panel-width.ts
25058
25717
  /**
25059
- * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
25060
- * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
25061
- * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
25062
- * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
25718
+ * 观测**面板自己那一块**的宽度(回收站的断点全靠它,docs/ui-spec.md)。
25719
+ *
25720
+ * 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
25721
+ * 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
25722
+ *
25723
+ * 量的是**盒子的 border-box 宽**(`getBoundingClientRect().width`),不是 `contentRect.width`:
25724
+ * 后者已经扣掉内边距,而"多宽才分两栏"那个阈值说的是整块盒子——拿扣过内边垫的数去比就是**扣两次**,
25725
+ * 结果明明够宽却退化成下钻(2026-10-15 真机反馈后定的判据)。
25726
+ *
25727
+ * 首帧读一次当前宽度,之后跟着 RO 走;读到 0 就是窄档 ⇒ **窄档是默认、宽档要被证明**。
25728
+ * RO 缺席(不可能的宿主)时停在窄档:功能一条不少,只是同屏两栏变成下钻。
25063
25729
  */
25064
- function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
25065
- if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
25066
- open: true,
25067
- onClose: () => {
25068
- if (!busy) onCancel();
25069
- },
25070
- title: t("trash.confirm.restore", { count: pending.ids.length }),
25071
- closeLabel: t("common.close"),
25072
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25073
- variant: "outline",
25074
- disabled: busy,
25075
- onClick: onCancel,
25076
- children: t("common.cancel")
25077
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25078
- variant: "primary",
25079
- disabled: busy,
25080
- onClick: () => onConfirmRestore(pending.ids),
25081
- children: t("trash.confirm.restoreNow")
25082
- })] }),
25083
- children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25084
- style: CARD_LIST,
25085
- children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25086
- style: {
25087
- ...CARD,
25088
- flexDirection: "column",
25089
- alignItems: "stretch",
25090
- gap: 2,
25091
- padding: "9px 12px"
25092
- },
25093
- children: [
25094
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25095
- style: {
25096
- ...ELLIPSIS,
25097
- fontSize: 13.5,
25098
- lineHeight: "19px"
25099
- },
25100
- children: entry.title === "" ? t("common.untitled") : entry.title
25101
- }),
25102
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25103
- style: {
25104
- ...ELLIPSIS,
25105
- fontSize: 12,
25106
- lineHeight: "16px",
25107
- color: "var(--dsw-alias-label-tertiary)"
25108
- },
25109
- children: projectLabel(entry.projectDir, t)
25110
- }),
25111
- entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25112
- style: {
25113
- fontSize: 12,
25114
- lineHeight: "16px",
25115
- color: "var(--dsw-alias-label-tertiary)"
25116
- },
25117
- children: t("trash.row.workspaceGone")
25118
- }) : null
25119
- ]
25120
- }, entry.id))
25121
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
25122
- });
25123
- const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
25124
- const strong = t("trash.confirm.body.strong");
25125
- const body = splitOn(t("trash.confirm.body", { strong }), strong);
25126
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
25127
- open: true,
25128
- onClose: () => {
25129
- if (!busy) onCancel();
25130
- },
25131
- title,
25132
- closeLabel: t("common.close"),
25133
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25134
- variant: "outline",
25135
- disabled: busy,
25136
- onClick: onCancel,
25137
- children: t("common.cancel")
25138
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25139
- variant: "primary",
25140
- style: BULK_DANGER,
25141
- disabled: busy,
25142
- onClick: onConfirmPurge,
25143
- children: t("trash.confirm.purge")
25144
- })] }),
25145
- children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25146
- style: {
25147
- ...CARD,
25148
- flexDirection: "column",
25149
- alignItems: "stretch",
25150
- gap: 2,
25151
- padding: "10px 12px",
25152
- marginBlockEnd: 10
25153
- },
25154
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25155
- style: {
25156
- ...ELLIPSIS,
25157
- fontSize: 13.5,
25158
- lineHeight: "19px"
25159
- },
25160
- children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
25161
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25162
- style: {
25163
- ...ELLIPSIS,
25164
- fontSize: 12,
25165
- lineHeight: "16px",
25166
- color: "var(--dsw-alias-label-tertiary)"
25167
- },
25168
- children: t("trash.confirm.one.meta", {
25169
- project: projectLabel(pending.entry.projectDir, t),
25170
- at: absoluteText(pending.entry.deletedAt),
25171
- size: fileSizeText(pending.entry.releaseBytes)
25172
- })
25173
- })]
25174
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
25175
- body[0],
25176
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
25177
- style: { color: "var(--dsw-alias-state-error-primary)" },
25178
- children: body[1]
25179
- }),
25180
- body[2]
25181
- ] })]
25182
- });
25730
+ function usePanelWidth(ref) {
25731
+ const [width, setWidth] = (0, react.useState)(0);
25732
+ (0, react.useEffect)(() => {
25733
+ const el = ref.current;
25734
+ if (el === null) return;
25735
+ setWidth(el.getBoundingClientRect().width);
25736
+ const observer = new ResizeObserver((entries) => {
25737
+ const target = entries[0]?.target;
25738
+ if (target !== void 0) setWidth(target.getBoundingClientRect().width);
25739
+ });
25740
+ observer.observe(el);
25741
+ return () => {
25742
+ observer.disconnect();
25743
+ };
25744
+ }, [ref]);
25745
+ return width;
25183
25746
  }
25184
- /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
25185
- function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
25186
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
25187
- open: true,
25188
- onClose: () => {
25189
- if (!busy) onCancel();
25190
- },
25191
- title: t("trash.orphans.confirm", { count: report.totalSessions }),
25192
- closeLabel: t("common.close"),
25193
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25194
- variant: "outline",
25195
- disabled: busy,
25196
- onClick: onCancel,
25197
- children: t("common.cancel")
25198
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25199
- variant: "primary",
25200
- style: BULK_DANGER,
25201
- disabled: busy,
25202
- onClick: onConfirm,
25203
- children: t("trash.orphans.clean")
25204
- })] }),
25205
- children: [
25206
- report.groups.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25207
- style: CARD_LIST,
25208
- children: report.groups.map((group) => {
25209
- const text = t("trash.orphans.group", {
25210
- project: projectLabel(group.projectDir, t),
25211
- count: group.sessions,
25212
- size: fileSizeText(group.bytes)
25213
- });
25214
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25215
- title: text,
25216
- style: {
25217
- ...CARD,
25218
- padding: "9px 12px",
25219
- fontSize: 13,
25220
- lineHeight: "19px"
25221
- },
25222
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25223
- style: {
25224
- ...ELLIPSIS,
25225
- minWidth: 0,
25226
- flex: "1 1 auto"
25227
- },
25228
- children: text
25229
- })
25230
- }, group.projectDir);
25231
- })
25232
- }),
25233
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.orphans.body") }),
25234
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
25235
- style: {
25236
- fontSize: 12,
25237
- lineHeight: "17px",
25238
- color: "var(--dsw-alias-label-tertiary)"
25239
- },
25240
- children: t("trash.orphans.cacheNote")
25241
- })
25242
- ]
25243
- });
25747
+ //#endregion
25748
+ //#region src/client/trash-nav.ts
25749
+ /**
25750
+ * 清单的**键盘导航面**:方向键走环、Enter / Space 的双重语义、roving tabindex 的落点,
25751
+ * 以及窄档下钻里按 Esc 退回清单并把焦点交还给那一行。
25752
+ *
25753
+ * 为什么收回一处:这几条判据此前劈在 `trash-panel.tsx`(键盘分支与 `activeDescendantId` /
25754
+ * `tabStopId` 两枚只读)与 `use-trash-panel-controller.ts`(Esc 的两个 effect 与那笔焦点账)之间。
25755
+ * 它们说的其实是同一件事——"屏幕上从上到下的那一串行里,谁是光标、谁拿 tab 落点、Esc 之后焦点
25756
+ * 还给谁"——而那份顺序只在面板手里(`ordered` 只含展开的组)。收进一个 module 之后面板只交输入
25757
+ * 与四个动作,控制器不再知道焦点这回事。
25758
+ *
25759
+ * 它**不查文档、不认选择器**:焦点动作由调用方注入 `focusRow`,DOM 查询仍留在面板——行是在那儿
25760
+ * 渲染出来的,选择器只在那儿有权威。它只读事件的 `target` 来判断这一下该不该归清单:从行内按钮 /
25761
+ * 勾选框起来的 Enter / Space 是人家的激活键,不是清单的下钻或勾选。
25762
+ *
25763
+ * 三条原样搬来的判据(界面现状住 `docs/ui-spec.md` 的「键盘」那条;这里只留「为什么这么定」):
25764
+ * - **方向键走环**:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷,不是撞墙。
25765
+ * - **`aria-activedescendant` 只指向在 DOM 里的行**:选中那行被折叠起来时它就不存在,指过去是
25766
+ * 假引用。tab 落点(清单里唯一的 tab 巢)走同一条可见性规则,但**永远有一行拿着**——否则一次
25767
+ * 没点过时全行都是 `-1`,键盘根本进不来清单。焦点所在的行就是激活目标:选中行被折叠时,
25768
+ * Enter / Space 落在拿 tab 落点的那一条上,而不是一声不响。
25769
+ * - **Esc 只在下钻期间生效**:监听挂 `document`,因为窄档下钻时清单**不在 DOM 里**;退回后把焦点
25770
+ * 交还选中那一行**一次**,交完就清账,后续重渲染不再重复抢焦点。
25771
+ */
25772
+ /** 事件是不是清单自己的:从行(`role="option"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
25773
+ * 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
25774
+ function isListOwnedEvent(event) {
25775
+ const target = event.target;
25776
+ if (target == null || target === event.currentTarget) return true;
25777
+ return typeof target.getAttribute === "function" && target.getAttribute("role") === "option";
25778
+ }
25779
+ function useTrashNav(input) {
25780
+ const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, exitDrill, focusRow } = input;
25781
+ /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
25782
+ const refocusRow = (0, react.useRef)(null);
25783
+ /** 折叠起来的组只是收掉那一列,选中的 id 还留着——所以这条可见性得逐条对账,不能只看非空。 */
25784
+ const activeDescendantId = ordered.some((entry) => entry.id === selectedId) ? selectedId : null;
25785
+ const tabStopId = activeDescendantId ?? ordered[0]?.id ?? null;
25786
+ const onListKeyDown = (0, react.useCallback)((event) => {
25787
+ if (ordered.length === 0 || !isListOwnedEvent(event)) return;
25788
+ const index = ordered.findIndex((entry) => entry.id === selectedId);
25789
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25790
+ event.preventDefault();
25791
+ const step = event.key === "ArrowDown" ? 1 : -1;
25792
+ const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
25793
+ const target = ordered[next];
25794
+ select(target);
25795
+ focusRow(target.id);
25796
+ return;
25797
+ }
25798
+ if (event.key === "Enter" || event.key === " ") {
25799
+ const target = ordered[index >= 0 ? index : 0];
25800
+ if (target === void 0) return;
25801
+ event.preventDefault();
25802
+ if (selectMode) {
25803
+ toggleCheck(target.id);
25804
+ return;
25805
+ }
25806
+ if (drillEnabled) select(target, { drill: true });
25807
+ else select(target);
25808
+ }
25809
+ }, [
25810
+ drillEnabled,
25811
+ focusRow,
25812
+ ordered,
25813
+ select,
25814
+ selectMode,
25815
+ selectedId,
25816
+ toggleCheck
25817
+ ]);
25818
+ (0, react.useEffect)(() => {
25819
+ if (!drilled || !drillEnabled) return;
25820
+ const onKey = (event) => {
25821
+ if (event.key !== "Escape") return;
25822
+ refocusRow.current = selectedId;
25823
+ exitDrill();
25824
+ };
25825
+ document.addEventListener("keydown", onKey);
25826
+ return () => {
25827
+ document.removeEventListener("keydown", onKey);
25828
+ };
25829
+ }, [
25830
+ drillEnabled,
25831
+ drilled,
25832
+ exitDrill,
25833
+ selectedId
25834
+ ]);
25835
+ (0, react.useEffect)(() => {
25836
+ if (drilled || refocusRow.current === null) return;
25837
+ focusRow(refocusRow.current);
25838
+ refocusRow.current = null;
25839
+ }, [drilled, focusRow]);
25840
+ return {
25841
+ activeDescendantId,
25842
+ tabStopId,
25843
+ onListKeyDown
25844
+ };
25244
25845
  }
25245
25846
  //#endregion
25246
25847
  //#region src/client/use-trash-panel-controller.ts
25247
25848
  /**
25248
- * 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻与 Esc 交还焦点、清单换版后的清场、
25849
+ * 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻、清单换版后的清场、
25249
25850
  * 宽档自动选中、"别处动过回收站"的失效重读,以及确认后的写动作(恢复/批量/清空)与失败落点。
25250
25851
  *
25251
25852
  * 面板接入后,这套语义只住这里;面板自己只留布局与行内动作。搬过来的判据一条不改:
@@ -25259,48 +25860,24 @@ window.__ModuleLoader__.load({
25259
25860
  * - **自动选中只认 `selectedId === null`**:同一拍里选中的那条消失时,自动选中看到的是旧选中
25260
25861
  * (非 null)⇒ 不抢;清场落地后的下一拍才接住新清单的第一条。判据是这道 null 门,不是两个
25261
25862
  * effect 的声明顺序(顺序反过来也拦得住)。
25262
- * - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refresh()`,与挂载时同一条路;它不碰
25263
- * 选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
25863
+ * - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refreshAfterWrite()`;挂载那拍才走
25864
+ * `refreshAll()`。它不碰选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
25264
25865
  * - **动作成功就丢缓存**:恢复/批量彻底删除成功的条目、清空成功的全部条目,详情缓存必须在同一趟里
25265
25866
  * 失效——否则这条会话再被删回来时会摆着上一世的正文。失败只按 `actionFailure[id]` 留原文,不碰缓存。
25266
- * - **Esc 只在 drilled 期间挂 document 监听**:记下 `selectedId` 再退出下钻,渲染落地后把焦点交还
25267
- * 那一行**一次**;`back()`(「← 返回清单」)只收下钻、不动焦点。控制器自己不查 DOM——焦点动作
25268
- * 走注入的 `focusRow`。
25867
+ * - **`back()` 只收下钻、不动焦点**:Esc 那条(下钻期间挂 document 监听、退回后把焦点交还那一行
25868
+ * 一次)住 `trash-nav.ts`;控制器不查 DOM、也不认识焦点。
25269
25869
  *
25270
25870
  * **失败原文不翻译**:`detailFailure.message` 与 `actionFailure[id]` 都是 Node 半给的原文
25271
25871
  * (`callRemote` 的 `failure.message`),原样交给详情块与清单行上屏。
25272
25872
  *
25273
- * `busy` 是面板那枚全局闸(写动作在飞)的读数:闸本体也由本 module 导出(`useTrashPanelGate`),
25274
- * `restore` / `confirmApply` 用同一个 `gate.around` 把「动作 + 清单重读」整个包住;闸开着时谁该
25275
- * 禁用由面板决定。`kind:'all'` 的清空**不再套一层** `around`——`query.emptyTrash` 自己已经包了
25276
- * 同一枚闸。
25873
+ * `busy` 是面板那枚全局闸(写动作在飞)的读数,闸本体住 `busy-gate.ts`;`restore` / `confirmApply`
25874
+ * 把闸交给写入座位(`write-seats.ts`),由 `runWrite` 把「动作 + 收尾 + 清单重读」整趟包住——控制器
25875
+ * 不再自己 `around`;闸开着时谁该禁用由面板决定。`kind:'all'` 的清空不是 `/apply` 座位:
25876
+ * `query.emptyTrash` 自己包同一枚闸。
25277
25877
  */
25278
- /**
25279
- * 闸本体(从 `trash-panel.tsx` 的函数体原样收进来):任务落地或抛错都把 `busy` 放回 false。
25280
- * 不做引用计数:面板同一时刻只允许一趟写动作(按钮在 `busy` 期间全被按住),多一层计数只会
25281
- * 把"这一趟什么时候算完"变成第二个判据。
25282
- *
25283
- * 返回值经 `useMemo` 稳定:`gate` 进 `restore` / `confirmApply` 的 `useCallback` 依赖,每次渲染
25284
- * 换个新对象会让那两个回调跟着失效,进而把引用它们的 effect 也带着重跑。
25285
- */
25286
- function useTrashPanelGate() {
25287
- const [busy, setBusy] = (0, react.useState)(false);
25288
- const around = (0, react.useCallback)(async (task) => {
25289
- setBusy(true);
25290
- try {
25291
- return await task();
25292
- } finally {
25293
- setBusy(false);
25294
- }
25295
- }, []);
25296
- return (0, react.useMemo)(() => ({
25297
- busy,
25298
- around
25299
- }), [busy, around]);
25300
- }
25301
25878
  function useTrashPanelController(input) {
25302
- const { deps, gate, query, ordered, canAutoSelect, focusRow } = input;
25303
- const { entries, refresh, emptyTrash } = query;
25879
+ const { deps, gate, query, ordered, canAutoSelect } = input;
25880
+ const { entries, refreshAll, refreshAfterWrite, emptyTrash } = query;
25304
25881
  const [selectedId, setSelectedId] = (0, react.useState)(null);
25305
25882
  const [drilled, setDrilled] = (0, react.useState)(false);
25306
25883
  const [loadingId, setLoadingId] = (0, react.useState)(null);
@@ -25309,12 +25886,21 @@ window.__ModuleLoader__.load({
25309
25886
  const [actionFailure, setActionFailure] = (0, react.useState)({});
25310
25887
  /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
25311
25888
  const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
25312
- /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
25313
- const refocusRow = (0, react.useRef)(null);
25889
+ /** 挂载那一拍做完整刷新(清单 + 残留),之后失效通告只走写后刷新。 */
25890
+ const firstRefresh = (0, react.useRef)(true);
25314
25891
  const revision = useTrashRevision();
25315
25892
  (0, react.useEffect)(() => {
25316
- refresh();
25317
- }, [refresh, revision]);
25893
+ if (firstRefresh.current) {
25894
+ firstRefresh.current = false;
25895
+ refreshAll();
25896
+ return;
25897
+ }
25898
+ refreshAfterWrite();
25899
+ }, [
25900
+ refreshAll,
25901
+ refreshAfterWrite,
25902
+ revision
25903
+ ]);
25318
25904
  /**
25319
25905
  * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
25320
25906
  * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
@@ -25344,6 +25930,9 @@ window.__ModuleLoader__.load({
25344
25930
  setSelectedId(null);
25345
25931
  setDrilled(false);
25346
25932
  }, [entries, selectedId]);
25933
+ (0, react.useEffect)(() => {
25934
+ if (canAutoSelect && drilled) setDrilled(false);
25935
+ }, [canAutoSelect, drilled]);
25347
25936
  (0, react.useEffect)(() => {
25348
25937
  if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
25349
25938
  }, [
@@ -25353,28 +25942,6 @@ window.__ModuleLoader__.load({
25353
25942
  select
25354
25943
  ]);
25355
25944
  /**
25356
- * 下钻里按 Esc 退回清单:监听挂 `document`,因为窄档下钻时清单**不在 DOM 里**——挂在容器上的
25357
- * Esc 是一条永远收不到的死路径。退回后把焦点交还给选中的那一行(行还没进 DOM,记一笔等这一拍
25358
- * 渲染完再交,见下一个 effect)。
25359
- */
25360
- (0, react.useEffect)(() => {
25361
- if (!drilled) return;
25362
- const onKey = (event) => {
25363
- if (event.key !== "Escape") return;
25364
- refocusRow.current = selectedId;
25365
- setDrilled(false);
25366
- };
25367
- document.addEventListener("keydown", onKey);
25368
- return () => {
25369
- document.removeEventListener("keydown", onKey);
25370
- };
25371
- }, [drilled, selectedId]);
25372
- (0, react.useEffect)(() => {
25373
- if (drilled || refocusRow.current === null) return;
25374
- focusRow(refocusRow.current);
25375
- refocusRow.current = null;
25376
- }, [drilled, focusRow]);
25377
- /**
25378
25945
  * 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:折叠组只是把那一列收掉,
25379
25946
  * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25380
25947
  */
@@ -25409,7 +25976,7 @@ window.__ModuleLoader__.load({
25409
25976
  const retry = (0, react.useCallback)(() => {
25410
25977
  if (selected !== null) select(selected);
25411
25978
  }, [selected, select]);
25412
- /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(只有 Esc 会交还焦点)。 */
25979
+ /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
25413
25980
  const back = (0, react.useCallback)(() => {
25414
25981
  setDrilled(false);
25415
25982
  }, []);
@@ -25420,10 +25987,16 @@ window.__ModuleLoader__.load({
25420
25987
  [entryId]: message
25421
25988
  }));
25422
25989
  }, []);
25990
+ /** 成功支(含 already-gone)的条目丢详情缓存。 */
25991
+ const clearDetailCache = (0, react.useCallback)((successIds) => {
25992
+ for (const id of successIds) detailCache.current.delete(id);
25993
+ }, []);
25423
25994
  /**
25424
- * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把手势包进 `around`。
25995
+ * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把整趟交给面板恢复座位。
25425
25996
  * 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
25426
- * `refresh` 留在 `around` 里:否则旧清单还在屏上,同一个动作能被再点一次。
25997
+ * 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
25998
+ * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次。
25999
+ * 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
25427
26000
  */
25428
26001
  const restore = (0, react.useCallback)(async (entry) => {
25429
26002
  setActionFailure((current) => {
@@ -25431,74 +26004,62 @@ window.__ModuleLoader__.load({
25431
26004
  delete next[entry.id];
25432
26005
  return next;
25433
26006
  });
25434
- await gate.around(async () => {
25435
- const outcome = await callRemote(() => applyOne(deps, "restore", entry.id));
25436
- if (!outcome.ok) {
25437
- failRow(entry.id, outcome.failure.message);
25438
- return;
25439
- }
25440
- detailCache.current.delete(entry.id);
25441
- await refresh();
25442
- });
26007
+ const failure = (await restoreFromPanel({
26008
+ deps,
26009
+ ids: [entry.id],
26010
+ single: true,
26011
+ entries,
26012
+ gate,
26013
+ refreshAfterWrite,
26014
+ clearDetailCache
26015
+ })).failures.get(entry.id);
26016
+ if (failure !== void 0) failRow(entry.id, failure);
25443
26017
  }, [
26018
+ clearDetailCache,
25444
26019
  deps,
26020
+ entries,
25445
26021
  failRow,
25446
26022
  gate,
25447
- refresh
26023
+ refreshAfterWrite
25448
26024
  ]);
25449
26025
  /**
25450
- * 一段批量 → 组件状态:成功的丢详情缓存,失败的原文落回它自己那一行。整批一次上屏。
25451
- * **请求了但没回的 id 也是失败**(通道的错),落 missing 原文,不静默当成成功。
25452
- */
25453
- const settleOutcomes = (0, react.useCallback)((action, ids, outcomes) => {
25454
- const receipt = readReceipt(action, ids, outcomes);
25455
- const failures = [];
25456
- for (const verdict of receipt.verdicts) switch (verdict.kind) {
25457
- case "ok":
25458
- case "already-gone":
25459
- detailCache.current.delete(verdict.id);
25460
- break;
25461
- case "failed":
25462
- case "neutral":
25463
- case "missing": failures.push({
25464
- id: verdict.id,
25465
- message: failureMessage(verdict)
25466
- });
25467
- }
25468
- if (failures.length === 0) return;
25469
- setActionFailure((current) => {
25470
- const next = { ...current };
25471
- for (const failure of failures) next[failure.id] = failure.message;
25472
- return next;
25473
- });
25474
- }, []);
25475
- /**
25476
26026
  * 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
25477
- * `ApplyOutcome` 里 ⇒ 不再"每条一次全量重读"。顶层失败(路由自检 / 通道断)是**这一批一条都没落地**
25478
- * ——原文要落到每一个 id 上,别让清单看起来"删成功了"。
26027
+ * `WriteOutcome` 里 ⇒ 不再"每条一次全量重读"。顶层失败(路由自检 / 通道断)只说明没拿到信封,
26028
+ * 不能断言哪几条落地:原文保守地铺到每一个 id(别让清单看起来"删成功了"),清单照样重读。
25479
26029
  */
25480
26030
  const confirmApply = (0, react.useCallback)(async (action, ids) => {
25481
- await gate.around(async () => {
25482
- const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
26031
+ const settle = (outcome) => {
25483
26032
  setPending(null);
25484
- if (!outcome.ok) setActionFailure((current) => {
25485
- const next = { ...current };
25486
- for (const id of ids) next[id] = outcome.failure.message;
25487
- return next;
25488
- });
25489
- else settleOutcomes(action, ids, outcome.value);
25490
- await refresh();
26033
+ return refreshAfterWrite(outcome);
26034
+ };
26035
+ const seat = {
26036
+ deps,
26037
+ ids,
26038
+ gate,
26039
+ refreshAfterWrite: settle,
26040
+ clearDetailCache
26041
+ };
26042
+ const outcome = action === "purge" ? await purgeFromPanel(seat) : await restoreFromPanel({
26043
+ ...seat,
26044
+ entries
26045
+ });
26046
+ if (outcome.failures.size === 0) return;
26047
+ setActionFailure((current) => {
26048
+ const next = { ...current };
26049
+ for (const [id, message] of outcome.failures) next[id] = message;
26050
+ return next;
25491
26051
  });
25492
26052
  }, [
26053
+ clearDetailCache,
25493
26054
  deps,
26055
+ entries,
25494
26056
  gate,
25495
- refresh,
25496
- settleOutcomes
26057
+ refreshAfterWrite
25497
26058
  ]);
25498
26059
  /**
25499
26060
  * 确认框上的「确认」按 `pending` 的形状分派:三条按条目标都委托 `confirmApply`(一段 `/apply`);
25500
- * 清空是**聚合回执**、形状不同,走 `query.emptyTrash`——它自己已经包了同一枚闸与重读,这里
25501
- * **不再套第二层 `around`**(双套会让外层 busy 提前落下)。
26061
+ * 清空是**聚合回执**、形状不同,走 `query.emptyTrash`——`emptyTrash` 自己包闸;这条不是 `/apply`
26062
+ * 座位,别在外面再包一层(双套会让外层 busy 提前落下)。
25502
26063
  */
25503
26064
  const confirmPurge = (0, react.useCallback)(async () => {
25504
26065
  const target = pending;
@@ -25537,7 +26098,7 @@ window.__ModuleLoader__.load({
25537
26098
  * 清册读数与无主子会话的**取数编排**。
25538
26099
  *
25539
26100
  * 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
25540
- * ① **到达序号**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
26101
+ * ① **最新票**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
25541
26102
  * 后到的旧响应不许盖掉刚发生的动作带来的新状态;
25542
26103
  * ② **先清单后扫描**:`GET /orphans` 判"父没了"吃的是清册,而清册要由 `/list` 那次对账才与
25543
26104
  * `session-trash/` 的目录持平——反过来接,刚被手工搬进回收站的父会被报成失踪父;
@@ -25545,67 +26106,133 @@ window.__ModuleLoader__.load({
25545
26106
  *
25546
26107
  * 清理(`/apply` 的 `orphan-clean`)也住这里:它与扫描共用同一份报告与同一组提示,"读"与"写"分家
25547
26108
  * 会让 `busy` 与重读顺序那条判据劈成两半。`busy` 本身不归它管——那是面板的一枚全局闸,通过
25548
- * `around` 传进来(`around` 里跑的就是"这一趟还算忙")。
26109
+ * `gate` 传进来(闸里跑的就是"这一趟还算忙")。
25549
26110
  */
25550
- function useTrashQuery({ deps, t, around }) {
26111
+ function useTrashQuery({ deps, t, gate }) {
25551
26112
  const [entries, setEntries] = (0, react.useState)(null);
25552
26113
  const [listFailure, setListFailure] = (0, react.useState)(null);
25553
26114
  const [orphanReport, setOrphanReport] = (0, react.useState)(null);
25554
26115
  const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
25555
26116
  const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
25556
26117
  const [orphanConfirm, setOrphanConfirm] = (0, react.useState)(false);
25557
- const reloadSeq = (0, react.useRef)(0);
25558
- const reload = (0, react.useCallback)(async () => {
25559
- const seq = ++reloadSeq.current;
26118
+ /** 报告的同步只读镜像:`refreshAfterWrite` 需要区分「未知」与「已有读数」,又不把 identity 绑在 state 上。 */
26119
+ const orphanReportRef = (0, react.useRef)(null);
26120
+ const setReport = (0, react.useCallback)((report) => {
26121
+ orphanReportRef.current = report;
26122
+ setOrphanReport(report);
26123
+ }, []);
26124
+ /** 清单的同步只读镜像:`refreshAfterWrite` 要在发下一次读**之前**按"当前清单"判要不要强制重扫,
26125
+ * 又不能让回调跟着渲染期 state 换代。 */
26126
+ const entriesRef = (0, react.useRef)(null);
26127
+ const [reloadLatest] = (0, react.useState)(createLatestWins);
26128
+ const reloadRows = (0, react.useCallback)(async () => {
26129
+ const ticket = reloadLatest.issue();
25560
26130
  const outcome = await callRemote(() => deps.list());
25561
- if (seq !== reloadSeq.current) return false;
26131
+ if (!ticket.current) return "stale";
25562
26132
  if (!outcome.ok) {
25563
26133
  setListFailure(outcome.failure.message);
26134
+ entriesRef.current = null;
25564
26135
  setEntries(null);
25565
- return false;
26136
+ return "failed";
25566
26137
  }
25567
26138
  setListFailure(null);
26139
+ entriesRef.current = outcome.value;
25568
26140
  setEntries(outcome.value);
25569
- return true;
25570
- }, [deps]);
25571
- const orphanScanSeq = (0, react.useRef)(0);
25572
- const scanOrphans = (0, react.useCallback)(async () => {
25573
- const seq = ++orphanScanSeq.current;
26141
+ return "ok";
26142
+ }, [deps, reloadLatest]);
26143
+ const [orphanLatest] = (0, react.useState)(createLatestWins);
26144
+ /** 强制重扫标记:先记再读清单;这次读输给后发读时,由赢家消费,强制不丢。 */
26145
+ const pendingForcedRescan = (0, react.useRef)(false);
26146
+ /** 清单这一拍读失败:清掉报告与提示、作废在飞的旧扫描(晚到的读数不能把报告写回来),
26147
+ * 并丢掉挂起的强制重扫(报告已回 null,下一次成功刷新本来就会重扫)。 */
26148
+ const clearOrphanState = (0, react.useCallback)(() => {
26149
+ orphanLatest.invalidate();
26150
+ pendingForcedRescan.current = false;
26151
+ setReport(null);
26152
+ setOrphanFailure(null);
26153
+ setOrphanNotice(null);
26154
+ setOrphanConfirm(false);
26155
+ }, [orphanLatest, setReport]);
26156
+ const rescanOrphans = (0, react.useCallback)(async () => {
26157
+ const ticket = orphanLatest.issue();
25574
26158
  const outcome = await callRemote(() => deps.scanOrphans());
25575
- if (seq !== orphanScanSeq.current) return;
26159
+ if (!ticket.current) return;
25576
26160
  if (!outcome.ok) {
25577
- setOrphanReport(null);
26161
+ setReport(null);
25578
26162
  setOrphanNotice(null);
25579
26163
  setOrphanConfirm(false);
25580
26164
  setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
25581
26165
  return;
25582
26166
  }
25583
- setOrphanReport(outcome.value);
26167
+ setReport(outcome.value);
25584
26168
  setOrphanFailure(null);
25585
26169
  setOrphanNotice(null);
25586
- }, [deps, t]);
25587
- const refresh = (0, react.useCallback)(async () => {
25588
- if (!await reload()) {
25589
- setOrphanReport(null);
25590
- setOrphanFailure(null);
25591
- setOrphanNotice(null);
25592
- setOrphanConfirm(false);
26170
+ }, [
26171
+ deps,
26172
+ orphanLatest,
26173
+ setReport,
26174
+ t
26175
+ ]);
26176
+ /** 显式全量刷新:清单读失败时连残留报告一起清掉(这一拍不知道盘上长什么样);被顶掉则让位。
26177
+ * 它总要扫残留,所以顺手消费掉之前挂起的强制重扫。 */
26178
+ const refreshAll = (0, react.useCallback)(async () => {
26179
+ const listed = await reloadRows();
26180
+ if (listed === "stale") return;
26181
+ if (listed === "failed") {
26182
+ clearOrphanState();
25593
26183
  return;
25594
26184
  }
25595
- await scanOrphans();
25596
- }, [reload, scanOrphans]);
25597
- const emptyTrash = (0, react.useCallback)(() => around(async () => {
26185
+ pendingForcedRescan.current = false;
26186
+ await rescanOrphans();
26187
+ }, [
26188
+ clearOrphanState,
26189
+ reloadRows,
26190
+ rescanOrphans
26191
+ ]);
26192
+ /**
26193
+ * 写后与失效通告:清单必重读;要不要顺带重扫残留由 `needsOrphanRescan` 从这次的 outcome 派生:
26194
+ * purge 与 orphan-clean 总要扫(撤掉的父、被带走的孩子都可能留下新孤儿,报告为空也只是这一拍为空);
26195
+ * delete 与组根恢复可以省——孩子跟着父走,报告为空时扫了也只会得到空报告。**报告 `null` 不是"空的"**:
26196
+ * 它表示从没扫到(挂载刷新被顶掉 / 扫描失败),这时重扫是唯一能把未知变成已知的路;失效通告没有
26197
+ * outcome,仍按报告未知或非空才扫。
26198
+ * 强制重扫先记标记再读清单:若这次读输给后发的 refresh,赢家会看到标记并补扫,强制不丢。
26199
+ * 清单读失败时清空报告与提示:这一拍不知道盘上长什么样,旧读数不能继续当现状。
26200
+ */
26201
+ const refreshAfterWriteInternal = (0, react.useCallback)(async (forceRescan) => {
26202
+ if (forceRescan) pendingForcedRescan.current = true;
26203
+ const listed = await reloadRows();
26204
+ if (listed === "stale") return;
26205
+ if (listed === "failed") {
26206
+ clearOrphanState();
26207
+ return;
26208
+ }
26209
+ const pending = pendingForcedRescan.current;
26210
+ pendingForcedRescan.current = false;
26211
+ if (pending) {
26212
+ await rescanOrphans();
26213
+ return;
26214
+ }
26215
+ const current = orphanReportRef.current;
26216
+ if (current === null || current.totalSessions > 0) await rescanOrphans();
26217
+ }, [
26218
+ clearOrphanState,
26219
+ reloadRows,
26220
+ rescanOrphans
26221
+ ]);
26222
+ const refreshAfterWrite = (0, react.useCallback)((outcome) => refreshAfterWriteInternal(outcome !== void 0 && needsOrphanRescan(outcome, entriesRef.current)), [refreshAfterWriteInternal]);
26223
+ const emptyTrash = (0, react.useCallback)(() => gate.around(async () => {
25598
26224
  const outcome = await callRemote(() => deps.emptyTrash());
25599
26225
  if (!outcome.ok) {
25600
26226
  setListFailure(outcome.failure.message);
25601
26227
  return false;
25602
26228
  }
25603
- await refresh();
26229
+ clearUndoNotice();
26230
+ await refreshAfterWriteInternal(true);
25604
26231
  return true;
25605
26232
  }), [
25606
- around,
26233
+ gate,
25607
26234
  deps,
25608
- refresh
26235
+ refreshAfterWriteInternal
25609
26236
  ]);
25610
26237
  return {
25611
26238
  entries,
@@ -25620,48 +26247,54 @@ window.__ModuleLoader__.load({
25620
26247
  setOrphanNotice(null);
25621
26248
  setOrphanConfirm(true);
25622
26249
  },
25623
- reload,
25624
- scanOrphans,
25625
- refresh,
25626
- cleanOrphans: (0, react.useCallback)(() => {
26250
+ reloadRows,
26251
+ rescanOrphans,
26252
+ refreshAll,
26253
+ refreshAfterWrite,
26254
+ cleanOrphans: (0, react.useCallback)(async () => {
25627
26255
  if (orphanReport === null || orphanReport.roots.length === 0) {
25628
26256
  setOrphanConfirm(false);
25629
- return Promise.resolve();
26257
+ return;
25630
26258
  }
25631
- return around(async () => {
25632
- setOrphanFailure(null);
25633
- setOrphanNotice(null);
25634
- const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", orphanReport.roots));
26259
+ setOrphanFailure(null);
26260
+ setOrphanNotice(null);
26261
+ const settle = (outcome) => {
25635
26262
  setOrphanConfirm(false);
25636
- await refresh();
25637
- if (!outcome.ok) {
25638
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
25639
- return;
25640
- }
25641
- const failures = readReceipt("orphan-clean", orphanReport.roots, outcome.value).verdicts.filter(isFailureVerdict);
25642
- const gone = failures.filter((verdict) => verdict.kind === "neutral");
25643
- const failed = failures.filter((verdict) => verdict.kind !== "neutral");
25644
- const partialRemoved = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "removed"), 0);
25645
- const partialBytes = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "freedBytes"), 0);
25646
- if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
25647
- if (failed.length > 0) {
25648
- const message = failureMessage(failed[0]);
25649
- const base = t("trash.orphans.cleanFailed", {
25650
- count: failed.length,
25651
- message
25652
- });
25653
- const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
25654
- removed: partialRemoved,
25655
- size: fileSizeText(partialBytes)
25656
- })}` : "";
25657
- setOrphanFailure(`${base}${partial}`);
25658
- }
26263
+ return refreshAfterWrite(outcome);
26264
+ };
26265
+ const outcome = await cleanOrphansFromPanel({
26266
+ deps,
26267
+ ids: orphanReport.roots,
26268
+ gate,
26269
+ refreshAfterWrite: settle
25659
26270
  });
26271
+ if (!outcome.ok) {
26272
+ setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
26273
+ return;
26274
+ }
26275
+ const failures = outcome.receipt.verdicts.filter(isFailureVerdict);
26276
+ const gone = failures.filter((verdict) => verdict.kind === "neutral");
26277
+ const failed = failures.filter((verdict) => verdict.kind !== "neutral");
26278
+ const partialRemoved = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "removed"), 0);
26279
+ const partialBytes = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "freedBytes"), 0);
26280
+ if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
26281
+ if (failed.length > 0) {
26282
+ const message = failureMessage(failed[0]);
26283
+ const base = t("trash.orphans.cleanFailed", {
26284
+ count: failed.length,
26285
+ message
26286
+ });
26287
+ const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
26288
+ removed: partialRemoved,
26289
+ size: fileSizeText(partialBytes)
26290
+ })}` : "";
26291
+ setOrphanFailure(`${base}${partial}`);
26292
+ }
25660
26293
  }, [
25661
- around,
25662
26294
  deps,
26295
+ gate,
25663
26296
  orphanReport,
25664
- refresh,
26297
+ refreshAfterWrite,
25665
26298
  t
25666
26299
  ]),
25667
26300
  emptyTrash
@@ -25672,6 +26305,30 @@ window.__ModuleLoader__.load({
25672
26305
  const value = details?.[key];
25673
26306
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
25674
26307
  }
26308
+ /**
26309
+ * 写后要不要强制重扫残留:只看这次写的动作、它请求的 id 与**发读前**的清单。
26310
+ * - purge / orphan-clean 总要扫:语料缺席时故意留下的盘上后代、被带走父行留下的孩子,报告为空也可能是新孤儿。
26311
+ * - restore 只在清单未知(不知道恢复的是什么)、清单里已经找不到这个 id(这一拍不能断言它不是孩子),
26312
+ * 或恢复的是露面的孩子(父行已摘,回到语料后可能无主)时扫;组根恢复把孩子一起带回,不新增孤儿。
26313
+ * - delete / count 不扫。新动作必须在这里选一条,别静默落到"不扫"。裁决见 ADR-0016。
26314
+ */
26315
+ function needsOrphanRescan(outcome, entries) {
26316
+ switch (outcome.action) {
26317
+ case "purge":
26318
+ case "orphan-clean": return true;
26319
+ case "restore": {
26320
+ if (entries === null) return true;
26321
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
26322
+ return outcome.ids.some((id) => {
26323
+ const entry = byId.get(id);
26324
+ return entry === void 0 || entry.parentEntryId !== void 0;
26325
+ });
26326
+ }
26327
+ case "delete":
26328
+ case "count": return false;
26329
+ default: return outcome.action;
26330
+ }
26331
+ }
25675
26332
  //#endregion
25676
26333
  //#region src/client/use-trash-selection.ts
25677
26334
  /**
@@ -25943,14 +26600,14 @@ window.__ModuleLoader__.load({
25943
26600
  });
25944
26601
  }
25945
26602
  function TrashPanel({ deps, t = zhTranslate }) {
25946
- /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住控制器 module,查询侧的写动作也吃它。 */
25947
- const gate = useTrashPanelGate();
26603
+ /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住 `busy-gate.ts`,查询侧的写动作也吃它。 */
26604
+ const gate = useBusyGate();
25948
26605
  const query = useTrashQuery({
25949
26606
  deps,
25950
26607
  t,
25951
- around: gate.around
26608
+ gate
25952
26609
  });
25953
- const { entries, listFailure, orphanReport, orphanFailure, orphanNotice, orphanConfirm, setOrphanConfirm, refresh } = query;
26610
+ const { entries, listFailure, refreshAll } = query;
25954
26611
  const listRef = (0, react.useRef)(null);
25955
26612
  const rootRef = (0, react.useRef)(null);
25956
26613
  const width = usePanelWidth(rootRef);
@@ -25974,12 +26631,12 @@ window.__ModuleLoader__.load({
25974
26631
  entries,
25975
26632
  groups
25976
26633
  });
25977
- /** 行的 DOM 查询留给面板;控制器只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
26634
+ /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
25978
26635
  const focusRow = (0, react.useCallback)((entryId) => {
25979
26636
  (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
25980
26637
  }, []);
25981
26638
  /**
25982
- * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / Esc 交还焦点 / 写动作与失败落点,整块住在
26639
+ * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / 写动作与失败落点,整块住在
25983
26640
  * `use-trash-panel-controller.ts`。布局常量不搬进控制器:面板只交两样从布局算出来的面——
25984
26641
  * 屏幕上的顺序 `ordered`(只含展开的组)与"这一档要不要自动选中"(`canAutoSelect: split`)。
25985
26642
  */
@@ -25988,36 +26645,23 @@ window.__ModuleLoader__.load({
25988
26645
  gate,
25989
26646
  query,
25990
26647
  ordered,
25991
- canAutoSelect: split,
26648
+ canAutoSelect: split
26649
+ });
26650
+ /**
26651
+ * 键盘导航(↑↓ 走环、Enter/Space、tab 落点、Esc 交还焦点)整块住在 `trash-nav.ts`。
26652
+ * 屏幕顺序与四个动作从这里进去;它不查 DOM——`focusRow` 是面板交出去的那一动作。
26653
+ */
26654
+ const nav = useTrashNav({
26655
+ ordered,
26656
+ selectedId: controller.selectedId,
26657
+ selectMode,
26658
+ drillEnabled: !split,
26659
+ drilled: controller.drilled,
26660
+ select: controller.select,
26661
+ toggleCheck,
26662
+ exitDrill: controller.back,
25992
26663
  focusRow
25993
26664
  });
25994
- const onListKeyDown = (event) => {
25995
- if (ordered.length === 0) return;
25996
- const index = ordered.findIndex((entry) => entry.id === controller.selectedId);
25997
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25998
- event.preventDefault();
25999
- const step = event.key === "ArrowDown" ? 1 : -1;
26000
- const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
26001
- const target = ordered[next];
26002
- controller.select(target);
26003
- focusRow(target.id);
26004
- return;
26005
- }
26006
- if (event.key === "Enter" || event.key === " ") {
26007
- if (index < 0) return;
26008
- event.preventDefault();
26009
- if (selectMode) {
26010
- toggleCheck(ordered[index].id);
26011
- return;
26012
- }
26013
- controller.select(ordered[index], { drill: true });
26014
- }
26015
- };
26016
- /** 清理那批无主子会话:整块住在 `useTrashQuery.cleanOrphans`(扫描的报告、两种失败的分流、先刷新后落提示
26017
- * 都在那一条里);这里只负责把确认框收起来。 */
26018
- const confirmOrphans = async () => {
26019
- await query.cleanOrphans();
26020
- };
26021
26665
  /**
26022
26666
  * 详细信息那块的选中集合:确认框要把"这一批有哪些、要去哪"摆出来,靠的全量条目是从勾选 id 反查
26023
26667
  * (顺序已经是"清单从上往下")——清单里找不到的 id 是别的窗口刚动过,滤掉不等于丢动作
@@ -26034,14 +26678,6 @@ window.__ModuleLoader__.load({
26034
26678
  * 在准备批量恢复时是假话——那类具体警告归各自的确认框,这里只留两件事:回收站在哪、不自动过期。
26035
26679
  */
26036
26680
  const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
26037
- /** `aria-activedescendant` 只能指向**在 DOM 里**的那一行:折叠起来之后它就是不存在,指过去是假引用。 */
26038
- const activeDescendantId = ordered.some((entry) => entry.id === controller.selectedId) ? controller.selectedId : null;
26039
- /**
26040
- * 清单里那个唯一的 tab 落点(roving tabindex)。它必须**永远有一行拿着**:窄档不自动选中,
26041
- * 只按"选中那行拿 0"来给的话,一次没点过时全行都是 `-1`,键盘根本进不来清单。
26042
- * 落点与 `aria-activedescendant` 同一条可见性规则——选中那行被折叠起来时落到第一条可见行。
26043
- */
26044
- const tabStopId = activeDescendantId ?? ordered[0]?.id ?? null;
26045
26681
  /**
26046
26682
  * 清单容器(roving tabindex 那个唯一的 tab 巢)。`flex:'1 1 auto'` 是**空态居中链**的一节:
26047
26683
  * 空态块自己有 `flex:1` + `justifyContent:center`,但要有**有高度的**父链才居得起来——本层是
@@ -26053,10 +26689,10 @@ window.__ModuleLoader__.load({
26053
26689
  "data-dsh-session-delete": "trash-list",
26054
26690
  role: "listbox",
26055
26691
  "aria-label": t("detail.list.label"),
26056
- "aria-activedescendant": activeDescendantId === null ? void 0 : `sd-trash-${activeDescendantId}`,
26692
+ "aria-activedescendant": nav.activeDescendantId === null ? void 0 : `sd-trash-${nav.activeDescendantId}`,
26057
26693
  "aria-multiselectable": selectMode ? true : void 0,
26058
26694
  tabIndex: -1,
26059
- onKeyDown: onListKeyDown,
26695
+ onKeyDown: nav.onListKeyDown,
26060
26696
  style: {
26061
26697
  display: "flex",
26062
26698
  flexDirection: "column",
@@ -26111,7 +26747,7 @@ window.__ModuleLoader__.load({
26111
26747
  variant: "outline",
26112
26748
  size: "sm",
26113
26749
  style: { flex: "none" },
26114
- onClick: () => void refresh(),
26750
+ onClick: () => void refreshAll(),
26115
26751
  children: t("common.retry")
26116
26752
  })
26117
26753
  ]
@@ -26258,7 +26894,7 @@ window.__ModuleLoader__.load({
26258
26894
  id: `sd-trash-${entry.id}`,
26259
26895
  role: "option",
26260
26896
  "aria-selected": selectMode ? isChecked : isSelected,
26261
- tabIndex: entry.id === tabStopId ? 0 : -1,
26897
+ tabIndex: entry.id === nav.tabStopId ? 0 : -1,
26262
26898
  "data-dsh-session-delete": "trash-row",
26263
26899
  "data-entry-id": entry.id,
26264
26900
  style: isSelected ? CARD_SELECTED : CARD,
@@ -26351,7 +26987,10 @@ window.__ModuleLoader__.load({
26351
26987
  variant: "outline",
26352
26988
  size: "sm",
26353
26989
  disabled: controller.busy,
26354
- onClick: () => void controller.restore(entry),
26990
+ onClick: (event) => {
26991
+ event.stopPropagation();
26992
+ controller.restore(entry);
26993
+ },
26355
26994
  children: t("trash.row.restore")
26356
26995
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26357
26996
  variant: "ghost",
@@ -26359,10 +26998,13 @@ window.__ModuleLoader__.load({
26359
26998
  style: QUIET_DANGER,
26360
26999
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
26361
27000
  disabled: controller.busy,
26362
- onClick: () => controller.setPending({
26363
- kind: "entry",
26364
- entry
26365
- }),
27001
+ onClick: (event) => {
27002
+ event.stopPropagation();
27003
+ controller.setPending({
27004
+ kind: "entry",
27005
+ entry
27006
+ });
27007
+ },
26366
27008
  children: t("trash.row.purge")
26367
27009
  })]
26368
27010
  })
@@ -26468,135 +27110,11 @@ window.__ModuleLoader__.load({
26468
27110
  }) : null]
26469
27111
  })
26470
27112
  }),
26471
- orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26472
- "data-dsh-session-delete": "orphan-banner",
26473
- style: {
26474
- ...CARD,
26475
- alignItems: "flex-start",
26476
- gap: 12,
26477
- marginBottom: 10
26478
- },
26479
- children: [
26480
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26481
- style: {
26482
- display: "inline-flex",
26483
- flex: "none",
26484
- paddingTop: 2,
26485
- color: "var(--dsw-alias-state-warn-primary)"
26486
- },
26487
- "aria-hidden": "true",
26488
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
26489
- }),
26490
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26491
- style: {
26492
- display: "flex",
26493
- flexDirection: "column",
26494
- gap: 2,
26495
- flex: "1 1 auto",
26496
- minWidth: 0
26497
- },
26498
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26499
- style: {
26500
- fontSize: 13,
26501
- lineHeight: "19px"
26502
- },
26503
- children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
26504
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26505
- style: {
26506
- fontSize: 12,
26507
- lineHeight: "16px",
26508
- color: "var(--dsw-alias-label-tertiary)"
26509
- },
26510
- children: t("trash.orphans.meta", {
26511
- size: fileSizeText(orphanReport.totalBytes),
26512
- parents: orphanReport.missingParents
26513
- })
26514
- })]
26515
- }),
26516
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26517
- variant: "outline",
26518
- size: "sm",
26519
- style: { flex: "none" },
26520
- disabled: controller.busy,
26521
- onClick: () => {
26522
- if (controller.busy) return;
26523
- query.openOrphanPreview();
26524
- },
26525
- children: t("trash.orphans.open")
26526
- })
26527
- ]
26528
- }) : null,
26529
- orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26530
- role: "status",
26531
- style: {
26532
- ...CARD,
26533
- alignItems: "flex-start",
26534
- gap: 10,
26535
- marginBottom: 10,
26536
- padding: "10px 12px"
26537
- },
26538
- children: [
26539
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26540
- style: {
26541
- display: "inline-flex",
26542
- flex: "none",
26543
- color: "var(--dsw-alias-state-error-primary)"
26544
- },
26545
- "aria-hidden": "true",
26546
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
26547
- }),
26548
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26549
- style: {
26550
- flex: "1 1 auto",
26551
- minWidth: 0,
26552
- wordBreak: "break-word",
26553
- fontSize: 12,
26554
- lineHeight: "17px",
26555
- color: "var(--dsw-alias-state-error-primary)"
26556
- },
26557
- children: orphanFailure
26558
- }),
26559
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26560
- variant: "outline",
26561
- size: "sm",
26562
- style: { flex: "none" },
26563
- disabled: controller.busy,
26564
- onClick: () => void refresh(),
26565
- children: t("common.retry")
26566
- })
26567
- ]
26568
- }) : null,
26569
- orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26570
- "data-dsh-session-delete": "orphan-notice",
26571
- role: "status",
26572
- style: {
26573
- ...CARD,
26574
- alignItems: "flex-start",
26575
- gap: 10,
26576
- marginBottom: 10,
26577
- padding: "10px 12px"
26578
- },
26579
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26580
- style: {
26581
- display: "inline-flex",
26582
- flex: "none",
26583
- paddingTop: 2,
26584
- color: "var(--dsw-alias-label-tertiary)"
26585
- },
26586
- "aria-hidden": "true",
26587
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
26588
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26589
- style: {
26590
- flex: "1 1 auto",
26591
- minWidth: 0,
26592
- wordBreak: "break-word",
26593
- fontSize: 12,
26594
- lineHeight: "17px",
26595
- color: "var(--dsw-alias-label-secondary)"
26596
- },
26597
- children: orphanNotice
26598
- })]
26599
- }) : null,
27113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
27114
+ query,
27115
+ busy: controller.busy,
27116
+ t
27117
+ }),
26600
27118
  toolbar,
26601
27119
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26602
27120
  style: BODY,
@@ -26701,14 +27219,7 @@ window.__ModuleLoader__.load({
26701
27219
  onCancel: () => controller.setPending(null),
26702
27220
  onConfirmPurge: () => void controller.confirmPurge(),
26703
27221
  onConfirmRestore: (ids) => void controller.confirmApply("restore", ids)
26704
- }),
26705
- orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
26706
- report: orphanReport,
26707
- busy: controller.busy,
26708
- t,
26709
- onCancel: () => setOrphanConfirm(false),
26710
- onConfirm: () => void confirmOrphans()
26711
- }) : null
27222
+ })
26712
27223
  ]
26713
27224
  });
26714
27225
  }