@xrn1997/dsh-session-delete 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -124
- package/icon.svg +18 -18
- package/lib/client.js +1749 -962
- package/lib/index.js +1841 -985
- package/lib/types/api/dispatch.d.ts +24 -44
- package/lib/types/api/route-table.d.ts +35 -0
- package/lib/types/api/trash-rows.d.ts +31 -0
- package/lib/types/client/batch-bar.d.ts +6 -6
- package/lib/types/client/batch-confirm.d.ts +5 -3
- package/lib/types/client/batch-menu-item.d.ts +2 -2
- package/lib/types/client/batch-select.d.ts +7 -50
- package/lib/types/client/batch-selection-store.d.ts +30 -0
- package/lib/types/client/copy.d.ts +16 -1
- package/lib/types/client/delete-confirm.d.ts +2 -0
- package/lib/types/client/host-rows.d.ts +35 -0
- package/lib/types/client/main-view.d.ts +37 -0
- package/lib/types/client/receipt.d.ts +43 -0
- package/lib/types/client/remote.d.ts +62 -76
- package/lib/types/client/store.d.ts +17 -0
- package/lib/types/client/trash-confirm.d.ts +39 -0
- package/lib/types/client/trash-panel.d.ts +4 -2
- package/lib/types/client/trash-store.d.ts +2 -0
- package/lib/types/client/trash-view.d.ts +38 -0
- package/lib/types/client/ui/icons.d.ts +3 -3
- package/lib/types/client/undo-toast.d.ts +21 -5
- package/lib/types/client/use-column-resize.d.ts +2 -2
- package/lib/types/client/use-panel-width.d.ts +1 -1
- package/lib/types/client/use-trash-panel-controller.d.ts +55 -0
- package/lib/types/client/use-trash-query.d.ts +32 -0
- package/lib/types/client/use-trash-selection.d.ts +32 -0
- package/lib/types/client/write-commit.d.ts +10 -0
- package/lib/types/deps.d.ts +14 -0
- package/lib/types/index.d.ts +9 -15
- package/lib/types/log/zstd-frames.d.ts +1 -1
- package/lib/types/ports/agent-handles.d.ts +25 -0
- package/lib/types/ports/home.d.ts +19 -0
- package/lib/types/ports/host-port.d.ts +70 -20
- package/lib/types/remote/map-error.d.ts +3 -3
- package/lib/types/shared/wire.d.ts +107 -11
- package/lib/types/trash/detail.d.ts +6 -24
- package/lib/types/trash/entry.d.ts +29 -6
- package/lib/types/trash/groups.d.ts +45 -0
- package/lib/types/trash/manifest.d.ts +2 -1
- package/lib/types/trash/reconcile.d.ts +2 -2
- package/lib/types/trash/storage-manifest.d.ts +12 -0
- package/lib/types/verbs/{apply.d.ts → batch.d.ts} +1 -1
- package/lib/types/verbs/delete-session.d.ts +12 -18
- package/lib/types/verbs/errors.d.ts +17 -6
- package/lib/types/verbs/member-journal.d.ts +27 -0
- package/lib/types/verbs/member-presence.d.ts +35 -0
- package/lib/types/verbs/orphan-subagents.d.ts +58 -0
- package/lib/types/verbs/permanent-removal.d.ts +113 -0
- package/lib/types/verbs/purge-trash.d.ts +19 -3
- package/lib/types/verbs/restore-session.d.ts +15 -13
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/client.js
CHANGED
|
@@ -93,7 +93,10 @@ window.__ModuleLoader__.load({
|
|
|
93
93
|
//#endregion
|
|
94
94
|
//#region src/client/copy.ts
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
96
|
+
* 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
|
|
97
|
+
*
|
|
98
|
+
* **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id、`src/client/remote.ts` 的
|
|
99
|
+
* 幂等空值与取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
|
|
97
100
|
*
|
|
98
101
|
* ## 为什么不是散在各组件里的中文字面量
|
|
99
102
|
*
|
|
@@ -179,6 +182,18 @@ window.__ModuleLoader__.load({
|
|
|
179
182
|
"trash.confirm.restore": "恢复 {count} 个会话?",
|
|
180
183
|
"trash.confirm.restore.body": "这一批会回到它们各自的原项目目录,恢复到会话列表里。",
|
|
181
184
|
"trash.confirm.restoreNow": "恢复",
|
|
185
|
+
"trash.orphans.banner": "回收站之外发现 {count} 个无主子会话",
|
|
186
|
+
"trash.orphans.meta": "共 {size},来自 {parents} 个失踪的父会话",
|
|
187
|
+
"trash.orphans.open": "查看并清理",
|
|
188
|
+
"trash.orphans.confirm": "清理 {count} 个无主子会话?",
|
|
189
|
+
"trash.orphans.body": "这些子会话的父会话已经不在会话列表或回收站里,清理后不可恢复。正在运行的子会话会被拒绝,不会被删除。",
|
|
190
|
+
"trash.orphans.group": "{project} · {count} 个 · {size}",
|
|
191
|
+
"trash.orphans.cacheNote": "本次只清理会话目录,不包含宿主投影缓存和附件。",
|
|
192
|
+
"trash.orphans.clean": "清理",
|
|
193
|
+
"trash.orphans.failure": "残留检查/清理失败:{message}",
|
|
194
|
+
"trash.orphans.cleanFailed": "{count} 组未清理:{message}",
|
|
195
|
+
"trash.orphans.cleanPartial": "已先删掉 {removed} 个会话目录({size})",
|
|
196
|
+
"trash.orphans.cleanGone": "有 {count} 组已不在了(可能已被别处清理,或父会话已恢复)",
|
|
182
197
|
"detail.notSelected": "选中一条会话,看它讲了什么",
|
|
183
198
|
"detail.loading": "正在读这条会话…",
|
|
184
199
|
"detail.readFailed": "这条会话的正文读不出来",
|
|
@@ -282,6 +297,18 @@ window.__ModuleLoader__.load({
|
|
|
282
297
|
"trash.confirm.restore": "Restore these {count} sessions?",
|
|
283
298
|
"trash.confirm.restore.body": "They go back to their original project folders and reappear in the session list.",
|
|
284
299
|
"trash.confirm.restoreNow": "Restore",
|
|
300
|
+
"trash.orphans.banner": "Found {count} orphan subagent sessions outside the trash",
|
|
301
|
+
"trash.orphans.meta": "{size} total, from {parents} missing parent sessions",
|
|
302
|
+
"trash.orphans.open": "Review and clean",
|
|
303
|
+
"trash.orphans.confirm": "Clean {count} orphan subagent sessions?",
|
|
304
|
+
"trash.orphans.body": "Their parent sessions are no longer in the session list or the trash; this cannot be undone. Running sessions are refused and left untouched.",
|
|
305
|
+
"trash.orphans.group": "{project} · {count} sessions · {size}",
|
|
306
|
+
"trash.orphans.cacheNote": "This cleans session directories only; host projection caches and attachments are not included.",
|
|
307
|
+
"trash.orphans.clean": "Clean",
|
|
308
|
+
"trash.orphans.failure": "Orphan check/cleanup failed: {message}",
|
|
309
|
+
"trash.orphans.cleanFailed": "{count} groups were not cleaned: {message}",
|
|
310
|
+
"trash.orphans.cleanPartial": "Removed {removed} session directories first ({size})",
|
|
311
|
+
"trash.orphans.cleanGone": "{count} groups were already gone (cleaned elsewhere or their parent was restored)",
|
|
285
312
|
"detail.notSelected": "Select a session to see what it was about",
|
|
286
313
|
"detail.loading": "Reading this session…",
|
|
287
314
|
"detail.readFailed": "Could not read this session’s log",
|
|
@@ -407,9 +434,261 @@ window.__ModuleLoader__.load({
|
|
|
407
434
|
});
|
|
408
435
|
}
|
|
409
436
|
//#endregion
|
|
437
|
+
//#region src/client/store.ts
|
|
438
|
+
/**
|
|
439
|
+
* 客户端**模块级快照 store** 的唯一实现(2026-10-20)。
|
|
440
|
+
*
|
|
441
|
+
* 为什么要有它:浏览器半有一批"跨卸载的现场"——回收站修订号(`trash-store.ts`)、单删请求
|
|
442
|
+
* (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-toast.tsx`)、
|
|
443
|
+
* 批量选中集(`batch-selection-store.ts`)等。它们都挂在 `shell.overlay` / `main` / 菜单行这些
|
|
444
|
+
* 互不相邻的座位上,模块级是唯一的共同点。
|
|
445
|
+
* 在这之前每个文件各写一遍「`let` 现场 + `Set<() => void>` + `notify` + `useSyncExternalStore`」,
|
|
446
|
+
* 各自的 seq / revision 也在分头维护。现在订阅/通告只住这一处。
|
|
447
|
+
*
|
|
448
|
+
* 语义(四条,别在调用方另算一遍):
|
|
449
|
+
* - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
|
|
450
|
+
* - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
|
|
451
|
+
* (同一段文案连续出现两次也要重新计一次停留),不是版本号。
|
|
452
|
+
* - `subscribe` 回退订函数;`use()` 就是 `useSyncExternalStore(subscribe, get, get)`。
|
|
453
|
+
* - `reset()` 只给测试收场:回最初那份引用、seq 清零,值没变就不通告。
|
|
454
|
+
*/
|
|
455
|
+
function createStore(initial) {
|
|
456
|
+
let value = initial;
|
|
457
|
+
let seq = 0;
|
|
458
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
459
|
+
const notify = () => {
|
|
460
|
+
for (const listener of listeners) listener();
|
|
461
|
+
};
|
|
462
|
+
const get = () => value;
|
|
463
|
+
const subscribe = (listener) => {
|
|
464
|
+
listeners.add(listener);
|
|
465
|
+
return () => {
|
|
466
|
+
listeners.delete(listener);
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
return {
|
|
470
|
+
get,
|
|
471
|
+
set(next) {
|
|
472
|
+
if (Object.is(next, value)) return;
|
|
473
|
+
value = next;
|
|
474
|
+
notify();
|
|
475
|
+
},
|
|
476
|
+
update(next) {
|
|
477
|
+
const candidate = next(value);
|
|
478
|
+
if (Object.is(candidate, value)) return false;
|
|
479
|
+
value = candidate;
|
|
480
|
+
notify();
|
|
481
|
+
return true;
|
|
482
|
+
},
|
|
483
|
+
publish(make) {
|
|
484
|
+
seq += 1;
|
|
485
|
+
value = make(seq);
|
|
486
|
+
notify();
|
|
487
|
+
},
|
|
488
|
+
subscribe,
|
|
489
|
+
use() {
|
|
490
|
+
return (0, react.useSyncExternalStore)(subscribe, get, get);
|
|
491
|
+
},
|
|
492
|
+
reset() {
|
|
493
|
+
seq = 0;
|
|
494
|
+
if (Object.is(initial, value)) return;
|
|
495
|
+
value = initial;
|
|
496
|
+
notify();
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/client/batch-selection-store.ts
|
|
502
|
+
/**
|
|
503
|
+
* 宿主列表批量删除的**选中集现场**(2026-10-20):多选模式 + 选中名单,一份合并快照。
|
|
504
|
+
*
|
|
505
|
+
* 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
|
|
506
|
+
* 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
|
|
507
|
+
* 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
|
|
508
|
+
* `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
|
|
509
|
+
* 的 `createStore` 里,本文件不另写一份。
|
|
510
|
+
*
|
|
511
|
+
* **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
|
|
512
|
+
* (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
|
|
513
|
+
*
|
|
514
|
+
* 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
|
|
515
|
+
* 「全选」追加的条目按 `host-rows.ts` 读到的 DOM 序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
|
|
516
|
+
*/
|
|
517
|
+
const INITIAL = {
|
|
518
|
+
selecting: false,
|
|
519
|
+
picks: []
|
|
520
|
+
};
|
|
521
|
+
const selectionStore = createStore(INITIAL);
|
|
522
|
+
function getSelection() {
|
|
523
|
+
return selectionStore.get();
|
|
524
|
+
}
|
|
525
|
+
function getPicks() {
|
|
526
|
+
return getSelection().picks;
|
|
527
|
+
}
|
|
528
|
+
/** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
|
|
529
|
+
function useSelection() {
|
|
530
|
+
return selectionStore.use();
|
|
531
|
+
}
|
|
532
|
+
/** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
|
|
533
|
+
function enterSelection() {
|
|
534
|
+
selectionStore.update((current) => current.selecting ? current : {
|
|
535
|
+
...current,
|
|
536
|
+
selecting: true
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
540
|
+
function exitSelection() {
|
|
541
|
+
selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
|
|
542
|
+
}
|
|
543
|
+
/** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
|
|
544
|
+
function togglePick(item) {
|
|
545
|
+
selectionStore.update((current) => {
|
|
546
|
+
const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
|
|
547
|
+
return {
|
|
548
|
+
...current,
|
|
549
|
+
picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
|
|
554
|
+
function clearPicks() {
|
|
555
|
+
selectionStore.update((current) => current.picks.length === 0 ? current : {
|
|
556
|
+
...current,
|
|
557
|
+
picks: []
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
|
|
561
|
+
function addPicks(items) {
|
|
562
|
+
selectionStore.update((current) => {
|
|
563
|
+
const chosen = new Set(current.picks.map((item) => item.sessionId));
|
|
564
|
+
const additions = [];
|
|
565
|
+
for (const item of items) {
|
|
566
|
+
if (chosen.has(item.sessionId)) continue;
|
|
567
|
+
chosen.add(item.sessionId);
|
|
568
|
+
additions.push(item);
|
|
569
|
+
}
|
|
570
|
+
return additions.length === 0 ? current : {
|
|
571
|
+
...current,
|
|
572
|
+
picks: [...current.picks, ...additions]
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
|
|
577
|
+
function shrinkPicksToFailures(failedSessionIds) {
|
|
578
|
+
const keep = new Set(failedSessionIds);
|
|
579
|
+
selectionStore.update((current) => ({
|
|
580
|
+
...current,
|
|
581
|
+
picks: current.picks.filter((item) => keep.has(item.sessionId))
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/client/host-rows.ts
|
|
586
|
+
/**
|
|
587
|
+
* 宿主会话行的**DOM 适配**(2026-10-20):可见行解析、全选判定、模式内的整行点击守卫。
|
|
588
|
+
*
|
|
589
|
+
* 从 `batch-select.tsx` 拆出来的理由:选中集本身(模式 + 名单)只住 `batch-selection-store.ts`;
|
|
590
|
+
* 本文件只回答**页面上**的两件事——"现在 DOM 里有哪些可选的会话行"与"在这个行上点一下算不算选中"。
|
|
591
|
+
* 两边的唯一交界是 `data-row-key`(宿主会话行的稳定标记)和行尾兜底登记的那张 `rowInfo` 表。
|
|
592
|
+
*
|
|
593
|
+
* **为什么整行点击要抢在 document 捕获阶段**:宿主的会话行自己带 `onClick = open(sessionId)`,
|
|
594
|
+
* 第三方没有官方的"别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
|
|
595
|
+
*/
|
|
596
|
+
/**
|
|
597
|
+
* 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
|
|
598
|
+
* (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
|
|
599
|
+
* 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
|
|
600
|
+
*/
|
|
601
|
+
const rowInfo = /* @__PURE__ */ new Map();
|
|
602
|
+
/** 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。 */
|
|
603
|
+
function registerHostRowInfo(sessionId, info) {
|
|
604
|
+
rowInfo.set(sessionId, info);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
|
|
608
|
+
* 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
|
|
609
|
+
* 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
|
|
610
|
+
*/
|
|
611
|
+
function readVisibleSelectableRows() {
|
|
612
|
+
const rows = [];
|
|
613
|
+
const seen = /* @__PURE__ */ new Set();
|
|
614
|
+
for (const row of document.querySelectorAll("[data-row-key^=\"session:\"]")) {
|
|
615
|
+
const key = row.getAttribute("data-row-key") ?? "";
|
|
616
|
+
if (!key.startsWith("session:")) continue;
|
|
617
|
+
const sessionId = key.slice(8);
|
|
618
|
+
if (sessionId === "" || seen.has(sessionId)) continue;
|
|
619
|
+
const info = rowInfo.get(sessionId);
|
|
620
|
+
if (info === void 0 || info.live) continue;
|
|
621
|
+
seen.add(sessionId);
|
|
622
|
+
rows.push({
|
|
623
|
+
sessionId,
|
|
624
|
+
title: info.title
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return rows;
|
|
628
|
+
}
|
|
629
|
+
/** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
|
|
630
|
+
function selectAllVisible() {
|
|
631
|
+
addPicks(readVisibleSelectableRows());
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* 同一个切换口该显示「全选」还是「取消全选」:
|
|
635
|
+
* - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
|
|
636
|
+
* - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
|
|
637
|
+
* - 其余(含 0 条)⇒ 全选。
|
|
638
|
+
*/
|
|
639
|
+
function allVisibleSelected() {
|
|
640
|
+
const picks = getSelection().picks;
|
|
641
|
+
if (picks.length === 0) return false;
|
|
642
|
+
const rows = readVisibleSelectableRows();
|
|
643
|
+
if (rows.length === 0) return true;
|
|
644
|
+
const chosen = new Set(picks.map((item) => item.sessionId));
|
|
645
|
+
return rows.every((row) => chosen.has(row.sessionId));
|
|
646
|
+
}
|
|
647
|
+
/** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
|
|
648
|
+
const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
|
|
649
|
+
/**
|
|
650
|
+
* 多选态内的整行点击守卫(document 捕获阶段)。
|
|
651
|
+
*
|
|
652
|
+
* 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
|
|
653
|
+
* 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
|
|
654
|
+
* 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
|
|
655
|
+
* 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
|
|
656
|
+
*/
|
|
657
|
+
function useSelectionRowClickGuard() {
|
|
658
|
+
const selecting = useSelection().selecting;
|
|
659
|
+
(0, react.useEffect)(() => {
|
|
660
|
+
if (!selecting) return;
|
|
661
|
+
const handle = (event) => {
|
|
662
|
+
if (!(event.target instanceof Element)) return;
|
|
663
|
+
const row = event.target.closest("[data-row-key^=\"session:\"]");
|
|
664
|
+
if (row === null) return;
|
|
665
|
+
if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
|
|
666
|
+
event.preventDefault();
|
|
667
|
+
event.stopPropagation();
|
|
668
|
+
if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
|
|
669
|
+
const key = row.getAttribute("data-row-key") ?? "";
|
|
670
|
+
if (!key.startsWith("session:")) return;
|
|
671
|
+
const sessionId = key.slice(8);
|
|
672
|
+
if (sessionId === "") return;
|
|
673
|
+
const info = rowInfo.get(sessionId);
|
|
674
|
+
if (info?.live === true) return;
|
|
675
|
+
togglePick({
|
|
676
|
+
sessionId,
|
|
677
|
+
title: info?.title ?? ""
|
|
678
|
+
});
|
|
679
|
+
};
|
|
680
|
+
document.addEventListener("click", handle, true);
|
|
681
|
+
document.addEventListener("dblclick", handle, true);
|
|
682
|
+
return () => {
|
|
683
|
+
document.removeEventListener("click", handle, true);
|
|
684
|
+
document.removeEventListener("dblclick", handle, true);
|
|
685
|
+
};
|
|
686
|
+
}, [selecting]);
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
410
689
|
//#region src/shared/wire.ts
|
|
411
690
|
/**
|
|
412
|
-
* 跨半 wire 契约:`/dsh-session-delete-api`
|
|
691
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
|
|
413
692
|
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
414
693
|
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
415
694
|
*
|
|
@@ -432,13 +711,15 @@ window.__ModuleLoader__.load({
|
|
|
432
711
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
433
712
|
*/
|
|
434
713
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
435
|
-
/**
|
|
714
|
+
/** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
|
|
436
715
|
* 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
|
|
437
716
|
* - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
|
|
438
717
|
* - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
|
|
439
|
-
* - `POST /detail`:单条详情(用 POST
|
|
440
|
-
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
|
|
441
|
-
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
718
|
+
* - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
|
|
719
|
+
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
720
|
+
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
721
|
+
* - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
|
|
722
|
+
* 第五个动作,这一段只负责"有多少、在哪、多大"。 */
|
|
442
723
|
const ROUTES = {
|
|
443
724
|
list: "/list",
|
|
444
725
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
@@ -448,7 +729,9 @@ window.__ModuleLoader__.load({
|
|
|
448
729
|
/** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
|
|
449
730
|
apply: "/apply",
|
|
450
731
|
/** 清空回收站。 */
|
|
451
|
-
empty: "/empty"
|
|
732
|
+
empty: "/empty",
|
|
733
|
+
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
734
|
+
orphans: "/orphans"
|
|
452
735
|
};
|
|
453
736
|
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
|
|
454
737
|
const PARAMS = {
|
|
@@ -469,6 +752,18 @@ window.__ModuleLoader__.load({
|
|
|
469
752
|
const candidate = value;
|
|
470
753
|
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
471
754
|
}
|
|
755
|
+
const SESSION_DELETE_CODES = {
|
|
756
|
+
live: "sessiondelete/live",
|
|
757
|
+
"not-found": "sessiondelete/not-found",
|
|
758
|
+
"trash-empty": "sessiondelete/trash-empty",
|
|
759
|
+
"project-missing": "sessiondelete/project-missing",
|
|
760
|
+
io: "sessiondelete/io",
|
|
761
|
+
"bad-method": "sessiondelete/bad-method",
|
|
762
|
+
"not-found-route": "sessiondelete/not-found-route",
|
|
763
|
+
"bad-request": "sessiondelete/bad-request"
|
|
764
|
+
};
|
|
765
|
+
/** 页脚/文案里带尾斜杠的那一版(`splitOn` 按它切一刀再把目录名包成 code)。 */
|
|
766
|
+
const TRASH_DIR_LABEL = `session-trash/`;
|
|
472
767
|
/**
|
|
473
768
|
* 宿主 `projectKey` 的 **`~XXXX` 转义还原**。
|
|
474
769
|
*
|
|
@@ -484,13 +779,66 @@ window.__ModuleLoader__.load({
|
|
|
484
779
|
return encoded.replace(/~([0-9A-F]{4})/g, (_whole, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
485
780
|
}
|
|
486
781
|
//#endregion
|
|
782
|
+
//#region src/client/receipt.ts
|
|
783
|
+
function missingOutcomeMessage(action, id) {
|
|
784
|
+
return `批量通道没回这一条的结果:${action} ${id}`;
|
|
785
|
+
}
|
|
786
|
+
function isFailureVerdict(verdict) {
|
|
787
|
+
return verdict.kind === "failed" || verdict.kind === "neutral" || verdict.kind === "missing";
|
|
788
|
+
}
|
|
789
|
+
/** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
|
|
790
|
+
function failureMessage(verdict) {
|
|
791
|
+
return verdict.kind === "failed" || verdict.kind === "neutral" ? verdict.error.message : verdict.message;
|
|
792
|
+
}
|
|
793
|
+
function decode$1(action, outcome) {
|
|
794
|
+
if (outcome.action !== action) return void 0;
|
|
795
|
+
const typed = outcome;
|
|
796
|
+
if (!typed.ok) return action === "orphan-clean" && typed.error.code === SESSION_DELETE_CODES["not-found"] ? {
|
|
797
|
+
kind: "neutral",
|
|
798
|
+
id: typed.id,
|
|
799
|
+
error: typed.error
|
|
800
|
+
} : {
|
|
801
|
+
kind: "failed",
|
|
802
|
+
id: typed.id,
|
|
803
|
+
error: typed.error
|
|
804
|
+
};
|
|
805
|
+
const value = typed.value;
|
|
806
|
+
return value === null ? {
|
|
807
|
+
kind: "already-gone",
|
|
808
|
+
id: typed.id
|
|
809
|
+
} : {
|
|
810
|
+
kind: "ok",
|
|
811
|
+
id: typed.id,
|
|
812
|
+
value
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function readReceipt(action, requestedIds, outcomes) {
|
|
816
|
+
const wanted = [...new Set(requestedIds)];
|
|
817
|
+
const wantedSet = new Set(wanted);
|
|
818
|
+
const byId = /* @__PURE__ */ new Map();
|
|
819
|
+
for (const outcome of outcomes) {
|
|
820
|
+
if (!wantedSet.has(outcome.id) || byId.has(outcome.id)) continue;
|
|
821
|
+
const verdict = decode$1(action, outcome);
|
|
822
|
+
if (verdict !== void 0) byId.set(outcome.id, verdict);
|
|
823
|
+
}
|
|
824
|
+
const verdicts = wanted.map((id) => byId.get(id) ?? {
|
|
825
|
+
kind: "missing",
|
|
826
|
+
id,
|
|
827
|
+
message: missingOutcomeMessage(action, id)
|
|
828
|
+
});
|
|
829
|
+
return {
|
|
830
|
+
verdicts,
|
|
831
|
+
byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
//#endregion
|
|
487
835
|
//#region src/client/remote.ts
|
|
488
836
|
/**
|
|
489
837
|
* 浏览器半的**取数通道**:同源 fetch 宿主 web server 上的 `/dsh-session-delete-api`,
|
|
490
838
|
* 加上信封判别与注入面那一处「展开成裸值」的适配。
|
|
491
839
|
*
|
|
492
840
|
* 通道形状(裁决):真相/账本/判据全在宿主那边,Node 半用宿主**自己的 web server** 开一条
|
|
493
|
-
*
|
|
841
|
+
* 插件专属前缀路由把六个段名(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)搬出来
|
|
494
842
|
* (`src/api/dispatch.ts`),浏览器半同源调用。**不是重造后端**:
|
|
495
843
|
* 路由层只做搬运与信封。为什么不用官方的 `remote.<namespace>`:那条路要**生成的 Typert 远程描述符**
|
|
496
844
|
* 加一次 `$mount`,而盒内装配清单写死在 `dsh-api-remotes` 里、全宿主没有动态发现,第三方只能自挂自,
|
|
@@ -513,11 +861,13 @@ window.__ModuleLoader__.load({
|
|
|
513
861
|
* 所以到了组件面前只剩「裸值 / throw」两支,调用方只写 `if (!outcome.ok)`,正常流里没有 try/catch;
|
|
514
862
|
* 非本信封形状的异常(网络层、TypeError…)也不丢原文(见 `failureOf`)。
|
|
515
863
|
*
|
|
516
|
-
* 面(`list` / `ping` / `detail` / `apply` / `empty`)与 Node
|
|
517
|
-
* `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`
|
|
864
|
+
* 面(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)与 Node 半路由的六个段名一一对应(段名本身的家是
|
|
865
|
+
* `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`orphans` 回一份**纯读**的
|
|
866
|
+
* 残留扫描报告,`detail` 回单条正文,
|
|
518
867
|
* `apply` 回**一组逐条结果**,`empty` 回聚合回执。2026-10-17 之前是六个动作段(一条一段),
|
|
519
868
|
* 批量只能由调用方循环拼——那正是「恢复 100 条很慢」的形状来源,收成一段之后这条通道不再有
|
|
520
|
-
* "第二条执行路径"
|
|
869
|
+
* "第二条执行路径"。**清理无主子会话也不另开一段**:它是 `apply` 的第五个动作,`orphans` 只负责
|
|
870
|
+
* "有多少、在哪、多大"。
|
|
521
871
|
*/
|
|
522
872
|
/** 任意异常 → 失败面。非带标记的失败(网络层、TypeError…)也保留原文,绝不吞;带标记的失败把
|
|
523
873
|
* `details` 第三件一起带出。 */
|
|
@@ -611,6 +961,7 @@ window.__ModuleLoader__.load({
|
|
|
611
961
|
return {
|
|
612
962
|
list: () => requestEnvelope(prefix + ROUTES.list, { method: "GET" }),
|
|
613
963
|
ping: () => requestEnvelope(prefix + ROUTES.ping, { method: "GET" }),
|
|
964
|
+
orphans: () => requestEnvelope(prefix + ROUTES.orphans, { method: "GET" }),
|
|
614
965
|
detail: (entryId) => requestEnvelope(prefix + ROUTES.detail, post({ [PARAMS.entryId]: entryId })),
|
|
615
966
|
apply: (action, ids) => requestEnvelope(prefix + ROUTES.apply, post({
|
|
616
967
|
[PARAMS.action]: action,
|
|
@@ -624,9 +975,9 @@ window.__ModuleLoader__.load({
|
|
|
624
975
|
*
|
|
625
976
|
* 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
|
|
626
977
|
* 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
|
|
627
|
-
*
|
|
978
|
+
* 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
|
|
628
979
|
*
|
|
629
|
-
* 2026-10-17
|
|
980
|
+
* 2026-10-17:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
|
|
630
981
|
* 而"信封到不到"这件事一次零成本的 GET 就答得出来。
|
|
631
982
|
*/
|
|
632
983
|
async function probeChannel(raw) {
|
|
@@ -638,15 +989,36 @@ window.__ModuleLoader__.load({
|
|
|
638
989
|
}
|
|
639
990
|
}
|
|
640
991
|
/**
|
|
641
|
-
*
|
|
992
|
+
* **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
|
|
993
|
+
*
|
|
994
|
+
* 三条判据都在这一处:
|
|
995
|
+
* - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
|
|
996
|
+
* - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
|
|
997
|
+
* - `null` 是批量里"已被同批前一条带走"的幂等值——N=1 永远不会命中它,回 `null` 只能是通道答错了,
|
|
998
|
+
* 所以同样按通道错抛,不冒充成功。
|
|
999
|
+
*
|
|
1000
|
+
* 成/败/幂等/缺失的判读只走 {@link readReceipt}(一个家)。
|
|
1001
|
+
*/
|
|
1002
|
+
async function applyOne(deps, action, id) {
|
|
1003
|
+
const outcomes = await deps.applyOutcomes(action, [id]);
|
|
1004
|
+
const verdict = readReceipt(action, [id], outcomes).byId.get(id);
|
|
1005
|
+
if (verdict === void 0) throw new Error(missingOutcomeMessage(action, id));
|
|
1006
|
+
if (verdict.kind === "failed" || verdict.kind === "neutral") throwRemoteFailure(verdict.error);
|
|
1007
|
+
if (verdict.kind === "missing") throw new Error(verdict.message);
|
|
1008
|
+
if (verdict.kind === "already-gone") throw new Error(`单条动作回了幂等空值:${action} ${id}`);
|
|
1009
|
+
return {
|
|
1010
|
+
id,
|
|
1011
|
+
action,
|
|
1012
|
+
ok: true,
|
|
1013
|
+
value: verdict.value
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
|
|
642
1018
|
*
|
|
643
1019
|
* **为什么必须有这一步**:通道那头的调用 resolve 成 `RemoteResult<T>`(`{ ok: true, value }` /
|
|
644
1020
|
* `{ ok: false, error }`),失败是**返回**而不是 reject。组件面要的是「拿到值 / 抛异常」,
|
|
645
|
-
* 所以在这里展开一次:`ok ? value : throw
|
|
646
|
-
* (以及钉住它们的用例)保持裸值形状不变。
|
|
647
|
-
*
|
|
648
|
-
* **单条支里只有一条落地**:没有它(幽灵 id、或路由整次没办成)就 throw——"批量只回了一条别的"
|
|
649
|
-
* 不是成功,也不是 0。
|
|
1021
|
+
* 所以在这里展开一次:`ok ? value : throw`。
|
|
650
1022
|
*/
|
|
651
1023
|
function buildDeps(raw) {
|
|
652
1024
|
/** 一次 `apply`:顶层信封先展开(那是"这次调用办没办成"),逐条结果原样交给调用方。 */
|
|
@@ -655,22 +1027,12 @@ window.__ModuleLoader__.load({
|
|
|
655
1027
|
if (!result.ok) throwRemoteFailure(result.error);
|
|
656
1028
|
return result.value;
|
|
657
1029
|
};
|
|
658
|
-
/** N=1 的便捷支:**按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上)。
|
|
659
|
-
* 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里)。 */
|
|
660
|
-
const applyOne = async (action, id) => {
|
|
661
|
-
const outcome = (await applyOnce(action, [id])).find((candidate) => candidate.id === id);
|
|
662
|
-
if (outcome === void 0) throw new Error(`批量通道没回这一条的结果:${action} ${id}`);
|
|
663
|
-
if (!outcome.ok) throwRemoteFailure(outcome.error);
|
|
664
|
-
return outcome;
|
|
665
|
-
};
|
|
666
1030
|
return {
|
|
667
1031
|
list: () => raw.list().then((result) => unwrapRemoteResult(result)),
|
|
1032
|
+
scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
|
|
668
1033
|
detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
|
|
669
1034
|
applyOutcomes: applyOnce,
|
|
670
|
-
emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
|
|
671
|
-
delete: async (sessionId) => (await applyOne("delete", sessionId)).value,
|
|
672
|
-
restore: async (entryId) => (await applyOne("restore", entryId)).value,
|
|
673
|
-
countSubagents: async (sessionId) => (await applyOne("count", sessionId)).value
|
|
1035
|
+
emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
|
|
674
1036
|
};
|
|
675
1037
|
}
|
|
676
1038
|
//#endregion
|
|
@@ -810,7 +1172,7 @@ window.__ModuleLoader__.load({
|
|
|
810
1172
|
})]
|
|
811
1173
|
});
|
|
812
1174
|
}
|
|
813
|
-
/** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17
|
|
1175
|
+
/** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 加)。 */
|
|
814
1176
|
function IconCheckOutlineRegular({ size }) {
|
|
815
1177
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFrame, {
|
|
816
1178
|
size,
|
|
@@ -842,10 +1204,10 @@ window.__ModuleLoader__.load({
|
|
|
842
1204
|
}
|
|
843
1205
|
//#endregion
|
|
844
1206
|
//#region src/client/ui/ui.css?dsh-css-text
|
|
845
|
-
var ui_default = "/*\r\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\r\n *\r\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\r\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\r\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\r\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\r\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\r\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\r\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\r\n * 要跟宿主自己的归档/置顶同形)。\r\n * 这是官方给第三方 UI 插件的处方:\r\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\r\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\r\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\r\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\r\n *\r\n * 三条改写规则:\r\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\r\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\r\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\r\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\r\n *\r\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\r\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\r\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\r\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\r\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\r\n *\r\n * **五处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\r\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\r\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\r\n * div,我们这一行是 `role=\"option\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\r\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17 第十三批)——\r\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\r\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日同批)——宿主没有\"浮条\"\r\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\r\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场。\r\n * 整张表里就这五条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n */\r\n\r\n/* ────────────────────────── Button ────────────────────────── */\r\n\r\n.sd-button {\r\n box-sizing: border-box;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 4px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n cursor: pointer;\r\n font-size: 14px;\r\n line-height: 22px;\r\n color: var(--dsw-alias-label-primary);\r\n background: transparent;\r\n padding: 0 14px;\r\n}\r\n\r\n.sd-button:disabled {\r\n cursor: not-allowed;\r\n opacity: 0.4;\r\n}\r\n\r\n.sd-button-md {\r\n height: 36px;\r\n}\r\n\r\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\r\n.sd-button-sm {\r\n height: 28px;\r\n font-size: 12px;\r\n line-height: 18px;\r\n padding: 0 10px;\r\n border-radius: var(--dsw-radius-sm);\r\n}\r\n\r\n.sd-button-primary {\r\n background: var(--dsw-alias-button-primary-fill);\r\n color: var(--dsw-alias-label-primary-foreground);\r\n}\r\n\r\n.sd-button-primary:hover:not(:disabled) {\r\n background: var(--dsw-alias-button-primary-hover);\r\n}\r\n\r\n.sd-button-ghost:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-ghost:active:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-active);\r\n}\r\n\r\n.sd-button-outline {\r\n border: 0.5px solid var(--dsw-alias-border-l3);\r\n background: transparent;\r\n}\r\n\r\n.sd-button-outline:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-icon {\r\n display: inline-flex;\r\n width: 16px;\r\n height: 16px;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* ────────────────────────── Tag ────────────────────────── */\r\n\r\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\r\n.sd-tag {\r\n display: inline-flex;\r\n align-items: center;\r\n border-radius: 999px;\r\n corner-shape: round;\r\n padding: 1px 8px;\r\n font-size: 11px;\r\n line-height: 17px;\r\n font-weight: 500;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\r\n.sd-tag-danger {\r\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n/* ────────────────────────── Modal ────────────────────────── */\r\n\r\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\r\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\r\n.sd-modal-root {\r\n pointer-events: auto;\r\n position: fixed;\r\n inset: 0;\r\n z-index: 1000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\r\n}\r\n\r\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\r\n.sd-modal-mask {\r\n position: absolute;\r\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\r\n backdrop-filter: var(--dsw-mask-blur);\r\n}\r\n\r\n.sd-modal-mask::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n background: var(--dsw-alias-bg-mask-1);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-modal-dialog {\r\n box-sizing: border-box;\r\n position: relative;\r\n z-index: 1;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 20px;\r\n width: min(380px, 100%);\r\n padding: 0 0 24px;\r\n overflow: hidden;\r\n border: 0;\r\n border-radius: var(--dsw-radius-panel);\r\n background: var(--dsw-alias-bg-layer-2);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n@keyframes sd-modal-enter {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-modal-mask::after,\r\n .sd-modal-dialog { animation: none; }\r\n}\r\n\r\n.sd-modal-dialog:focus {\r\n outline: none;\r\n}\r\n\r\n.sd-modal-content {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n}\r\n\r\n/* 头行:标题左、关闭钮右。 */\r\n.sd-modal-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 8px;\r\n padding: 22px 14px 12px 24px;\r\n}\r\n\r\n.sd-modal-title {\r\n margin: 0;\r\n font-size: 16px;\r\n line-height: 24px;\r\n font-weight: 500;\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-modal-close {\r\n flex: none;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 28px;\r\n height: 28px;\r\n border: none;\r\n border-radius: var(--dsw-radius-sm);\r\n background: transparent;\r\n cursor: pointer;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-modal-close:hover {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-modal-body {\r\n display: flex;\r\n flex-direction: column;\r\n min-width: 0;\r\n margin-top: 20px;\r\n padding: 0 24px;\r\n}\r\n\r\n.sd-modal-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: flex-end;\r\n gap: 8px;\r\n padding: 0 24px;\r\n}\r\n\r\n/* ────────────────────────── Tooltip ────────────────────────── */\r\n\r\n.sd-tooltip-bubble {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n position: fixed;\r\n z-index: 100;\r\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\r\n `max-content` 只按文案定宽,再用半个视口收口。 */\r\n width: max-content;\r\n max-width: 50vw;\r\n padding: 3px 7px;\r\n border-radius: var(--dsw-radius-sm);\r\n background: var(--dsw-alias-tooltip-bg);\r\n color: var(--dsw-static-neutral-bluish-00);\r\n font-size: 13px;\r\n line-height: 20px;\r\n white-space: pre-line;\r\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\r\n overflow-wrap: break-word;\r\n pointer-events: none;\r\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\r\n.sd-tooltip-label { min-width: 0; }\r\n\r\n.sd-tooltip-bubble[data-side='right'] {\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='bottom'] {\r\n transform: translateX(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='top'] {\r\n transform: translate(-50%, -100%);\r\n}\r\n\r\n@keyframes sd-tooltip-in {\r\n from { opacity: 0; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-tooltip-bubble {\r\n animation: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── Toast ────────────────────────── */\r\n\r\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\r\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\r\n.sd-toast {\r\n position: fixed;\r\n top: 40px;\r\n left: 50%;\r\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\r\n z-index: 1100;\r\n /* 通告不截点击。 */\r\n pointer-events: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\r\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\r\n width: max-content;\r\n max-width: min(640px, calc(100vw - 48px));\r\n padding: 12px 16px;\r\n border-radius: var(--dsw-radius-lg);\r\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\r\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\r\n background: var(--dsw-alias-toast-bg);\r\n color: var(--dsw-alias-toast-label);\r\n font-size: 14px;\r\n line-height: 22px;\r\n box-shadow: var(--dsw-shadow-lv3);\r\n transform: translateX(-50%);\r\n animation:\r\n sd-toast-in 160ms ease-out,\r\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n}\r\n\r\n.sd-toast-icon {\r\n display: grid;\r\n place-items: center;\r\n flex: none;\r\n color: var(--dsw-alias-state-warn-label);\r\n}\r\n\r\n.sd-toast-icon-success {\r\n color: var(--dsw-alias-state-success-primary);\r\n}\r\n\r\n.sd-toast-text {\r\n min-width: 0;\r\n}\r\n\r\n.sd-toast-action {\r\n display: inline;\r\n padding: 0;\r\n border: 0;\r\n background: none;\r\n color: var(--dsw-static-deepseek-400);\r\n font: inherit;\r\n cursor: pointer;\r\n /* 动作文字与周围文案之间的呼吸位。 */\r\n margin-inline: 3px;\r\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\r\n pointer-events: auto;\r\n}\r\n\r\n.sd-toast-action:hover {\r\n opacity: 0.8;\r\n}\r\n\r\n@keyframes sd-toast-in {\r\n from {\r\n opacity: 0;\r\n transform: translate(-50%, -6px);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translate(-50%, 0);\r\n }\r\n}\r\n\r\n@keyframes sd-toast-fade {\r\n to {\r\n opacity: 0;\r\n visibility: hidden;\r\n }\r\n}\r\n\r\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-toast {\r\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 菜单行 ────────────────────────── */\r\n\r\n.sd-menu-item-wrap {\r\n position: relative;\r\n}\r\n\r\n/* 菜单格:紧凑文字、图标与选中间距。 */\r\n.sd-menu-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n width: 100%;\r\n min-height: 34px;\r\n padding: 6px 8px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n background: transparent;\r\n cursor: pointer;\r\n font-size: 13px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n text-align: left;\r\n}\r\n\r\n.sd-menu-item:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\r\n 浏览器默认的 ring 再叠一层就重了。 */\r\n.sd-menu-item:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n outline: none;\r\n}\r\n\r\n.sd-menu-item:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n.sd-menu-item-icon {\r\n display: inline-flex;\r\n flex: none;\r\n width: 14px;\r\n height: 14px;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--dsw-alias-menu-icon);\r\n}\r\n\r\n.sd-menu-item-icon svg {\r\n width: 14px;\r\n height: 14px;\r\n}\r\n\r\n.sd-menu-item-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\r\n.sd-menu-item-danger {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger .sd-menu-item-icon {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n}\r\n\r\n.sd-menu-item-danger:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n outline: none;\r\n}\r\n\r\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\r\n.sd-menu-separator {\r\n height: 0.5px;\r\n margin: 3px 2px;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n/* ────────────────────────── Checkbox ────────────────────────── */\r\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\r\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\r\n\r\n.sd-checkbox {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-checkbox input {\r\n flex: 0 0 auto;\r\n width: 16px;\r\n height: 16px;\r\n margin: 0;\r\n accent-color: var(--dsw-alias-brand-primary);\r\n cursor: inherit;\r\n}\r\n\r\n.sd-checkbox input:focus-visible {\r\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\r\n outline-offset: 2px;\r\n}\r\n\r\n.sd-checkbox:has(input:disabled) {\r\n cursor: default;\r\n opacity: 0.5;\r\n}\r\n\r\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\r\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\r\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\r\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\r\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\r\n\r\n.sd-disclosure {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n min-width: 0;\r\n}\r\n\r\n.sd-disclosure-row {\r\n position: relative;\r\n overflow: hidden;\r\n display: flex;\r\n align-items: center;\r\n height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n min-width: 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n transition: color 100ms ease;\r\n}\r\n\r\n.sd-disclosure-row[data-expandable] {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-row:hover {\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-disclosure-leading {\r\n position: relative;\r\n flex: none;\r\n width: calc(16px + var(--dsh-content-font-delta, 0px));\r\n height: calc(16px + var(--dsh-content-font-delta, 0px));\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin-right: 6px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n color: inherit;\r\n}\r\n\r\n.sd-disclosure-leading svg:not([data-state]) {\r\n width: calc(14px + var(--dsh-content-font-delta, 0px));\r\n height: calc(14px + var(--dsh-content-font-delta, 0px));\r\n}\r\n\r\nbutton.sd-disclosure-leading {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-title {\r\n flex: none;\r\n font-size: var(--dsh-content-font-size-secondary, 13px);\r\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n color: inherit;\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-disclosure-row {\r\n transition: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\r\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\r\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\r\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\r\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\r\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\r\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\r\n\r\n.sd-width-handle {\r\n position: relative;\r\n z-index: 0;\r\n cursor: col-resize;\r\n align-self: stretch;\r\n flex: 0 0 10px;\r\n width: 10px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n}\r\n\r\n.sd-width-handle::after {\r\n content: \"\";\r\n background: linear-gradient(\r\n to bottom,\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\r\n );\r\n opacity: 0;\r\n pointer-events: none;\r\n border-radius: 2px;\r\n width: 2px;\r\n position: absolute;\r\n top: 0;\r\n bottom: 0;\r\n left: 4px;\r\n}\r\n\r\n.sd-width-handle:hover::after,\r\n.sd-width-handle[data-dragging]::after {\r\n opacity: 1;\r\n}\r\n\r\n.sd-width-handle:focus-visible {\r\n outline: none;\r\n}\r\n\r\n.sd-width-handle:focus-visible::after {\r\n opacity: 1;\r\n}\r\n\r\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\r\n.sd-resizing {\r\n user-select: none;\r\n cursor: col-resize;\r\n}\r\n\r\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"option\"` 的可聚焦 div,而这一代\r\n `--dsw-focus-ring-color` 逐字是 `transparent`(设计稿 2026-10-15 §1.3)——不自己描一条,键盘走\r\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\r\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\r\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\r\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\r\n outline-offset: 1px;\r\n}\r\n\r\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\r\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\r\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\r\n.sd-rowaction-button {\r\n border-radius: var(--dsw-radius-xs);\r\n cursor: pointer;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--dsw-alias-label-tertiary);\r\n background: 0 0;\r\n border: none;\r\n flex: none;\r\n justify-content: center;\r\n align-items: center;\r\n padding: 0;\r\n display: inline-flex;\r\n}\r\n.sd-rowaction-button:hover {\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\r\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\r\n 禁用档走 opacity。 */\r\n.sd-rowaction-button[aria-pressed='true'] {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n.sd-rowaction-button:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n/* 行首 16px 格里的勾选框(第十三批①改版,**只在多选模式内渲染**)。格本身由宿主居中(16×20),\r\n 这里只把包装层压成同样大小;勾选框本体是回收站多选同款原生 input(`.sd-checkbox`,见上)。 */\r\n.sd-batch-pick-leading {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\r\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\r\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\r\n}\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\r\n display: none;\r\n}\r\n\r\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\r\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\r\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\r\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\r\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\r\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\r\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\r\n.sd-batch-bar {\r\n position: fixed;\r\n bottom: 24px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n z-index: 900;\r\n width: max-content;\r\n max-width: calc(100vw - 48px);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n gap: 8px;\r\n padding: 8px 12px;\r\n background: var(--dsw-alias-bg-layer-2);\r\n border: 0.5px solid var(--dsw-alias-border-l4);\r\n border-radius: var(--dsw-radius-lg);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n font-size: 12px;\r\n line-height: 18px;\r\n}\r\n\r\n.sd-batch-bar-main {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.sd-batch-bar-hint {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list-head {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\r\n.sd-batch-bar-list-head span + span {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n max-height: 240px;\r\n overflow: auto;\r\n margin: 0;\r\n padding: 0 0 8px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\r\n list-style: none;\r\n}\r\n\r\n.sd-batch-bar-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n min-width: 0;\r\n}\r\n\r\n.sd-batch-bar-title {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-batch-bar-meta {\r\n flex: none;\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-remove {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: none;\r\n width: 16px;\r\n height: 16px;\r\n padding: 0;\r\n border: none;\r\n border-radius: var(--dsw-radius-xs);\r\n background: 0 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-batch-bar-remove:hover {\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n";
|
|
1207
|
+
var ui_default = "/*\r\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\r\n *\r\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\r\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\r\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\r\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\r\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\r\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\r\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\r\n * 要跟宿主自己的归档/置顶同形)。\r\n * 这是官方给第三方 UI 插件的处方:\r\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\r\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\r\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\r\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\r\n *\r\n * 三条改写规则:\r\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\r\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\r\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\r\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\r\n *\r\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\r\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\r\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\r\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\r\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\r\n *\r\n * **五处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\r\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\r\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\r\n * div,我们这一行是 `role=\"option\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\r\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17)——\r\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\r\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日)——宿主没有\"浮条\"\r\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\r\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场。\r\n * 整张表里就这五条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的 `text-align: start`。宿主 `DisclosureRow` 那份\r\n * 是 `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表的 `text-align:center` 会渗进里面的\r\n * 文字(真机上就是项目名整条居中,2026-10-15 截图)。它写在 `src/client/trash-panel.tsx` 组头那一行的\r\n * inline 样式上——**所以「与官方逐条比对」时本表仍然只数上面那五条**,这条不算进抄本差异。\r\n */\r\n\r\n/* ────────────────────────── Button ────────────────────────── */\r\n\r\n.sd-button {\r\n box-sizing: border-box;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 4px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n cursor: pointer;\r\n font-size: 14px;\r\n line-height: 22px;\r\n color: var(--dsw-alias-label-primary);\r\n background: transparent;\r\n padding: 0 14px;\r\n}\r\n\r\n.sd-button:disabled {\r\n cursor: not-allowed;\r\n opacity: 0.4;\r\n}\r\n\r\n.sd-button-md {\r\n height: 36px;\r\n}\r\n\r\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\r\n.sd-button-sm {\r\n height: 28px;\r\n font-size: 12px;\r\n line-height: 18px;\r\n padding: 0 10px;\r\n border-radius: var(--dsw-radius-sm);\r\n}\r\n\r\n.sd-button-primary {\r\n background: var(--dsw-alias-button-primary-fill);\r\n color: var(--dsw-alias-label-primary-foreground);\r\n}\r\n\r\n.sd-button-primary:hover:not(:disabled) {\r\n background: var(--dsw-alias-button-primary-hover);\r\n}\r\n\r\n.sd-button-ghost:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-ghost:active:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-active);\r\n}\r\n\r\n.sd-button-outline {\r\n border: 0.5px solid var(--dsw-alias-border-l3);\r\n background: transparent;\r\n}\r\n\r\n.sd-button-outline:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-icon {\r\n display: inline-flex;\r\n width: 16px;\r\n height: 16px;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* ────────────────────────── Tag ────────────────────────── */\r\n\r\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\r\n.sd-tag {\r\n display: inline-flex;\r\n align-items: center;\r\n border-radius: 999px;\r\n corner-shape: round;\r\n padding: 1px 8px;\r\n font-size: 11px;\r\n line-height: 17px;\r\n font-weight: 500;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\r\n.sd-tag-danger {\r\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n/* ────────────────────────── Modal ────────────────────────── */\r\n\r\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\r\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\r\n.sd-modal-root {\r\n pointer-events: auto;\r\n position: fixed;\r\n inset: 0;\r\n z-index: 1000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\r\n}\r\n\r\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\r\n.sd-modal-mask {\r\n position: absolute;\r\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\r\n backdrop-filter: var(--dsw-mask-blur);\r\n}\r\n\r\n.sd-modal-mask::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n background: var(--dsw-alias-bg-mask-1);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-modal-dialog {\r\n box-sizing: border-box;\r\n position: relative;\r\n z-index: 1;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 20px;\r\n width: min(380px, 100%);\r\n padding: 0 0 24px;\r\n overflow: hidden;\r\n border: 0;\r\n border-radius: var(--dsw-radius-panel);\r\n background: var(--dsw-alias-bg-layer-2);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n@keyframes sd-modal-enter {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-modal-mask::after,\r\n .sd-modal-dialog { animation: none; }\r\n}\r\n\r\n.sd-modal-dialog:focus {\r\n outline: none;\r\n}\r\n\r\n.sd-modal-content {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n}\r\n\r\n/* 头行:标题左、关闭钮右。 */\r\n.sd-modal-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 8px;\r\n padding: 22px 14px 12px 24px;\r\n}\r\n\r\n.sd-modal-title {\r\n margin: 0;\r\n font-size: 16px;\r\n line-height: 24px;\r\n font-weight: 500;\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-modal-close {\r\n flex: none;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 28px;\r\n height: 28px;\r\n border: none;\r\n border-radius: var(--dsw-radius-sm);\r\n background: transparent;\r\n cursor: pointer;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-modal-close:hover {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-modal-body {\r\n display: flex;\r\n flex-direction: column;\r\n min-width: 0;\r\n margin-top: 20px;\r\n padding: 0 24px;\r\n}\r\n\r\n.sd-modal-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: flex-end;\r\n gap: 8px;\r\n padding: 0 24px;\r\n}\r\n\r\n/* ────────────────────────── Tooltip ────────────────────────── */\r\n\r\n.sd-tooltip-bubble {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n position: fixed;\r\n z-index: 100;\r\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\r\n `max-content` 只按文案定宽,再用半个视口收口。 */\r\n width: max-content;\r\n max-width: 50vw;\r\n padding: 3px 7px;\r\n border-radius: var(--dsw-radius-sm);\r\n background: var(--dsw-alias-tooltip-bg);\r\n color: var(--dsw-static-neutral-bluish-00);\r\n font-size: 13px;\r\n line-height: 20px;\r\n white-space: pre-line;\r\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\r\n overflow-wrap: break-word;\r\n pointer-events: none;\r\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\r\n.sd-tooltip-label { min-width: 0; }\r\n\r\n.sd-tooltip-bubble[data-side='right'] {\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='bottom'] {\r\n transform: translateX(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='top'] {\r\n transform: translate(-50%, -100%);\r\n}\r\n\r\n@keyframes sd-tooltip-in {\r\n from { opacity: 0; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-tooltip-bubble {\r\n animation: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── Toast ────────────────────────── */\r\n\r\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\r\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\r\n.sd-toast {\r\n position: fixed;\r\n top: 40px;\r\n left: 50%;\r\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\r\n z-index: 1100;\r\n /* 通告不截点击。 */\r\n pointer-events: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\r\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\r\n width: max-content;\r\n max-width: min(640px, calc(100vw - 48px));\r\n padding: 12px 16px;\r\n border-radius: var(--dsw-radius-lg);\r\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\r\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\r\n background: var(--dsw-alias-toast-bg);\r\n color: var(--dsw-alias-toast-label);\r\n font-size: 14px;\r\n line-height: 22px;\r\n box-shadow: var(--dsw-shadow-lv3);\r\n transform: translateX(-50%);\r\n animation:\r\n sd-toast-in 160ms ease-out,\r\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n}\r\n\r\n.sd-toast-icon {\r\n display: grid;\r\n place-items: center;\r\n flex: none;\r\n color: var(--dsw-alias-state-warn-label);\r\n}\r\n\r\n.sd-toast-icon-success {\r\n color: var(--dsw-alias-state-success-primary);\r\n}\r\n\r\n.sd-toast-text {\r\n min-width: 0;\r\n}\r\n\r\n.sd-toast-action {\r\n display: inline;\r\n padding: 0;\r\n border: 0;\r\n background: none;\r\n color: var(--dsw-static-deepseek-400);\r\n font: inherit;\r\n cursor: pointer;\r\n /* 动作文字与周围文案之间的呼吸位。 */\r\n margin-inline: 3px;\r\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\r\n pointer-events: auto;\r\n}\r\n\r\n.sd-toast-action:hover {\r\n opacity: 0.8;\r\n}\r\n\r\n@keyframes sd-toast-in {\r\n from {\r\n opacity: 0;\r\n transform: translate(-50%, -6px);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translate(-50%, 0);\r\n }\r\n}\r\n\r\n@keyframes sd-toast-fade {\r\n to {\r\n opacity: 0;\r\n visibility: hidden;\r\n }\r\n}\r\n\r\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-toast {\r\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 菜单行 ────────────────────────── */\r\n\r\n.sd-menu-item-wrap {\r\n position: relative;\r\n}\r\n\r\n/* 菜单格:紧凑文字、图标与选中间距。 */\r\n.sd-menu-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n width: 100%;\r\n min-height: 34px;\r\n padding: 6px 8px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n background: transparent;\r\n cursor: pointer;\r\n font-size: 13px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n text-align: left;\r\n}\r\n\r\n.sd-menu-item:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\r\n 浏览器默认的 ring 再叠一层就重了。 */\r\n.sd-menu-item:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n outline: none;\r\n}\r\n\r\n.sd-menu-item:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n.sd-menu-item-icon {\r\n display: inline-flex;\r\n flex: none;\r\n width: 14px;\r\n height: 14px;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--dsw-alias-menu-icon);\r\n}\r\n\r\n.sd-menu-item-icon svg {\r\n width: 14px;\r\n height: 14px;\r\n}\r\n\r\n.sd-menu-item-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\r\n.sd-menu-item-danger {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger .sd-menu-item-icon {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n}\r\n\r\n.sd-menu-item-danger:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n outline: none;\r\n}\r\n\r\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\r\n.sd-menu-separator {\r\n height: 0.5px;\r\n margin: 3px 2px;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n/* ────────────────────────── Checkbox ────────────────────────── */\r\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\r\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\r\n\r\n.sd-checkbox {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-checkbox input {\r\n flex: 0 0 auto;\r\n width: 16px;\r\n height: 16px;\r\n margin: 0;\r\n accent-color: var(--dsw-alias-brand-primary);\r\n cursor: inherit;\r\n}\r\n\r\n.sd-checkbox input:focus-visible {\r\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\r\n outline-offset: 2px;\r\n}\r\n\r\n.sd-checkbox:has(input:disabled) {\r\n cursor: default;\r\n opacity: 0.5;\r\n}\r\n\r\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\r\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\r\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\r\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\r\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\r\n\r\n.sd-disclosure {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n min-width: 0;\r\n}\r\n\r\n.sd-disclosure-row {\r\n position: relative;\r\n overflow: hidden;\r\n display: flex;\r\n align-items: center;\r\n height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n min-width: 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n transition: color 100ms ease;\r\n}\r\n\r\n.sd-disclosure-row[data-expandable] {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-row:hover {\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-disclosure-leading {\r\n position: relative;\r\n flex: none;\r\n width: calc(16px + var(--dsh-content-font-delta, 0px));\r\n height: calc(16px + var(--dsh-content-font-delta, 0px));\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin-right: 6px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n color: inherit;\r\n}\r\n\r\n.sd-disclosure-leading svg:not([data-state]) {\r\n width: calc(14px + var(--dsh-content-font-delta, 0px));\r\n height: calc(14px + var(--dsh-content-font-delta, 0px));\r\n}\r\n\r\nbutton.sd-disclosure-leading {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-title {\r\n flex: none;\r\n font-size: var(--dsh-content-font-size-secondary, 13px);\r\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n color: inherit;\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-disclosure-row {\r\n transition: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\r\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\r\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\r\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\r\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\r\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\r\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\r\n\r\n.sd-width-handle {\r\n position: relative;\r\n z-index: 0;\r\n cursor: col-resize;\r\n align-self: stretch;\r\n flex: 0 0 10px;\r\n width: 10px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n}\r\n\r\n.sd-width-handle::after {\r\n content: \"\";\r\n background: linear-gradient(\r\n to bottom,\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\r\n );\r\n opacity: 0;\r\n pointer-events: none;\r\n border-radius: 2px;\r\n width: 2px;\r\n position: absolute;\r\n top: 0;\r\n bottom: 0;\r\n left: 4px;\r\n}\r\n\r\n.sd-width-handle:hover::after,\r\n.sd-width-handle[data-dragging]::after {\r\n opacity: 1;\r\n}\r\n\r\n.sd-width-handle:focus-visible {\r\n outline: none;\r\n}\r\n\r\n.sd-width-handle:focus-visible::after {\r\n opacity: 1;\r\n}\r\n\r\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\r\n.sd-resizing {\r\n user-select: none;\r\n cursor: col-resize;\r\n}\r\n\r\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"option\"` 的可聚焦 div,而这一代\r\n `--dsw-focus-ring-color` 逐字是 `transparent`(2026-10-15 实测)——不自己描一条,键盘走\r\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\r\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\r\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\r\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\r\n outline-offset: 1px;\r\n}\r\n\r\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\r\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\r\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\r\n.sd-rowaction-button {\r\n border-radius: var(--dsw-radius-xs);\r\n cursor: pointer;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--dsw-alias-label-tertiary);\r\n background: 0 0;\r\n border: none;\r\n flex: none;\r\n justify-content: center;\r\n align-items: center;\r\n padding: 0;\r\n display: inline-flex;\r\n}\r\n.sd-rowaction-button:hover {\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\r\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\r\n 禁用档走 opacity。 */\r\n.sd-rowaction-button[aria-pressed='true'] {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n.sd-rowaction-button:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n/* 行首 16px 格里的勾选框(2026-10-17 改版,**只在多选模式内渲染**)。格本身由宿主居中(16×20),\r\n 这里只把包装层压成同样大小;勾选框本体是回收站多选同款原生 input(`.sd-checkbox`,见上)。 */\r\n.sd-batch-pick-leading {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\r\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\r\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\r\n}\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\r\n display: none;\r\n}\r\n\r\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\r\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\r\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\r\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\r\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\r\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\r\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\r\n.sd-batch-bar {\r\n position: fixed;\r\n bottom: 24px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n z-index: 900;\r\n width: max-content;\r\n max-width: calc(100vw - 48px);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n gap: 8px;\r\n padding: 8px 12px;\r\n background: var(--dsw-alias-bg-layer-2);\r\n border: 0.5px solid var(--dsw-alias-border-l4);\r\n border-radius: var(--dsw-radius-lg);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n font-size: 12px;\r\n line-height: 18px;\r\n}\r\n\r\n.sd-batch-bar-main {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.sd-batch-bar-hint {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list-head {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\r\n.sd-batch-bar-list-head span + span {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n max-height: 240px;\r\n overflow: auto;\r\n margin: 0;\r\n padding: 0 0 8px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\r\n list-style: none;\r\n}\r\n\r\n.sd-batch-bar-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n min-width: 0;\r\n}\r\n\r\n.sd-batch-bar-title {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-batch-bar-meta {\r\n flex: none;\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-remove {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: none;\r\n width: 16px;\r\n height: 16px;\r\n padding: 0;\r\n border: none;\r\n border-radius: var(--dsw-radius-xs);\r\n background: 0 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-batch-bar-remove:hover {\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n";
|
|
846
1208
|
//#endregion
|
|
847
1209
|
//#region src/client/ui/markdown.css?dsh-css-text
|
|
848
|
-
var markdown_default = "/**\r\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\r\n *\r\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\r\n * 那条 fidelity
|
|
1210
|
+
var markdown_default = "/**\r\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\r\n *\r\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\r\n * 那条 fidelity 核对(`docs/development.md` 的界面保真复核门)要的是它与官方明文**逐属性零差异**,只许文件头点名的几类例外\r\n * (类名前缀、`@keyframes` 改名,外加它点名的自造与已声明的差别);条数不钉在这里,钉了就会漂成假基线。这一份抄的是\r\n * **另一个来源**(宿主 markdown 那一张表),混进去就把那份核对弄脏了。\r\n *\r\n * **来源(逐字抄,只改选择器与下面点名的三处)**:\r\n * `app.asar` 里 `@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css`\r\n * (`.markdown` 那一支;`.compact` 一支我们不用)+ `lib/markdown/CodeBlock.module.css` 的 `pre`。\r\n * token 名一个没换:`--dsw-font-markdown-*` / `--dsw-alias-markdown-*` / `--dsh-content-font-*`\r\n * 全是宿主会话区自己吃的那组,所以宿主调内容字号时,这里跟着一起动。\r\n *\r\n * 三处**有意**的差异,每条都是因为我们的 DOM 长不出宿主那个节点:\r\n * ① 宿主给表包一层 `.tableScroll`(div)再横向滚动;react-markdown 不产这层 ⇒ 滚动挂在 `table`\r\n * 自己身上(`display:block` + 匿名表盒)。这条得真机量过才算成立。\r\n * ② 宿主的代码卡有 banner 行(语言名 + 复制),四个圆角被拆成\"上圆角在 banner、下圆角在 pre\";\r\n * 我们没有 banner ⇒ 圆角整块给 `pre`。\r\n * ③ 宿主的链接可点(点开外链/文件),我们不碰宿主导航,链接渲成不可点的文本 ⇒ 保留链接色与字重,\r\n * **去掉** hover 的下划线(那是\"点我\"的邀请)。\r\n *\r\n * 抄过来但用不上的选择器已删:`.fileLink` / `.fileMention` / `.linkIcon` / `.image` / `.imageAlt`\r\n * (图片渲成 `[图片 · alt]`)/ `.katex-display` / `:global(.md-code-block)` / `.shiki`(高亮由宿主\r\n * 的 highlighter 提供,我们没有)。\r\n */\r\n.sd-md {\r\n min-width: 0;\r\n overflow-wrap: anywhere;\r\n font: var(--dsw-font-markdown-base);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-md strong {\r\n font-weight: 600;\r\n}\r\n\r\n.sd-md h1 {\r\n font: var(--dsw-font-markdown-h1);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h2 {\r\n font: var(--dsw-font-markdown-h2);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h3 {\r\n font: var(--dsw-font-markdown-h3);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h4 {\r\n font: var(--dsw-font-markdown-h4);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h5, h6) {\r\n font: var(--dsw-font-markdown-base-strong);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) strong {\r\n font-weight: inherit;\r\n}\r\n\r\n.sd-md p {\r\n margin: 16px 0;\r\n}\r\n\r\n/* Tighten h4–h6 against a following list (design: 8px gap). */\r\n.sd-md :where(h4, h5, h6) + :where(ul, ol) {\r\n margin-top: 8px;\r\n}\r\n\r\n.sd-md :where(h4, h5, h6):has(+ :where(ul, ol)) {\r\n margin-bottom: 8px;\r\n}\r\n\r\n/* 见文件头差异③:颜色与字重照抄,hover 的 dotted 下划线不抄——这里没有可点的东西。 */\r\n.sd-md-link {\r\n color: var(--dsw-alias-link);\r\n font-weight: 500;\r\n}\r\n\r\n.sd-md :where(ul, ol) {\r\n margin: 16px 0;\r\n padding-left: 18px;\r\n}\r\n\r\n.sd-md li:not(:first-child) {\r\n margin-top: 6px;\r\n}\r\n\r\n.sd-md li > :where(ul, ol) {\r\n margin-top: 4px;\r\n}\r\n\r\n.sd-md li::marker {\r\n line-height: 24px;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */\r\n.sd-md :where(ul, ol) ol {\r\n list-style-position: inside;\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md :where(ul, ol) ol li p {\r\n display: inline;\r\n}\r\n\r\n.sd-md li > p {\r\n margin: 8px 0;\r\n}\r\n\r\n.sd-md li > *:first-child {\r\n margin-top: 0;\r\n}\r\n\r\n.sd-md li > *:last-child {\r\n margin-bottom: 0;\r\n}\r\n\r\n.sd-md hr {\r\n display: block;\r\n border: none;\r\n height: 0.5px;\r\n margin: 32px 0;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n.sd-md blockquote {\r\n border-left: 2px solid var(--dsw-alias-label-caption);\r\n margin: 16px 0 0;\r\n padding-left: 14px;\r\n}\r\n\r\n/* 代码卡:宿主的卡片由 `.block`(底色+圆角)与其内 `pre`(字号+内边距)两层拼成。\r\n 我们只有 `pre` 这一层,所以两层的声明合成在这里——见文件头差异②。 */\r\n.sd-md pre {\r\n font: var(--dsw-font-markdown-code-block);\r\n margin: 16px 0;\r\n padding: 16px;\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-markdown-code-block);\r\n border-radius: var(--dsw-radius-lg);\r\n overflow-x: auto;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n}\r\n\r\n.sd-md pre code {\r\n font: inherit;\r\n background: none;\r\n padding: 0;\r\n}\r\n\r\n.sd-md :not(pre) > code {\r\n display: inline-flex;\r\n align-items: center;\r\n box-sizing: border-box;\r\n font: var(--dsw-font-markdown-code);\r\n font-family: var(--ds-font-family-code);\r\n font-size: 0.875em !important;\r\n background-color: var(--dsw-alias-markdown-inline-code);\r\n border: 0.5px solid var(--dsw-alias-border-l1);\r\n border-radius: var(--dsw-radius-sm);\r\n padding: 0 5px;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) code {\r\n font: inherit;\r\n font-family: var(--ds-font-family-code);\r\n}\r\n\r\n.sd-md input[type='checkbox'] {\r\n margin: 0 8px 0 0;\r\n accent-color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* 见文件头差异①:宿主是 `.tableScroll > table`,我们没有那层 div。 */\r\n.sd-md table {\r\n display: block;\r\n box-sizing: border-box;\r\n max-width: 100%;\r\n overflow-x: auto;\r\n overscroll-behavior-x: contain;\r\n border-collapse: collapse;\r\n width: max-content;\r\n}\r\n\r\n.sd-md th {\r\n text-align: start;\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l3);\r\n border-top: none;\r\n font: var(--dsw-font-markdown-table-head);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md td {\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\r\n font: var(--dsw-font-markdown-table);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md th:first-child,\r\n.sd-md td:first-child {\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md td:last-child {\r\n padding-right: 0;\r\n}\r\n\r\n.sd-md table code {\r\n font-size: 11px;\r\n}\r\n\r\n.sd-md > *:first-child,\r\n.sd-md p:first-child {\r\n margin-top: 0 !important;\r\n}\r\n\r\n.sd-md > *:last-child,\r\n.sd-md p:last-child {\r\n margin-bottom: 0 !important;\r\n}\r\n\r\n/* 图片在回收站里读不到(路径属于会话当时的 cwd),渲成一句说明,字色用宿主的 tertiary。 */\r\n.sd-md-image {\r\n color: var(--dsw-alias-label-tertiary);\r\n font-style: italic;\r\n}\r\n";
|
|
849
1211
|
//#endregion
|
|
850
1212
|
//#region src/client/ui/styles.ts
|
|
851
1213
|
/**
|
|
@@ -1032,227 +1394,73 @@ window.__ModuleLoader__.load({
|
|
|
1032
1394
|
//#endregion
|
|
1033
1395
|
//#region src/client/batch-select.tsx
|
|
1034
1396
|
/**
|
|
1035
|
-
*
|
|
1397
|
+
* 宿主列表批量删除的**控件面**(2026-10-17,2026-10-20 拆出状态与行适配):行首勾选框、行尾兜底开关,
|
|
1398
|
+
* 以及确认框/浮条读的那一份计数问句。
|
|
1036
1399
|
*
|
|
1037
1400
|
* 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
|
|
1038
|
-
* 才把整行点击变成"勾/取消"
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
* 四处隔着槽位边界,只有模块级 store 能让他们看见同一份名单。状态源用 React 自带的
|
|
1042
|
-
* `useSyncExternalStore`,不为此引新的状态库。
|
|
1401
|
+
* 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
|
|
1402
|
+
* 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
|
|
1403
|
+
* store 能让他们看见同一份名单;本文件不再持有任何现场。
|
|
1043
1404
|
*
|
|
1044
1405
|
* **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
|
|
1045
1406
|
* 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
|
|
1046
1407
|
* 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
|
|
1047
|
-
* 但模式内**点整行**也能选(`useSelectionRowClickGuard`),所以不依赖那颗小框。
|
|
1408
|
+
* 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
|
|
1048
1409
|
* 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
|
|
1049
|
-
*
|
|
1050
|
-
* 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id),**顺序 = 勾选顺序**:
|
|
1051
|
-
* 宿主列表的 DOM 序不归我们读,`/apply` 那条「保持输入顺序」吃到的因此就是勾选序。
|
|
1052
1410
|
*/
|
|
1053
|
-
let picks = [];
|
|
1054
|
-
/** 多选模式:入口在每一行的「⋯」菜单里;只有模式内才渲染勾选框、才吃整行点击。 */
|
|
1055
|
-
let selecting = false;
|
|
1056
|
-
const listeners$4 = /* @__PURE__ */ new Set();
|
|
1057
|
-
const emit$3 = () => {
|
|
1058
|
-
for (const listener of listeners$4) listener();
|
|
1059
|
-
};
|
|
1060
|
-
function getPicks() {
|
|
1061
|
-
return picks;
|
|
1062
|
-
}
|
|
1063
|
-
function isSelecting() {
|
|
1064
|
-
return selecting;
|
|
1065
|
-
}
|
|
1066
|
-
/** 「⋯ → 多选」按下时进入;重复进入是幂等的。 */
|
|
1067
|
-
function enterSelection() {
|
|
1068
|
-
if (selecting) return;
|
|
1069
|
-
selecting = true;
|
|
1070
|
-
emit$3();
|
|
1071
|
-
}
|
|
1072
|
-
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
1073
|
-
function exitSelection() {
|
|
1074
|
-
if (!selecting && picks.length === 0) return;
|
|
1075
|
-
selecting = false;
|
|
1076
|
-
picks = [];
|
|
1077
|
-
emit$3();
|
|
1078
|
-
}
|
|
1079
|
-
function useSelecting() {
|
|
1080
|
-
return (0, react.useSyncExternalStore)(subscribe$3, isSelecting, isSelecting);
|
|
1081
|
-
}
|
|
1082
|
-
/** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
|
|
1083
|
-
function togglePick(item) {
|
|
1084
|
-
picks = picks.findIndex((candidate) => candidate.sessionId === item.sessionId) === -1 ? [...picks, item] : picks.filter((candidate) => candidate.sessionId !== item.sessionId);
|
|
1085
|
-
emit$3();
|
|
1086
|
-
}
|
|
1087
|
-
function clearPicks() {
|
|
1088
|
-
if (picks.length === 0) return;
|
|
1089
|
-
picks = [];
|
|
1090
|
-
emit$3();
|
|
1091
|
-
}
|
|
1092
1411
|
/**
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1412
|
+
* 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
|
|
1413
|
+
*
|
|
1414
|
+
* **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
|
|
1415
|
+
* 缺一条就是假数;「不许拿 0 冒充没有」。
|
|
1096
1416
|
*/
|
|
1097
|
-
function
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (sessionId === "" || seen.has(sessionId)) continue;
|
|
1105
|
-
const info = rowInfo.get(sessionId);
|
|
1106
|
-
if (info === void 0 || info.live) continue;
|
|
1107
|
-
seen.add(sessionId);
|
|
1108
|
-
rows.push({
|
|
1109
|
-
sessionId,
|
|
1110
|
-
title: info.title
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
return rows;
|
|
1114
|
-
}
|
|
1115
|
-
/** 全选:把当前可见可选行里还没选的按 DOM 序追加;原有选中保持在前。 */
|
|
1116
|
-
function selectAllVisible() {
|
|
1117
|
-
const chosen = new Set(picks.map((item) => item.sessionId));
|
|
1118
|
-
const additions = visibleSelectableRows().filter((row) => !chosen.has(row.sessionId));
|
|
1119
|
-
if (additions.length === 0) return;
|
|
1120
|
-
picks = [...picks, ...additions];
|
|
1121
|
-
emit$3();
|
|
1417
|
+
async function fetchCounts(deps, ids) {
|
|
1418
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
1419
|
+
const outcome = await callRemote(() => deps.applyOutcomes("count", ids));
|
|
1420
|
+
if (!outcome.ok) return null;
|
|
1421
|
+
const receipt = readReceipt("count", ids, outcome.value);
|
|
1422
|
+
if (receipt.verdicts.some((verdict) => verdict.kind !== "ok")) return null;
|
|
1423
|
+
return new Map(receipt.verdicts.map((verdict) => [verdict.id, verdict.kind === "ok" ? verdict.value : 0]));
|
|
1122
1424
|
}
|
|
1123
1425
|
/**
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1426
|
+
* 行首 16px 格里的主勾选框:**只在多选模式内渲染**(模式入口在「⋯ → 多选」,不常驻)。
|
|
1427
|
+
*
|
|
1428
|
+
* 用的是回收站多选同款原生 `Checkbox`:它自己会 `stopPropagation`,所以点框不会顺带打开会话
|
|
1429
|
+
* (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
|
|
1430
|
+
* 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
|
|
1128
1431
|
*/
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
if (
|
|
1133
|
-
const
|
|
1134
|
-
return
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1432
|
+
function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
|
|
1433
|
+
const { selecting, picks } = useSelection();
|
|
1434
|
+
const on = picks.some((candidate) => candidate.sessionId === sessionId);
|
|
1435
|
+
if (!selecting) return null;
|
|
1436
|
+
const name = title === "" ? t("common.untitled") : title;
|
|
1437
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1438
|
+
className: "sd-batch-pick-leading",
|
|
1439
|
+
"data-dsh-session-delete": "batch-pick",
|
|
1440
|
+
"data-seat": "leading",
|
|
1441
|
+
"data-checked": on ? "true" : "false",
|
|
1442
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
1443
|
+
checked: on,
|
|
1444
|
+
label: t("batch.row.pick", { title: name }),
|
|
1445
|
+
onChange: () => {
|
|
1446
|
+
togglePick({
|
|
1447
|
+
sessionId,
|
|
1448
|
+
title
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
})
|
|
1452
|
+
});
|
|
1150
1453
|
}
|
|
1151
1454
|
/**
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*/
|
|
1156
|
-
const rowInfo = /* @__PURE__ */ new Map();
|
|
1157
|
-
/** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
|
|
1158
|
-
const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
|
|
1159
|
-
/**
|
|
1160
|
-
* 多选态内的整行点击守卫(document 捕获阶段)。
|
|
1161
|
-
*
|
|
1162
|
-
* 为什么必须在这里拦:宿主的会话行自己带 `onClick = open(sessionId)`,第三方没有官方的
|
|
1163
|
-
* "别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
|
|
1164
|
-
* 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
|
|
1165
|
-
* 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
|
|
1166
|
-
* 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
|
|
1167
|
-
* 勾选框本身仍在。
|
|
1168
|
-
*/
|
|
1169
|
-
function useSelectionRowClickGuard() {
|
|
1170
|
-
const selectingNow = useSelecting();
|
|
1171
|
-
(0, react.useEffect)(() => {
|
|
1172
|
-
if (!selectingNow) return;
|
|
1173
|
-
const handle = (event) => {
|
|
1174
|
-
if (!(event.target instanceof Element)) return;
|
|
1175
|
-
const row = event.target.closest("[data-row-key^=\"session:\"]");
|
|
1176
|
-
if (row === null) return;
|
|
1177
|
-
if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
|
|
1178
|
-
event.preventDefault();
|
|
1179
|
-
event.stopPropagation();
|
|
1180
|
-
if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
|
|
1181
|
-
const key = row.getAttribute("data-row-key") ?? "";
|
|
1182
|
-
if (!key.startsWith("session:")) return;
|
|
1183
|
-
const sessionId = key.slice(8);
|
|
1184
|
-
if (sessionId === "") return;
|
|
1185
|
-
const info = rowInfo.get(sessionId);
|
|
1186
|
-
if (info?.live === true) return;
|
|
1187
|
-
togglePick({
|
|
1188
|
-
sessionId,
|
|
1189
|
-
title: info?.title ?? ""
|
|
1190
|
-
});
|
|
1191
|
-
};
|
|
1192
|
-
document.addEventListener("click", handle, true);
|
|
1193
|
-
document.addEventListener("dblclick", handle, true);
|
|
1194
|
-
return () => {
|
|
1195
|
-
document.removeEventListener("click", handle, true);
|
|
1196
|
-
document.removeEventListener("dblclick", handle, true);
|
|
1197
|
-
};
|
|
1198
|
-
}, [selectingNow]);
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
|
|
1202
|
-
*
|
|
1203
|
-
* **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
|
|
1204
|
-
* 缺一条就是假数;「不许拿 0 冒充没有」(10-15 稿 §10)。
|
|
1205
|
-
*/
|
|
1206
|
-
async function fetchCounts(deps, ids) {
|
|
1207
|
-
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
1208
|
-
const outcome = await callRemote(() => deps.applyOutcomes("count", ids));
|
|
1209
|
-
if (!outcome.ok) return null;
|
|
1210
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1211
|
-
for (const item of outcome.value) {
|
|
1212
|
-
if (item.action !== "count" || !item.ok) return null;
|
|
1213
|
-
counts.set(item.id, item.value);
|
|
1214
|
-
}
|
|
1215
|
-
return counts.size === ids.length ? counts : null;
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* 行首 16px 格里的主勾选框:**只在多选模式内渲染**(模式入口在「⋯ → 多选」,不常驻)。
|
|
1219
|
-
*
|
|
1220
|
-
* 用的是回收站多选同款原生 `Checkbox`:它自己会 `stopPropagation`,所以点框不会顺带打开会话
|
|
1221
|
-
* (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
|
|
1222
|
-
* 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
|
|
1223
|
-
*/
|
|
1224
|
-
function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
|
|
1225
|
-
const selectingNow = useSelecting();
|
|
1226
|
-
const on = usePicks().some((candidate) => candidate.sessionId === sessionId);
|
|
1227
|
-
if (!selectingNow) return null;
|
|
1228
|
-
const name = title === "" ? t("common.untitled") : title;
|
|
1229
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1230
|
-
className: "sd-batch-pick-leading",
|
|
1231
|
-
"data-dsh-session-delete": "batch-pick",
|
|
1232
|
-
"data-seat": "leading",
|
|
1233
|
-
"data-checked": on ? "true" : "false",
|
|
1234
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
1235
|
-
checked: on,
|
|
1236
|
-
label: t("batch.row.pick", { title: name }),
|
|
1237
|
-
onChange: () => {
|
|
1238
|
-
togglePick({
|
|
1239
|
-
sessionId,
|
|
1240
|
-
title
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1243
|
-
})
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
1246
|
-
/**
|
|
1247
|
-
* 多选态里行尾悬停排的**兜底**勾选开关:只在宿主不渲染行首格的行上露头(状态点行 / 归档行)。
|
|
1248
|
-
* 座位原文保证「排内点击不误开会话」(`rowActions` 那层自己挡住冒泡),所以这颗按钮不需要
|
|
1249
|
-
* 自己 `stopPropagation`。行首那颗与这颗共用同一份选中集和同一句 aria 名字;模式内点整行也能选。
|
|
1455
|
+
* 多选态里行尾悬停排的**兜底**勾选开关:只在宿主不渲染行首格的行上露头(状态点行 / 归档行)。
|
|
1456
|
+
* 座位原文保证「排内点击不误开会话」(`rowActions` 那层自己挡住冒泡),所以这颗按钮不需要
|
|
1457
|
+
* 自己 `stopPropagation`。行首那颗与这颗共用同一份选中集和同一句 aria 名字;模式内点整行也能选。
|
|
1250
1458
|
*/
|
|
1251
1459
|
function BatchPickButton({ sessionId, title, live = false, t = zhTranslate }) {
|
|
1252
|
-
const
|
|
1253
|
-
const on =
|
|
1460
|
+
const { selecting, picks } = useSelection();
|
|
1461
|
+
const on = picks.some((candidate) => candidate.sessionId === sessionId);
|
|
1254
1462
|
(0, react.useEffect)(() => {
|
|
1255
|
-
|
|
1463
|
+
registerHostRowInfo(sessionId, {
|
|
1256
1464
|
title,
|
|
1257
1465
|
live
|
|
1258
1466
|
});
|
|
@@ -1261,7 +1469,7 @@ window.__ModuleLoader__.load({
|
|
|
1261
1469
|
title,
|
|
1262
1470
|
live
|
|
1263
1471
|
]);
|
|
1264
|
-
if (!
|
|
1472
|
+
if (!selecting) return null;
|
|
1265
1473
|
const name = title === "" ? t("common.untitled") : title;
|
|
1266
1474
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
|
|
1267
1475
|
label: t("menu.live"),
|
|
@@ -1289,43 +1497,6 @@ window.__ModuleLoader__.load({
|
|
|
1289
1497
|
});
|
|
1290
1498
|
}
|
|
1291
1499
|
//#endregion
|
|
1292
|
-
//#region src/client/trash-store.ts
|
|
1293
|
-
/**
|
|
1294
|
-
* 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
|
|
1295
|
-
*
|
|
1296
|
-
* 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
|
|
1297
|
-
* **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
|
|
1298
|
-
* 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
|
|
1299
|
-
* 同理。
|
|
1300
|
-
*
|
|
1301
|
-
* 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场 +
|
|
1302
|
-
* `useSyncExternalStore`**,不为此引状态库、也不引入上下文——三样东西都挂在 `shell.overlay`/`main`
|
|
1303
|
-
* 这些各不相邻的座位上,模块级是它们唯一的共同点。
|
|
1304
|
-
*
|
|
1305
|
-
* 语义只有"变过"这一件事:一个单调递增的修订号。面板把它写进 effect 的依赖里,戳一次重读一次;
|
|
1306
|
-
* 不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
|
|
1307
|
-
*/
|
|
1308
|
-
let revision = 0;
|
|
1309
|
-
const listeners$3 = /* @__PURE__ */ new Set();
|
|
1310
|
-
/** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
|
|
1311
|
-
function invalidateTrash() {
|
|
1312
|
-
revision += 1;
|
|
1313
|
-
for (const listener of listeners$3) listener();
|
|
1314
|
-
}
|
|
1315
|
-
function getTrashRevision() {
|
|
1316
|
-
return revision;
|
|
1317
|
-
}
|
|
1318
|
-
function subscribeTrash(listener) {
|
|
1319
|
-
listeners$3.add(listener);
|
|
1320
|
-
return () => {
|
|
1321
|
-
listeners$3.delete(listener);
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
|
-
/** 供面板订阅。 */
|
|
1325
|
-
function useTrashRevision() {
|
|
1326
|
-
return (0, react.useSyncExternalStore)(subscribeTrash, getTrashRevision, getTrashRevision);
|
|
1327
|
-
}
|
|
1328
|
-
//#endregion
|
|
1329
1500
|
//#region src/client/ui/Button.tsx
|
|
1330
1501
|
/**
|
|
1331
1502
|
* `Button`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Button,出处与三条改写规则
|
|
@@ -1558,9 +1729,157 @@ window.__ModuleLoader__.load({
|
|
|
1558
1729
|
}), document.body);
|
|
1559
1730
|
}
|
|
1560
1731
|
//#endregion
|
|
1732
|
+
//#region src/client/main-view.ts
|
|
1733
|
+
let client;
|
|
1734
|
+
/** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开。 */
|
|
1735
|
+
let closedByUs = /* @__PURE__ */ new Set();
|
|
1736
|
+
/** 重开排队的最新序号:后一次删除/恢复(或重开)让前一次的等待作废(只开最新那条)。 */
|
|
1737
|
+
let openToken = 0;
|
|
1738
|
+
/** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
|
|
1739
|
+
function bindMainView(ctx) {
|
|
1740
|
+
client = ctx;
|
|
1741
|
+
}
|
|
1742
|
+
function uiWorkspaceOf() {
|
|
1743
|
+
try {
|
|
1744
|
+
const candidate = client?.get?.("uiWorkspace");
|
|
1745
|
+
if (candidate === void 0 || candidate === null) return void 0;
|
|
1746
|
+
if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return void 0;
|
|
1747
|
+
if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return void 0;
|
|
1748
|
+
return candidate;
|
|
1749
|
+
} catch {
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
function currentSessionOf(ui) {
|
|
1754
|
+
try {
|
|
1755
|
+
return ui.selection?.getSnapshot()?.sessionId;
|
|
1756
|
+
} catch {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* 删除成功后调用:当前主视图若在这批里,把它关掉并记下"这一条是我们关掉的"(供撤销重开)。
|
|
1762
|
+
* 关不掉/不是当前会话都什么都不做——这只是界面收尾,绝不让删除结果变红。
|
|
1763
|
+
*/
|
|
1764
|
+
function afterDelete(sessionIds) {
|
|
1765
|
+
if (sessionIds.length === 0) return;
|
|
1766
|
+
openToken += 1;
|
|
1767
|
+
const ui = uiWorkspaceOf();
|
|
1768
|
+
if (ui === void 0) return;
|
|
1769
|
+
const current = currentSessionOf(ui);
|
|
1770
|
+
if (current === void 0 || !sessionIds.includes(current)) return;
|
|
1771
|
+
try {
|
|
1772
|
+
ui.clearMain();
|
|
1773
|
+
closedByUs.add(current);
|
|
1774
|
+
} catch {}
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
|
|
1778
|
+
*
|
|
1779
|
+
* 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
|
|
1780
|
+
* 的成功条数可能多于一条;`openWhenListed` 的 token 守卫保证前一条的等待不会后落地抢焦点。
|
|
1781
|
+
* 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
|
|
1782
|
+
*/
|
|
1783
|
+
function afterRestore(sessionIds) {
|
|
1784
|
+
if (sessionIds.length === 0) return;
|
|
1785
|
+
openToken += 1;
|
|
1786
|
+
const targets = sessionIds.filter((id) => closedByUs.has(id));
|
|
1787
|
+
if (targets.length === 0) return;
|
|
1788
|
+
for (const id of targets) closedByUs.delete(id);
|
|
1789
|
+
openWhenListed(targets[targets.length - 1]);
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
|
|
1793
|
+
* 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——所以先等它出现,
|
|
1794
|
+
* 最多 2 秒;期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
|
|
1795
|
+
*
|
|
1796
|
+
* **token 守卫**(`openToken`):开始等待时取一个序号,立即开与每次 tick 都核对;后一次删除/
|
|
1797
|
+
* 恢复(或 `resetMainView`)推进序号,前一次的等待就不再打开、也不再续时。
|
|
1798
|
+
*/
|
|
1799
|
+
function openWhenListed(sessionId) {
|
|
1800
|
+
const ui = uiWorkspaceOf();
|
|
1801
|
+
if (ui === void 0) return;
|
|
1802
|
+
const token = ++openToken;
|
|
1803
|
+
const listReady = () => {
|
|
1804
|
+
try {
|
|
1805
|
+
return ui.sessions?.list?.getSnapshot()?.byId?.[sessionId] !== void 0;
|
|
1806
|
+
} catch {
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
const canTakeFocus = () => {
|
|
1811
|
+
const current = currentSessionOf(ui);
|
|
1812
|
+
return current === void 0 || current === sessionId;
|
|
1813
|
+
};
|
|
1814
|
+
const open = () => {
|
|
1815
|
+
if (token !== openToken) return;
|
|
1816
|
+
if (!canTakeFocus()) return;
|
|
1817
|
+
try {
|
|
1818
|
+
ui.openSession?.(sessionId);
|
|
1819
|
+
} catch {}
|
|
1820
|
+
};
|
|
1821
|
+
if (listReady()) {
|
|
1822
|
+
open();
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
let attempts = 0;
|
|
1826
|
+
const tick = () => {
|
|
1827
|
+
if (token !== openToken) return;
|
|
1828
|
+
attempts += 1;
|
|
1829
|
+
if (listReady()) {
|
|
1830
|
+
open();
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (attempts < 20) setTimeout(tick, 100);
|
|
1834
|
+
};
|
|
1835
|
+
setTimeout(tick, 100);
|
|
1836
|
+
}
|
|
1837
|
+
//#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);
|
|
1858
|
+
}
|
|
1859
|
+
/** 供面板订阅。 */
|
|
1860
|
+
function useTrashRevision() {
|
|
1861
|
+
return revisionStore.use();
|
|
1862
|
+
}
|
|
1863
|
+
//#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();
|
|
1874
|
+
}
|
|
1875
|
+
function commitExternalRestore(sessionIds) {
|
|
1876
|
+
afterRestore(sessionIds);
|
|
1877
|
+
invalidateTrash();
|
|
1878
|
+
}
|
|
1879
|
+
//#endregion
|
|
1561
1880
|
//#region src/client/undo-toast.tsx
|
|
1562
1881
|
/**
|
|
1563
|
-
*
|
|
1882
|
+
* 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
|
|
1564
1883
|
*
|
|
1565
1884
|
* 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
|
|
1566
1885
|
* (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
|
|
@@ -1570,71 +1889,71 @@ window.__ModuleLoader__.load({
|
|
|
1570
1889
|
* `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
|
|
1571
1890
|
* 本文件就是那份 store + 那个常驻组件。
|
|
1572
1891
|
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1892
|
+
* 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
|
|
1893
|
+
* 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
|
|
1575
1894
|
*/
|
|
1576
|
-
|
|
1577
|
-
let nextSeq$2 = 0;
|
|
1578
|
-
const listeners$2 = /* @__PURE__ */ new Set();
|
|
1579
|
-
const emit$2 = () => {
|
|
1580
|
-
for (const listener of listeners$2) listener();
|
|
1581
|
-
};
|
|
1895
|
+
const noticeStore = createStore(null);
|
|
1582
1896
|
/** 删除成功后发布一条撤销提示(单条)。 */
|
|
1583
1897
|
function publishUndoNotice(input) {
|
|
1584
|
-
|
|
1585
|
-
seq
|
|
1898
|
+
noticeStore.publish((seq) => ({
|
|
1899
|
+
seq,
|
|
1586
1900
|
kind: "one",
|
|
1587
1901
|
entryId: input.entryId,
|
|
1588
1902
|
title: input.title
|
|
1589
|
-
};
|
|
1590
|
-
emit$2();
|
|
1903
|
+
}));
|
|
1591
1904
|
}
|
|
1592
|
-
/**
|
|
1905
|
+
/** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
|
|
1593
1906
|
function publishUndoBatchNotice(input) {
|
|
1594
|
-
|
|
1595
|
-
seq
|
|
1907
|
+
noticeStore.publish((seq) => ({
|
|
1908
|
+
seq,
|
|
1596
1909
|
kind: "batch",
|
|
1597
1910
|
entryIds: input.entryIds,
|
|
1598
1911
|
n: input.n
|
|
1599
|
-
};
|
|
1600
|
-
emit$2();
|
|
1912
|
+
}));
|
|
1601
1913
|
}
|
|
1602
1914
|
/** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
|
|
1603
1915
|
function clearUndoNotice() {
|
|
1604
|
-
|
|
1605
|
-
notice = null;
|
|
1606
|
-
emit$2();
|
|
1607
|
-
}
|
|
1608
|
-
function getUndoNotice() {
|
|
1609
|
-
return notice;
|
|
1610
|
-
}
|
|
1611
|
-
function subscribe$2(listener) {
|
|
1612
|
-
listeners$2.add(listener);
|
|
1613
|
-
return () => {
|
|
1614
|
-
listeners$2.delete(listener);
|
|
1615
|
-
};
|
|
1916
|
+
noticeStore.set(null);
|
|
1616
1917
|
}
|
|
1617
1918
|
/** 供组件订阅。 */
|
|
1618
1919
|
function useUndoNotice() {
|
|
1619
|
-
return
|
|
1920
|
+
return noticeStore.use();
|
|
1620
1921
|
}
|
|
1621
|
-
/**
|
|
1922
|
+
/** 提示停留时长。**6 秒** 定死在本行(比 Toast 默认的 3 秒长)——`docs/ui-spec.md` §2 状态 ③ 只指到这里,不抄这个数。 */
|
|
1622
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
|
+
}));
|
|
1931
|
+
}
|
|
1623
1932
|
/** 撤销:单条 `restore(entryId)`、批量**一次** `applyOutcomes('restore', entryIds)`;失败就把原因换到同一条横幅上,不静默。 */
|
|
1624
1933
|
async function undo(deps, current) {
|
|
1625
1934
|
if (current.kind === "one" && current.entryId === "") return;
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
+
}
|
|
1631
1955
|
}
|
|
1632
|
-
|
|
1633
|
-
...current,
|
|
1634
|
-
seq: nextSeq$2 += 1,
|
|
1635
|
-
failure: outcome.failure.message
|
|
1636
|
-
};
|
|
1637
|
-
emit$2();
|
|
1956
|
+
clearUndoNotice();
|
|
1638
1957
|
}
|
|
1639
1958
|
/**
|
|
1640
1959
|
* 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
|
|
@@ -1686,7 +2005,8 @@ window.__ModuleLoader__.load({
|
|
|
1686
2005
|
* 整条链路都在菜单子树之外,菜单关不关都不影响它。
|
|
1687
2006
|
*
|
|
1688
2007
|
* 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
|
|
1689
|
-
* 见 `undo-toast.tsx
|
|
2008
|
+
* 见 `undo-toast.tsx`),状态源是 `store.ts` 的 `createStore`(内部用 React 自带的
|
|
2009
|
+
* `useSyncExternalStore`)——不为此引新的状态库。
|
|
1690
2010
|
*/
|
|
1691
2011
|
/**
|
|
1692
2012
|
* 被操作对象那张卡。`Modal`(`ui/Modal.tsx`,照官方抄的)的 `.sd-modal-body` 不设字号也不给层级 ⇒ 裸摆一行会话标题会和下面那句
|
|
@@ -1700,39 +2020,22 @@ window.__ModuleLoader__.load({
|
|
|
1700
2020
|
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
1701
2021
|
borderRadius: "var(--dsw-radius-lg)"
|
|
1702
2022
|
};
|
|
1703
|
-
|
|
1704
|
-
let nextSeq$1 = 0;
|
|
1705
|
-
const listeners$1 = /* @__PURE__ */ new Set();
|
|
1706
|
-
const emit$1 = () => {
|
|
1707
|
-
for (const listener of listeners$1) listener();
|
|
1708
|
-
};
|
|
2023
|
+
const requestStore$1 = createStore(null);
|
|
1709
2024
|
/** 菜单行调用:请求删除这个会话(确认框由 overlay 上的宿主渲染)。 */
|
|
1710
2025
|
function requestDelete(input) {
|
|
1711
|
-
|
|
1712
|
-
seq
|
|
2026
|
+
requestStore$1.publish((seq) => ({
|
|
2027
|
+
seq,
|
|
1713
2028
|
sessionId: input.sessionId,
|
|
1714
2029
|
title: input.title
|
|
1715
|
-
};
|
|
1716
|
-
emit$1();
|
|
2030
|
+
}));
|
|
1717
2031
|
}
|
|
1718
2032
|
/** 收起确认框(用户点「取消」/「关闭」、或删除成功后收工)。 */
|
|
1719
2033
|
function clearDeleteRequest() {
|
|
1720
|
-
|
|
1721
|
-
request$1 = null;
|
|
1722
|
-
emit$1();
|
|
1723
|
-
}
|
|
1724
|
-
function getDeleteRequest() {
|
|
1725
|
-
return request$1;
|
|
1726
|
-
}
|
|
1727
|
-
function subscribe$1(listener) {
|
|
1728
|
-
listeners$1.add(listener);
|
|
1729
|
-
return () => {
|
|
1730
|
-
listeners$1.delete(listener);
|
|
1731
|
-
};
|
|
2034
|
+
requestStore$1.set(null);
|
|
1732
2035
|
}
|
|
1733
2036
|
/** 供宿主组件订阅。 */
|
|
1734
2037
|
function useDeleteRequest() {
|
|
1735
|
-
return
|
|
2038
|
+
return requestStore$1.use();
|
|
1736
2039
|
}
|
|
1737
2040
|
/**
|
|
1738
2041
|
* 常驻的确认框宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
|
|
@@ -1748,7 +2051,7 @@ window.__ModuleLoader__.load({
|
|
|
1748
2051
|
}, current.seq);
|
|
1749
2052
|
}
|
|
1750
2053
|
/**
|
|
1751
|
-
*
|
|
2054
|
+
* 确认框本体。视图与文案与 docs/ui-spec.md 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
|
|
1752
2055
|
* 那个 primary 在宿主浅色下是近黑、深色下近白,**从来不是蓝**)。
|
|
1753
2056
|
* 失败面**不吞**:原文以 `Tag tone="danger"` 摆进对话框,且**确认失败后不关框**,
|
|
1754
2057
|
* 用户可以再点一次(不是一次失败就永久锁死)。
|
|
@@ -1757,7 +2060,7 @@ window.__ModuleLoader__.load({
|
|
|
1757
2060
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
1758
2061
|
const [failure, setFailure] = (0, react.useState)(null);
|
|
1759
2062
|
/**
|
|
1760
|
-
*
|
|
2063
|
+
* 这条会话在盘上带着多少个后代。删除是按组走的(组语义见 `src/trash/groups.ts`),而宿主的客户端本来就把孩子那几行
|
|
1761
2064
|
* 整排藏了 ⇒ 不说就没人知道点下去侧栏会少一片(issue #3)。那一刻它还没进回收站,清单里没有它,
|
|
1762
2065
|
* 所以只能现问一次。
|
|
1763
2066
|
*/
|
|
@@ -1765,7 +2068,7 @@ window.__ModuleLoader__.load({
|
|
|
1765
2068
|
(0, react.useEffect)(() => {
|
|
1766
2069
|
let alive = true;
|
|
1767
2070
|
(async () => {
|
|
1768
|
-
const outcome = await callRemote(() => deps
|
|
2071
|
+
const outcome = await callRemote(() => applyOne(deps, "count", current.sessionId).then((item) => item.value));
|
|
1769
2072
|
if (alive) setSubagents(outcome.ok ? outcome.value : 0);
|
|
1770
2073
|
})();
|
|
1771
2074
|
return () => {
|
|
@@ -1775,18 +2078,21 @@ window.__ModuleLoader__.load({
|
|
|
1775
2078
|
const confirm = async () => {
|
|
1776
2079
|
setBusy(true);
|
|
1777
2080
|
setFailure(null);
|
|
1778
|
-
const outcome = await callRemote(() => deps
|
|
2081
|
+
const outcome = await callRemote(() => applyOne(deps, "delete", current.sessionId).then((item) => item.value));
|
|
1779
2082
|
setBusy(false);
|
|
1780
2083
|
if (!outcome.ok) {
|
|
1781
2084
|
setFailure(outcome.failure.message);
|
|
1782
2085
|
return;
|
|
1783
2086
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2087
|
+
const entry = outcome.value;
|
|
2088
|
+
commitExternalDelete({
|
|
2089
|
+
sessionIds: [current.sessionId],
|
|
2090
|
+
publishUndo: () => publishUndoNotice({
|
|
2091
|
+
entryId: entry.id,
|
|
2092
|
+
title: current.title
|
|
2093
|
+
})
|
|
1789
2094
|
});
|
|
2095
|
+
clearDeleteRequest();
|
|
1790
2096
|
};
|
|
1791
2097
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
1792
2098
|
open: true,
|
|
@@ -1831,9 +2137,9 @@ window.__ModuleLoader__.load({
|
|
|
1831
2137
|
//#endregion
|
|
1832
2138
|
//#region src/client/batch-confirm.tsx
|
|
1833
2139
|
/**
|
|
1834
|
-
*
|
|
2140
|
+
* 批量删除的确认框(2026-10-17)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
|
|
1835
2141
|
*
|
|
1836
|
-
* 为什么又开一个文件而不是把 `delete-confirm.tsx`
|
|
2142
|
+
* 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(docs/ui-spec.md 状态 ②,
|
|
1837
2143
|
* 用例钉着),批量形与它只有三件不同——标题随条数、目标卡逐条列、执行是一次批量 `apply`。共用一个壳
|
|
1838
2144
|
* 意味着单删的 `busy` / 失败面要跟着学批量形,那是拿已交付的行为去换省一个文件。卡片样式(`TARGET_CARD`)
|
|
1839
2145
|
* 与 hint、子会话那两句**复用**单删的,一句不造第二个键。
|
|
@@ -1841,48 +2147,32 @@ window.__ModuleLoader__.load({
|
|
|
1841
2147
|
* 现场是模块级的(同 `delete-confirm.tsx` / `undo-toast.tsx`):请求从浮条那颗按钮发来,确认框住在
|
|
1842
2148
|
* `shell.overlay` 上——它必须活在行开关那棵子树之外(槽位行随宿主重排而卸载,见 `delete-confirm.tsx` 头注)。
|
|
1843
2149
|
*/
|
|
1844
|
-
|
|
1845
|
-
let nextSeq = 0;
|
|
1846
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
1847
|
-
const emit = () => {
|
|
1848
|
-
for (const listener of listeners) listener();
|
|
1849
|
-
};
|
|
2150
|
+
const requestStore = createStore(null);
|
|
1850
2151
|
/** 浮条那颗按钮调用:请求确认这一批(缺省 = 当前选中集)。空名单不发框。 */
|
|
1851
2152
|
function requestDeleteBatch(items = getPicks()) {
|
|
1852
2153
|
if (items.length === 0) return;
|
|
1853
|
-
|
|
1854
|
-
seq
|
|
2154
|
+
requestStore.publish((seq) => ({
|
|
2155
|
+
seq,
|
|
1855
2156
|
items: [...items]
|
|
1856
|
-
};
|
|
1857
|
-
emit();
|
|
2157
|
+
}));
|
|
1858
2158
|
}
|
|
1859
2159
|
function clearDeleteBatchRequest() {
|
|
1860
|
-
|
|
1861
|
-
request = null;
|
|
1862
|
-
emit();
|
|
2160
|
+
requestStore.set(null);
|
|
1863
2161
|
}
|
|
1864
2162
|
/** 重试轮之后把现场收缩到失败名单(框里只剩还没办成的那几条)。 */
|
|
1865
2163
|
function shrinkRequestToFailures(failedSessionIds) {
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
function getBatchRequest() {
|
|
1875
|
-
return request;
|
|
1876
|
-
}
|
|
1877
|
-
function subscribe(listener) {
|
|
1878
|
-
listeners.add(listener);
|
|
1879
|
-
return () => {
|
|
1880
|
-
listeners.delete(listener);
|
|
1881
|
-
};
|
|
2164
|
+
requestStore.update((current) => {
|
|
2165
|
+
if (current === null) return current;
|
|
2166
|
+
const keep = new Set(failedSessionIds);
|
|
2167
|
+
return {
|
|
2168
|
+
...current,
|
|
2169
|
+
items: current.items.filter((item) => keep.has(item.sessionId))
|
|
2170
|
+
};
|
|
2171
|
+
});
|
|
1882
2172
|
}
|
|
1883
2173
|
/** 供浮条与装配面订阅。 */
|
|
1884
2174
|
function useDeleteBatchRequest() {
|
|
1885
|
-
return
|
|
2175
|
+
return requestStore.use();
|
|
1886
2176
|
}
|
|
1887
2177
|
/** 常驻的批量确认框宿主。没有待确认的批量删除时什么都不渲染。 */
|
|
1888
2178
|
function BatchConfirmHost({ deps, t = zhTranslate }) {
|
|
@@ -1953,29 +2243,35 @@ window.__ModuleLoader__.load({
|
|
|
1953
2243
|
setFailures(new Map(pendingIds.map((id) => [id, outcome.failure.message])));
|
|
1954
2244
|
return;
|
|
1955
2245
|
}
|
|
1956
|
-
const
|
|
1957
|
-
for (const item of outcome.value) if (!byId.has(item.id)) byId.set(item.id, item);
|
|
2246
|
+
const receipt = readReceipt("delete", pendingIds, outcome.value);
|
|
1958
2247
|
const moved = [];
|
|
2248
|
+
const deletedSessionIds = [];
|
|
1959
2249
|
const failed = /* @__PURE__ */ new Map();
|
|
1960
2250
|
let okCount = 0;
|
|
1961
|
-
for (const
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
+
});
|
|
1970
2273
|
}
|
|
1971
|
-
okCount += 1;
|
|
1972
|
-
if (item.action === "delete" && item.value !== null) moved.push(item.value.id);
|
|
1973
|
-
}
|
|
1974
|
-
if (okCount > 0) publishUndoBatchNotice({
|
|
1975
|
-
entryIds: moved,
|
|
1976
|
-
n: okCount
|
|
1977
2274
|
});
|
|
1978
|
-
invalidateTrash();
|
|
1979
2275
|
shrinkPicksToFailures([...failed.keys()]);
|
|
1980
2276
|
if (failed.size === 0) {
|
|
1981
2277
|
clearDeleteBatchRequest();
|
|
@@ -2032,25 +2328,24 @@ window.__ModuleLoader__.load({
|
|
|
2032
2328
|
//#endregion
|
|
2033
2329
|
//#region src/client/batch-bar.tsx
|
|
2034
2330
|
/**
|
|
2035
|
-
*
|
|
2331
|
+
* 批量删除的浮条(2026-10-17 `6f24fd4`):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
|
|
2036
2332
|
* 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
|
|
2037
2333
|
* (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
|
|
2038
|
-
* 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `
|
|
2334
|
+
* 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
|
|
2039
2335
|
*
|
|
2040
|
-
* 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"
|
|
2041
|
-
*
|
|
2336
|
+
* 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
|
|
2337
|
+
* (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
|
|
2042
2338
|
* `shell.overlay` 上,与单删的撤销提示/确认框同类(槽位原文:"a toast stack or a status pill
|
|
2043
2339
|
* all belong here")。
|
|
2044
2340
|
*
|
|
2045
2341
|
* 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
|
|
2046
|
-
*
|
|
2047
|
-
* 读不到就整句不说——K
|
|
2342
|
+
* 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与 2026-10-17 `c66f082` 那条 `reloadSeq` 同一个道理)。
|
|
2343
|
+
* 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
|
|
2048
2344
|
*/
|
|
2049
2345
|
/** 勾选变化后到发问之间的等待:连勾十条不该发十次 count。 */
|
|
2050
2346
|
const COUNT_DEBOUNCE_MS = 300;
|
|
2051
2347
|
function BatchBar({ deps, t = zhTranslate }) {
|
|
2052
|
-
const selecting =
|
|
2053
|
-
const picks = usePicks();
|
|
2348
|
+
const { selecting, picks } = useSelection();
|
|
2054
2349
|
useSelectionRowClickGuard();
|
|
2055
2350
|
const [counts, setCounts] = (0, react.useState)(null);
|
|
2056
2351
|
const [expanded, setExpanded] = (0, react.useState)(false);
|
|
@@ -2168,7 +2463,7 @@ window.__ModuleLoader__.load({
|
|
|
2168
2463
|
//#endregion
|
|
2169
2464
|
//#region src/client/batch-menu-item.tsx
|
|
2170
2465
|
function BatchSelectMenuItem({ dismissMenu, t = zhTranslate }) {
|
|
2171
|
-
const selecting =
|
|
2466
|
+
const selecting = useSelection().selecting;
|
|
2172
2467
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItemButton, {
|
|
2173
2468
|
separatorBefore: true,
|
|
2174
2469
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckOutlineRegular, { size: 14 }),
|
|
@@ -2183,10 +2478,10 @@ window.__ModuleLoader__.load({
|
|
|
2183
2478
|
//#endregion
|
|
2184
2479
|
//#region src/client/delete-menu-item.tsx
|
|
2185
2480
|
/**
|
|
2186
|
-
*
|
|
2481
|
+
* 会话条目「…」菜单里的「删除」行(docs/ui-spec.md 状态 ①)。
|
|
2187
2482
|
*
|
|
2188
2483
|
* 这一行**只做两件事**:把「要删这个会话」写进 `delete-confirm.tsx` 的模块级现场,然后让菜单
|
|
2189
|
-
* owner
|
|
2484
|
+
* owner 把菜单收起来(确认框不许叠在收起的菜单上——docs/ui-spec.md 状态 ② 说的就是菜单已经收起之后
|
|
2190
2485
|
* 的画面)。**确认框不在这棵子树里**——
|
|
2191
2486
|
* 原因见 `delete-confirm.tsx` 的头注:宿主的菜单行随菜单一起卸载(真产物 `Menu` 是
|
|
2192
2487
|
* `open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`,`open === false`
|
|
@@ -2202,7 +2497,7 @@ window.__ModuleLoader__.load({
|
|
|
2202
2497
|
* 菜单的**开合归 owner**:槽位把 `useMenuOpenState` 交给这一行(`index.tsx` 的 `DeleteRow` 接住后
|
|
2203
2498
|
* 当 `dismissMenu` 传下来),按下时先发请求、再收菜单——与官方 README 的示例同序
|
|
2204
2499
|
* (`onSelect={() => { setMenuOpen(false); … }}`)。
|
|
2205
|
-
* 2.
|
|
2500
|
+
* 2. **运行中置灰**(docs/ui-spec.md「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
|
|
2206
2501
|
* `MenuItemButton` 只把 `disabled` 落在原生按钮属性上(真产物原文 `<button type="button"
|
|
2207
2502
|
* role="menuitem" … disabled={disabled}>`),jest-dom 的 `toBeDisabled()` 也只认这个属性;
|
|
2208
2503
|
* `aria-disabled` 在这条链上既渲染不出来也不算数。原因文案用 `ui/Tooltip.tsx`(照官方 Tooltip 抄的,
|
|
@@ -2235,7 +2530,7 @@ window.__ModuleLoader__.load({
|
|
|
2235
2530
|
//#endregion
|
|
2236
2531
|
//#region src/client/trash-entry.tsx
|
|
2237
2532
|
/**
|
|
2238
|
-
* 侧栏面板列表里的「回收站」行图标(`sidebar.panellist`
|
|
2533
|
+
* 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;docs/ui-spec.md 状态 ④ 的入口那一行)。
|
|
2239
2534
|
*
|
|
2240
2535
|
* **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
|
|
2241
2536
|
* onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
|
|
@@ -24581,13 +24876,13 @@ window.__ModuleLoader__.load({
|
|
|
24581
24876
|
//#endregion
|
|
24582
24877
|
//#region src/client/use-column-resize.ts
|
|
24583
24878
|
/**
|
|
24584
|
-
* 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15
|
|
24879
|
+
* 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,`docs/ui-spec.md` 的「面板与清单」)。
|
|
24585
24880
|
*
|
|
24586
24881
|
* 三件事住在这里,因为它们互相咬合、分开测各自都不成立:
|
|
24587
24882
|
* ① **宽度夹在两个界之间**。下限由调用方给(回收站那条的来历写在 `trash-panel.tsx`:真 Chromium
|
|
24588
24883
|
* 上扫出来的,约束不是"两颗按钮放得下"——它们 `flex:none`,再窄也是文字列缩;而是「删除于 … ·
|
|
24589
24884
|
* 大小」那一行不再被省略号截断),上限 = 面板宽 − `reserve`。**`reserve` 的数值与来历都住在调用方**
|
|
24590
|
-
* (`DETAIL_RESERVE
|
|
24885
|
+
* (`DETAIL_RESERVE`,那三件东西怎么加出来的见 `trash-panel.tsx` 该常量的注释与几何用例):
|
|
24591
24886
|
* 这一层不复制那份算式——上一版在这里钉过一个"480 ⇒ 900 那档正好回 420",调用方改到 489 之后
|
|
24592
24887
|
* 它就成了假数,而 900 那档的真实读数是**清单被夹到 411**(比默认窄 9px,还能往里拖)。
|
|
24593
24888
|
* ② **夹在"读"的时候也做一次**。窗口变窄不触发任何写入,若只在写入时夹,之前拖出来的超宽
|
|
@@ -24668,7 +24963,7 @@ window.__ModuleLoader__.load({
|
|
|
24668
24963
|
//#endregion
|
|
24669
24964
|
//#region src/client/use-panel-width.ts
|
|
24670
24965
|
/**
|
|
24671
|
-
*
|
|
24966
|
+
* 观测**面板自己那一块**的宽度(回收站的断点全靠它,docs/ui-spec.md)。
|
|
24672
24967
|
*
|
|
24673
24968
|
* 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
|
|
24674
24969
|
* 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
|
|
@@ -24698,71 +24993,835 @@ window.__ModuleLoader__.load({
|
|
|
24698
24993
|
return width;
|
|
24699
24994
|
}
|
|
24700
24995
|
//#endregion
|
|
24701
|
-
//#region src/client/trash-
|
|
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
|
+
};
|
|
24702
25014
|
/**
|
|
24703
|
-
*
|
|
24704
|
-
*
|
|
24705
|
-
|
|
24706
|
-
|
|
24707
|
-
|
|
24708
|
-
|
|
24709
|
-
|
|
24710
|
-
|
|
24711
|
-
* `
|
|
24712
|
-
*
|
|
24713
|
-
*
|
|
24714
|
-
*
|
|
24715
|
-
|
|
24716
|
-
|
|
24717
|
-
|
|
24718
|
-
|
|
24719
|
-
|
|
24720
|
-
|
|
24721
|
-
|
|
24722
|
-
|
|
24723
|
-
|
|
24724
|
-
|
|
24725
|
-
|
|
24726
|
-
|
|
24727
|
-
|
|
24728
|
-
|
|
24729
|
-
|
|
24730
|
-
|
|
24731
|
-
|
|
24732
|
-
|
|
24733
|
-
|
|
24734
|
-
|
|
24735
|
-
|
|
24736
|
-
|
|
24737
|
-
|
|
24738
|
-
|
|
24739
|
-
|
|
24740
|
-
|
|
24741
|
-
|
|
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);
|
|
25046
|
+
}
|
|
25047
|
+
//#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
|
+
};
|
|
25058
|
+
/**
|
|
25059
|
+
* 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
|
|
25060
|
+
* 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
|
|
25061
|
+
* 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
|
|
25062
|
+
* 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
|
|
25063
|
+
*/
|
|
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
|
+
});
|
|
25183
|
+
}
|
|
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
|
+
});
|
|
25244
|
+
}
|
|
25245
|
+
//#endregion
|
|
25246
|
+
//#region src/client/use-trash-panel-controller.ts
|
|
25247
|
+
/**
|
|
25248
|
+
* 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻与 Esc 交还焦点、清单换版后的清场、
|
|
25249
|
+
* 宽档自动选中、"别处动过回收站"的失效重读,以及确认后的写动作(恢复/批量/清空)与失败落点。
|
|
25250
|
+
*
|
|
25251
|
+
* 面板接入后,这套语义只住这里;面板自己只留布局与行内动作。搬过来的判据一条不改:
|
|
25252
|
+
*
|
|
25253
|
+
* - **选中从全量条目里找**(`query.entries`,不是键盘序列 `ordered`):把某一组折叠起来只是收掉
|
|
25254
|
+
* 清单那一列,右栏读的那条不该跟着消失。
|
|
25255
|
+
* - **一条只读一次**:面板存活期内按条目 id 缓存成功读数;**失败不缓存**——「重试」与切走再切回
|
|
25256
|
+
* 都会重新读那一条(缓存里存一次失败,界面就再也等不到它自己好了)。
|
|
25257
|
+
* - **清单换版就让位**:选中的 id 不在新清单里 ⇒ 丢它的缓存、清选中、退出下钻。清单读不到
|
|
25258
|
+
* (`entries === null`)**不算"没了"**:那一拍只是不知道,选中 id 留着,详情回 idle。
|
|
25259
|
+
* - **自动选中只认 `selectedId === null`**:同一拍里选中的那条消失时,自动选中看到的是旧选中
|
|
25260
|
+
* (非 null)⇒ 不抢;清场落地后的下一拍才接住新清单的第一条。判据是这道 null 门,不是两个
|
|
25261
|
+
* effect 的声明顺序(顺序反过来也拦得住)。
|
|
25262
|
+
* - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refresh()`,与挂载时同一条路;它不碰
|
|
25263
|
+
* 选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
|
|
25264
|
+
* - **动作成功就丢缓存**:恢复/批量彻底删除成功的条目、清空成功的全部条目,详情缓存必须在同一趟里
|
|
25265
|
+
* 失效——否则这条会话再被删回来时会摆着上一世的正文。失败只按 `actionFailure[id]` 留原文,不碰缓存。
|
|
25266
|
+
* - **Esc 只在 drilled 期间挂 document 监听**:记下 `selectedId` 再退出下钻,渲染落地后把焦点交还
|
|
25267
|
+
* 那一行**一次**;`back()`(「← 返回清单」)只收下钻、不动焦点。控制器自己不查 DOM——焦点动作
|
|
25268
|
+
* 走注入的 `focusRow`。
|
|
25269
|
+
*
|
|
25270
|
+
* **失败原文不翻译**:`detailFailure.message` 与 `actionFailure[id]` 都是 Node 半给的原文
|
|
25271
|
+
* (`callRemote` 的 `failure.message`),原样交给详情块与清单行上屏。
|
|
25272
|
+
*
|
|
25273
|
+
* `busy` 是面板那枚全局闸(写动作在飞)的读数:闸本体也由本 module 导出(`useTrashPanelGate`),
|
|
25274
|
+
* `restore` / `confirmApply` 用同一个 `gate.around` 把「动作 + 清单重读」整个包住;闸开着时谁该
|
|
25275
|
+
* 禁用由面板决定。`kind:'all'` 的清空**不再套一层** `around`——`query.emptyTrash` 自己已经包了
|
|
25276
|
+
* 同一枚闸。
|
|
25277
|
+
*/
|
|
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
|
+
function useTrashPanelController(input) {
|
|
25302
|
+
const { deps, gate, query, ordered, canAutoSelect, focusRow } = input;
|
|
25303
|
+
const { entries, refresh, emptyTrash } = query;
|
|
25304
|
+
const [selectedId, setSelectedId] = (0, react.useState)(null);
|
|
25305
|
+
const [drilled, setDrilled] = (0, react.useState)(false);
|
|
25306
|
+
const [loadingId, setLoadingId] = (0, react.useState)(null);
|
|
25307
|
+
const [detailFailure, setDetailFailure] = (0, react.useState)(null);
|
|
25308
|
+
const [pending, setPending] = (0, react.useState)(null);
|
|
25309
|
+
const [actionFailure, setActionFailure] = (0, react.useState)({});
|
|
25310
|
+
/** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
|
|
25311
|
+
const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
25312
|
+
/** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
|
|
25313
|
+
const refocusRow = (0, react.useRef)(null);
|
|
25314
|
+
const revision = useTrashRevision();
|
|
25315
|
+
(0, react.useEffect)(() => {
|
|
25316
|
+
refresh();
|
|
25317
|
+
}, [refresh, revision]);
|
|
25318
|
+
/**
|
|
25319
|
+
* 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
|
|
25320
|
+
* `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
|
|
25321
|
+
*/
|
|
25322
|
+
const select = (0, react.useCallback)(async (entry, options = {}) => {
|
|
25323
|
+
setSelectedId(entry.id);
|
|
25324
|
+
if (options.drill === true) setDrilled(true);
|
|
25325
|
+
if (detailCache.current.has(entry.id)) {
|
|
25326
|
+
setLoadingId(null);
|
|
25327
|
+
setDetailFailure(null);
|
|
25328
|
+
return;
|
|
25329
|
+
}
|
|
25330
|
+
setLoadingId(entry.id);
|
|
25331
|
+
setDetailFailure(null);
|
|
25332
|
+
const outcome = await callRemote(() => deps.detail(entry.id));
|
|
25333
|
+
setLoadingId((current) => current === entry.id ? null : current);
|
|
25334
|
+
if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
|
|
25335
|
+
else setDetailFailure({
|
|
25336
|
+
entryId: entry.id,
|
|
25337
|
+
message: outcome.failure.message
|
|
25338
|
+
});
|
|
25339
|
+
}, [deps]);
|
|
25340
|
+
(0, react.useEffect)(() => {
|
|
25341
|
+
if (entries === null || selectedId === null) return;
|
|
25342
|
+
if (entries.some((entry) => entry.id === selectedId)) return;
|
|
25343
|
+
detailCache.current.delete(selectedId);
|
|
25344
|
+
setSelectedId(null);
|
|
25345
|
+
setDrilled(false);
|
|
25346
|
+
}, [entries, selectedId]);
|
|
25347
|
+
(0, react.useEffect)(() => {
|
|
25348
|
+
if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
|
|
25349
|
+
}, [
|
|
25350
|
+
canAutoSelect,
|
|
25351
|
+
selectedId,
|
|
25352
|
+
ordered,
|
|
25353
|
+
select
|
|
25354
|
+
]);
|
|
25355
|
+
/**
|
|
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
|
+
* 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:折叠组只是把那一列收掉,
|
|
25379
|
+
* 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
|
|
25380
|
+
*/
|
|
25381
|
+
const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
|
|
25382
|
+
const detail = (0, react.useMemo)(() => {
|
|
25383
|
+
if (selected === null) return { kind: "idle" };
|
|
25384
|
+
if (loadingId === selected.id) return {
|
|
25385
|
+
kind: "loading",
|
|
25386
|
+
entry: selected
|
|
25387
|
+
};
|
|
25388
|
+
const cached = detailCache.current.get(selected.id);
|
|
25389
|
+
if (cached !== void 0) return {
|
|
25390
|
+
kind: "ready",
|
|
25391
|
+
entry: selected,
|
|
25392
|
+
detail: cached
|
|
25393
|
+
};
|
|
25394
|
+
if (detailFailure?.entryId === selected.id) return {
|
|
25395
|
+
kind: "failed",
|
|
25396
|
+
entry: selected,
|
|
25397
|
+
message: detailFailure.message
|
|
25398
|
+
};
|
|
25399
|
+
return {
|
|
25400
|
+
kind: "loading",
|
|
25401
|
+
entry: selected
|
|
25402
|
+
};
|
|
25403
|
+
}, [
|
|
25404
|
+
selected,
|
|
25405
|
+
loadingId,
|
|
25406
|
+
detailFailure
|
|
25407
|
+
]);
|
|
25408
|
+
/** 「重试」只重读当前这一条,不牵清单。 */
|
|
25409
|
+
const retry = (0, react.useCallback)(() => {
|
|
25410
|
+
if (selected !== null) select(selected);
|
|
25411
|
+
}, [selected, select]);
|
|
25412
|
+
/** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(只有 Esc 会交还焦点)。 */
|
|
25413
|
+
const back = (0, react.useCallback)(() => {
|
|
25414
|
+
setDrilled(false);
|
|
25415
|
+
}, []);
|
|
25416
|
+
/** 一次动作的失败原文落回它自己那一行(按条目 id 存,别条目的成败不会串台)。 */
|
|
25417
|
+
const failRow = (0, react.useCallback)((entryId, message) => {
|
|
25418
|
+
setActionFailure((current) => ({
|
|
25419
|
+
...current,
|
|
25420
|
+
[entryId]: message
|
|
25421
|
+
}));
|
|
25422
|
+
}, []);
|
|
25423
|
+
/**
|
|
25424
|
+
* 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把手势包进 `around`。
|
|
25425
|
+
* 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
|
|
25426
|
+
* `refresh` 留在 `around` 里:否则旧清单还在屏上,同一个动作能被再点一次。
|
|
25427
|
+
*/
|
|
25428
|
+
const restore = (0, react.useCallback)(async (entry) => {
|
|
25429
|
+
setActionFailure((current) => {
|
|
25430
|
+
const next = { ...current };
|
|
25431
|
+
delete next[entry.id];
|
|
25432
|
+
return next;
|
|
25433
|
+
});
|
|
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
|
+
});
|
|
25443
|
+
}, [
|
|
25444
|
+
deps,
|
|
25445
|
+
failRow,
|
|
25446
|
+
gate,
|
|
25447
|
+
refresh
|
|
25448
|
+
]);
|
|
25449
|
+
/**
|
|
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
|
+
* 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
|
|
25477
|
+
* `ApplyOutcome` 里 ⇒ 不再"每条一次全量重读"。顶层失败(路由自检 / 通道断)是**这一批一条都没落地**
|
|
25478
|
+
* ——原文要落到每一个 id 上,别让清单看起来"删成功了"。
|
|
25479
|
+
*/
|
|
25480
|
+
const confirmApply = (0, react.useCallback)(async (action, ids) => {
|
|
25481
|
+
await gate.around(async () => {
|
|
25482
|
+
const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
|
|
25483
|
+
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();
|
|
25491
|
+
});
|
|
25492
|
+
}, [
|
|
25493
|
+
deps,
|
|
25494
|
+
gate,
|
|
25495
|
+
refresh,
|
|
25496
|
+
settleOutcomes
|
|
25497
|
+
]);
|
|
25498
|
+
/**
|
|
25499
|
+
* 确认框上的「确认」按 `pending` 的形状分派:三条按条目标都委托 `confirmApply`(一段 `/apply`);
|
|
25500
|
+
* 清空是**聚合回执**、形状不同,走 `query.emptyTrash`——它自己已经包了同一枚闸与重读,这里
|
|
25501
|
+
* **不再套第二层 `around`**(双套会让外层 busy 提前落下)。
|
|
25502
|
+
*/
|
|
25503
|
+
const confirmPurge = (0, react.useCallback)(async () => {
|
|
25504
|
+
const target = pending;
|
|
25505
|
+
if (target === null) return;
|
|
25506
|
+
if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
|
|
25507
|
+
if (target.kind === "entries") return confirmApply("purge", target.ids);
|
|
25508
|
+
if (target.kind === "restore") return confirmApply("restore", target.ids);
|
|
25509
|
+
const emptied = await emptyTrash();
|
|
25510
|
+
setPending(null);
|
|
25511
|
+
if (emptied) detailCache.current.clear();
|
|
25512
|
+
}, [
|
|
25513
|
+
confirmApply,
|
|
25514
|
+
emptyTrash,
|
|
25515
|
+
pending
|
|
25516
|
+
]);
|
|
25517
|
+
return {
|
|
25518
|
+
busy: gate.busy,
|
|
25519
|
+
selected,
|
|
25520
|
+
selectedId,
|
|
25521
|
+
drilled,
|
|
25522
|
+
detail,
|
|
25523
|
+
pending,
|
|
25524
|
+
actionFailure,
|
|
25525
|
+
select,
|
|
25526
|
+
retry,
|
|
25527
|
+
back,
|
|
25528
|
+
setPending,
|
|
25529
|
+
restore,
|
|
25530
|
+
confirmPurge,
|
|
25531
|
+
confirmApply
|
|
25532
|
+
};
|
|
25533
|
+
}
|
|
25534
|
+
//#endregion
|
|
25535
|
+
//#region src/client/use-trash-query.ts
|
|
25536
|
+
/**
|
|
25537
|
+
* 清册读数与无主子会话的**取数编排**。
|
|
25538
|
+
*
|
|
25539
|
+
* 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
|
|
25540
|
+
* ① **到达序号**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
|
|
25541
|
+
* 后到的旧响应不许盖掉刚发生的动作带来的新状态;
|
|
25542
|
+
* ② **先清单后扫描**:`GET /orphans` 判"父没了"吃的是清册,而清册要由 `/list` 那次对账才与
|
|
25543
|
+
* `session-trash/` 的目录持平——反过来接,刚被手工搬进回收站的父会被报成失踪父;
|
|
25544
|
+
* ③ **读不到就说读不到**:`/orphans` 回 `sessiondelete/io` 是一次**失败的检查**,不许折成"零条残留"。
|
|
25545
|
+
*
|
|
25546
|
+
* 清理(`/apply` 的 `orphan-clean`)也住这里:它与扫描共用同一份报告与同一组提示,"读"与"写"分家
|
|
25547
|
+
* 会让 `busy` 与重读顺序那条判据劈成两半。`busy` 本身不归它管——那是面板的一枚全局闸,通过
|
|
25548
|
+
* `around` 传进来(`around` 里跑的就是"这一趟还算忙")。
|
|
25549
|
+
*/
|
|
25550
|
+
function useTrashQuery({ deps, t, around }) {
|
|
25551
|
+
const [entries, setEntries] = (0, react.useState)(null);
|
|
25552
|
+
const [listFailure, setListFailure] = (0, react.useState)(null);
|
|
25553
|
+
const [orphanReport, setOrphanReport] = (0, react.useState)(null);
|
|
25554
|
+
const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
|
|
25555
|
+
const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
|
|
25556
|
+
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;
|
|
25560
|
+
const outcome = await callRemote(() => deps.list());
|
|
25561
|
+
if (seq !== reloadSeq.current) return false;
|
|
25562
|
+
if (!outcome.ok) {
|
|
25563
|
+
setListFailure(outcome.failure.message);
|
|
25564
|
+
setEntries(null);
|
|
25565
|
+
return false;
|
|
25566
|
+
}
|
|
25567
|
+
setListFailure(null);
|
|
25568
|
+
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;
|
|
25574
|
+
const outcome = await callRemote(() => deps.scanOrphans());
|
|
25575
|
+
if (seq !== orphanScanSeq.current) return;
|
|
25576
|
+
if (!outcome.ok) {
|
|
25577
|
+
setOrphanReport(null);
|
|
25578
|
+
setOrphanNotice(null);
|
|
25579
|
+
setOrphanConfirm(false);
|
|
25580
|
+
setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
|
|
25581
|
+
return;
|
|
25582
|
+
}
|
|
25583
|
+
setOrphanReport(outcome.value);
|
|
25584
|
+
setOrphanFailure(null);
|
|
25585
|
+
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);
|
|
25593
|
+
return;
|
|
25594
|
+
}
|
|
25595
|
+
await scanOrphans();
|
|
25596
|
+
}, [reload, scanOrphans]);
|
|
25597
|
+
const emptyTrash = (0, react.useCallback)(() => around(async () => {
|
|
25598
|
+
const outcome = await callRemote(() => deps.emptyTrash());
|
|
25599
|
+
if (!outcome.ok) {
|
|
25600
|
+
setListFailure(outcome.failure.message);
|
|
25601
|
+
return false;
|
|
25602
|
+
}
|
|
25603
|
+
await refresh();
|
|
25604
|
+
return true;
|
|
25605
|
+
}), [
|
|
25606
|
+
around,
|
|
25607
|
+
deps,
|
|
25608
|
+
refresh
|
|
25609
|
+
]);
|
|
25610
|
+
return {
|
|
25611
|
+
entries,
|
|
25612
|
+
listFailure,
|
|
25613
|
+
orphanReport,
|
|
25614
|
+
orphanFailure,
|
|
25615
|
+
orphanNotice,
|
|
25616
|
+
orphanConfirm,
|
|
25617
|
+
setOrphanConfirm,
|
|
25618
|
+
openOrphanPreview: () => {
|
|
25619
|
+
setOrphanFailure(null);
|
|
25620
|
+
setOrphanNotice(null);
|
|
25621
|
+
setOrphanConfirm(true);
|
|
25622
|
+
},
|
|
25623
|
+
reload,
|
|
25624
|
+
scanOrphans,
|
|
25625
|
+
refresh,
|
|
25626
|
+
cleanOrphans: (0, react.useCallback)(() => {
|
|
25627
|
+
if (orphanReport === null || orphanReport.roots.length === 0) {
|
|
25628
|
+
setOrphanConfirm(false);
|
|
25629
|
+
return Promise.resolve();
|
|
25630
|
+
}
|
|
25631
|
+
return around(async () => {
|
|
25632
|
+
setOrphanFailure(null);
|
|
25633
|
+
setOrphanNotice(null);
|
|
25634
|
+
const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", orphanReport.roots));
|
|
25635
|
+
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
|
+
}
|
|
25659
|
+
});
|
|
25660
|
+
}, [
|
|
25661
|
+
around,
|
|
25662
|
+
deps,
|
|
25663
|
+
orphanReport,
|
|
25664
|
+
refresh,
|
|
25665
|
+
t
|
|
25666
|
+
]),
|
|
25667
|
+
emptyTrash
|
|
25668
|
+
};
|
|
25669
|
+
}
|
|
25670
|
+
/** 失败 `details` 里的非负数字(`orphan-clean` 的部分回执 `removed` / `freedBytes`);其余一律当 0。 */
|
|
25671
|
+
function detailNumber(details, key) {
|
|
25672
|
+
const value = details?.[key];
|
|
25673
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
25674
|
+
}
|
|
25675
|
+
//#endregion
|
|
25676
|
+
//#region src/client/use-trash-selection.ts
|
|
25677
|
+
/**
|
|
25678
|
+
* 清单上的**选择面**:多选态、折叠起来的组、勾选集合,
|
|
25679
|
+
* 以及由它们派生的三样只读(`checkedIds` / `ordered` / 两种三态判定)。
|
|
25680
|
+
*
|
|
25681
|
+
* 从 `trash-panel.tsx` 的函数体里整块搬出来的两条承重规矩:
|
|
25682
|
+
* - **勾选顺序 = 清单从上往下**(组间按各组最新一条、组内按删除时间),不是点击顺序:页脚两颗按
|
|
25683
|
+
* **屏幕上的顺序**执行,否则"我先后点了哪几条"会改变一批动作的执行次序读感。折叠组里的条目
|
|
25684
|
+
* 照样算在内——折叠只是不收它们进 DOM。
|
|
25685
|
+
* - **清单换了就把已经不存在的勾选剔掉**:别处(侧栏菜单、撤销提示)动过回收站时,留着一条
|
|
25686
|
+
* "看不见的勾选"会让页脚那颗带着它去删一条已经不在的东西。
|
|
25687
|
+
*
|
|
25688
|
+
* 组装这件事不在这里:`groups` 由面板(或它的取数一侧)算好再传进来,这个 module 只回答
|
|
25689
|
+
* "哪些被勾了、屏幕上从上到下是哪些行"。
|
|
25690
|
+
*/
|
|
25691
|
+
function useTrashSelection({ entries, groups }) {
|
|
25692
|
+
const [selectMode, setSelectMode] = (0, react.useState)(false);
|
|
25693
|
+
const [checked, setChecked] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
25694
|
+
/**
|
|
25695
|
+
* 折叠起来的组(键 = `projectDir`,也就是组头那一份身份)。**只活在面板这次打开**:
|
|
25696
|
+
* 这是一层视图偏好,为它开一条 Node 半的读写通道不值,而宿主 webview 的 origin 是不透明的
|
|
25697
|
+
* `null`(AGENTS.md 实测),`localStorage` 那条路在真机上大概率直接抛。
|
|
25698
|
+
*/
|
|
25699
|
+
const [collapsedGroups, setCollapsedGroups] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
25700
|
+
(0, react.useEffect)(() => {
|
|
25701
|
+
if (entries === null) return;
|
|
25702
|
+
setChecked((current) => {
|
|
25703
|
+
if (current.size === 0) return current;
|
|
25704
|
+
const alive = new Set(entries.map((entry) => entry.id));
|
|
25705
|
+
const next = new Set([...current].filter((id) => alive.has(id)));
|
|
25706
|
+
return next.size === current.size ? current : next;
|
|
25707
|
+
});
|
|
25708
|
+
}, [entries]);
|
|
25709
|
+
const checkedIds = (0, react.useMemo)(() => groups.flatMap((group) => group.entries).filter((entry) => checked.has(entry.id)).map((entry) => entry.id), [groups, checked]);
|
|
25710
|
+
const isCollapsed = (projectDir) => collapsedGroups.has(projectDir);
|
|
25711
|
+
const allCollapsed = groups.length > 0 && groups.every((group) => isCollapsed(group.projectDir));
|
|
25712
|
+
const ordered = (0, react.useMemo)(() => groups.filter((group) => !isCollapsed(group.projectDir)).flatMap((group) => group.entries), [groups, collapsedGroups]);
|
|
25713
|
+
const groupFullyChecked = (group) => group.entries.length > 0 && group.entries.every((entry) => checked.has(entry.id));
|
|
25714
|
+
const groupPartiallyChecked = (group) => !groupFullyChecked(group) && group.entries.some((entry) => checked.has(entry.id));
|
|
25715
|
+
return {
|
|
25716
|
+
selectMode,
|
|
25717
|
+
checked,
|
|
25718
|
+
checkedIds,
|
|
25719
|
+
ordered,
|
|
25720
|
+
allCollapsed,
|
|
25721
|
+
isCollapsed,
|
|
25722
|
+
enterSelect: () => {
|
|
25723
|
+
setSelectMode(true);
|
|
25724
|
+
},
|
|
25725
|
+
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
25726
|
+
exitSelect: () => {
|
|
25727
|
+
setSelectMode(false);
|
|
25728
|
+
setChecked(/* @__PURE__ */ new Set());
|
|
25729
|
+
},
|
|
25730
|
+
toggleGroup: (projectDir) => {
|
|
25731
|
+
setCollapsedGroups((current) => {
|
|
25732
|
+
const next = new Set(current);
|
|
25733
|
+
if (next.delete(projectDir)) return next;
|
|
25734
|
+
next.add(projectDir);
|
|
25735
|
+
return next;
|
|
25736
|
+
});
|
|
25737
|
+
},
|
|
25738
|
+
toggleAllGroups: () => {
|
|
25739
|
+
setCollapsedGroups(allCollapsed ? /* @__PURE__ */ new Set() : new Set(groups.map((group) => group.projectDir)));
|
|
25740
|
+
},
|
|
25741
|
+
toggleCheck: (entryId) => {
|
|
25742
|
+
setChecked((current) => {
|
|
25743
|
+
const next = new Set(current);
|
|
25744
|
+
if (next.delete(entryId)) return next;
|
|
25745
|
+
next.add(entryId);
|
|
25746
|
+
return next;
|
|
25747
|
+
});
|
|
25748
|
+
},
|
|
25749
|
+
toggleGroupCheck: (group) => {
|
|
25750
|
+
const ids = group.entries.map((entry) => entry.id);
|
|
25751
|
+
const clear = groupFullyChecked(group);
|
|
25752
|
+
setChecked((current) => {
|
|
25753
|
+
const next = new Set(current);
|
|
25754
|
+
for (const id of ids) if (clear) next.delete(id);
|
|
25755
|
+
else next.add(id);
|
|
25756
|
+
return next;
|
|
25757
|
+
});
|
|
25758
|
+
},
|
|
25759
|
+
groupFullyChecked,
|
|
25760
|
+
groupPartiallyChecked
|
|
25761
|
+
};
|
|
25762
|
+
}
|
|
25763
|
+
//#endregion
|
|
25764
|
+
//#region src/client/trash-panel.tsx
|
|
25765
|
+
/**
|
|
25766
|
+
* 回收站清单面板(`docs/ui-spec.md` 的状态 ④⑤ + 两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
|
|
25767
|
+
*
|
|
25768
|
+
* 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
|
|
25769
|
+
* (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
|
|
25770
|
+
* `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
|
|
25771
|
+
* 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
|
|
25772
|
+
*
|
|
25773
|
+
* **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(现行界面规格住
|
|
25774
|
+
* `docs/ui-spec.md`,本文件只记这三条与实现约束):
|
|
25775
|
+
* ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
|
|
25776
|
+
* 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
|
|
25777
|
+
* ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
|
|
25778
|
+
* window.resize 会漏**——宿主侧栏折叠/展开也改中央列宽,而且量的是面板自己那块的 **border-box 宽**。
|
|
25779
|
+
* 窄于此走下钻(详情铺满 + 返回),因为 2026-10-01 真机在 520px 窗量过中央列只有 405px,两栏在那
|
|
25780
|
+
* 儿两栏都读不了;900 这个数是按两栏各自要读得开算出来的(见 `SPLIT_MIN_WIDTH` 处);
|
|
25781
|
+
* ③ **动作留在清单行右侧**(2026-10-15 试过"退出行、住进详情",按真机反馈回到 2026-10-01 的形状):
|
|
25782
|
+
* 那一版左栏为了只放标题被压到 316px,用户第一眼就是"左侧太窄"。详情只负责看,失败原文也跟着
|
|
25783
|
+
* 动作留在它自己那一行。
|
|
25784
|
+
*
|
|
25785
|
+
* **卡片配色沿用 2026-10-01 那批**(宿主设置面卡片的底层 token,不是随包发布的 settings 别名):
|
|
25786
|
+
* `background:var(--dsw-alias-bg-layer-2)` + `border:.5px solid var(--dsw-alias-border-l4)` +
|
|
25787
|
+
* `border-radius:var(--dsw-radius-lg)`(20px 的 `xl` 会把 60px 上下的行吹成胶囊)。
|
|
25788
|
+
* 选中态换一枚 token(2026-10-15 从 asar 的 `dsh-client-ui-theme/lib/client.js` 逐字读出):
|
|
25789
|
+
* `--dsw-alias-button-ghost-active-fill`(浅 `#ebeef2` / 深 `#43454a`)配
|
|
25790
|
+
* `--dsw-alias-button-ghost-active-border`(`#979da6` / `#81858c`)——宿主自己侧栏选中项用的
|
|
25791
|
+
* `--dsw-specific-sidebar-nav-item-active` 与前者取值逐字相同,所以这是宿主"选中"那一档,不是我挑的。
|
|
25792
|
+
*
|
|
25793
|
+
* 时间分桶走 `relativeTime`(逐字抄自官方,见 `ui/format.ts`),项目名从宿主 `projectKey` 反推
|
|
25794
|
+
* (`unescapeProjectKey`,两半共用),组头 `title` 给完整原串。**行分隔**交给卡片自己的边框。
|
|
25795
|
+
*
|
|
25796
|
+
* 三条界面规则没有因为改版让路:
|
|
25797
|
+
* - **空态**:`回收站是空的`,且底部**不出现**「清空回收站…」(没有东西可清)。
|
|
25798
|
+
* - **失败写在出事的那一块上**:清单读不到 ⇒ 单独一块摆原文 + 重试(不是空态,"读不到"与"空的"是
|
|
25799
|
+
* 两件事);某条会话的正文读不到 ⇒ 只在详情那块说,清单照常可选;恢复/彻底删除失败 ⇒ 原因留在
|
|
25800
|
+
* **清单那一行**(按条目 id 存,切走再回来还在);无主子会话的检查/清理读不到 ⇒ 面板顶端那块摆原文 + 重试
|
|
25801
|
+
* (那是"没能回答有多少残留",与"没有残留"是两件事,不许折成空报告);清理发现"这一组已经不在了" ⇒
|
|
25802
|
+
* 单独一条中性提示,不计入失败。这些失败块都标 `role="status"`——但**插入即播报并不保证**
|
|
25803
|
+
* (`aria-live` 看的是"已在场的区域发生了变更"),要稳得常驻一个只给读屏看的播报台,本机没有 AT
|
|
25804
|
+
* 可验,所以留在这里当已知边界,不写成已经做到。
|
|
25805
|
+
* - **危险层级**:整页只有一块实心红(页脚「清空回收站…」,确认框里那块是它的后续);行与详情里的
|
|
25806
|
+
* 「彻底删除…」是**安静的红**——只写 `color:--dsw-alias-state-error-primary`、**不写 background**
|
|
24742
25807
|
* (作者态内联 `background` 永远赢过类规则,而 `ui.css` 里 `.sd-button-ghost:hover` / `:active` 都是
|
|
24743
25808
|
* 普通类 ⇒ 铺了底的动作在点错之前不给任何视觉回应)。批量与确认框那款实心红的写法(`variant="primary"`
|
|
24744
25809
|
* + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
|
|
24745
25810
|
*
|
|
24746
25811
|
* 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
|
|
24747
|
-
*
|
|
25812
|
+
* 结构与层级归 docs/ui-spec.md,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
|
|
24748
25813
|
* 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
|
|
24749
25814
|
*
|
|
24750
25815
|
* **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
|
|
24751
25816
|
* ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
|
|
24752
|
-
* (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a
|
|
24753
|
-
|
|
24754
|
-
/**
|
|
24755
|
-
* 整页那唯一一块实心红:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
|
|
24756
|
-
* 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
|
|
25817
|
+
* (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`,2026-10-01/10-15 真机量的);那两档取自同一个
|
|
25818
|
+
* token,来源表见 `src/client/ui/ui.css` 文件头。
|
|
24757
25819
|
*/
|
|
24758
|
-
|
|
24759
|
-
|
|
24760
|
-
|
|
24761
|
-
};
|
|
24762
|
-
/** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。 */
|
|
24763
|
-
const TRASH_DIR = "session-trash/";
|
|
25820
|
+
/** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。
|
|
25821
|
+
* 字面量住 `shared/wire.ts`(`TRASH_DIR_LABEL`)——Node 半拼的就是同一个名字(ADR-0003)。 */
|
|
25822
|
+
const TRASH_DIR = TRASH_DIR_LABEL;
|
|
24764
25823
|
/**
|
|
24765
|
-
* 断点:面板盒子宽 ≥
|
|
25824
|
+
* 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(docs/ui-spec.md,2026-10-15 真机后定值)。
|
|
24766
25825
|
*
|
|
24767
25826
|
* 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
|
|
24768
25827
|
* 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
|
|
@@ -24872,23 +25931,6 @@ window.__ModuleLoader__.load({
|
|
|
24872
25931
|
width: "100%",
|
|
24873
25932
|
paddingBottom: 8
|
|
24874
25933
|
};
|
|
24875
|
-
/** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
|
|
24876
|
-
const CARD = {
|
|
24877
|
-
display: "flex",
|
|
24878
|
-
alignItems: "center",
|
|
24879
|
-
gap: 14,
|
|
24880
|
-
minWidth: 0,
|
|
24881
|
-
padding: "9px 14px",
|
|
24882
|
-
background: "var(--dsw-alias-bg-layer-2)",
|
|
24883
|
-
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
24884
|
-
borderRadius: "var(--dsw-radius-lg)"
|
|
24885
|
-
};
|
|
24886
|
-
/** 选中行:底色与描边都换成宿主"选中"那一档(见头注那两枚 token 与实测取值)。 */
|
|
24887
|
-
const CARD_SELECTED = {
|
|
24888
|
-
...CARD,
|
|
24889
|
-
background: "var(--dsw-alias-button-ghost-active-fill)",
|
|
24890
|
-
border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
|
|
24891
|
-
};
|
|
24892
25934
|
/**
|
|
24893
25935
|
* 行那句「删除于 3小时前」:整句模板住在字典里(两种语言各自钉语序),单位词与绝对串走 `copy.ts`
|
|
24894
25936
|
* 那一处——详情那格要的是同一份,别抄成两本账。`now` 桶不套模板:"删除于 刚刚前"不是话。
|
|
@@ -24900,69 +25942,21 @@ window.__ModuleLoader__.load({
|
|
|
24900
25942
|
unit: unitWord(unit, t)
|
|
24901
25943
|
});
|
|
24902
25944
|
}
|
|
24903
|
-
/**
|
|
24904
|
-
* 宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、拆不回真实路径;
|
|
24905
|
-
* 但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。摘掉 `--…--` 裹边、还原这些
|
|
24906
|
-
* 转义,剩下的原样摆出来,完整串交给组头 `title`。字母表只有一份:还原走 `shared/wire.ts`。
|
|
24907
|
-
*/
|
|
24908
|
-
function projectLabel(projectDir, t) {
|
|
24909
|
-
const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
|
|
24910
|
-
return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
|
|
24911
|
-
}
|
|
24912
|
-
/** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
|
|
24913
|
-
function groupByProject(entries, t) {
|
|
24914
|
-
const byProject = /* @__PURE__ */ new Map();
|
|
24915
|
-
for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
|
|
24916
|
-
const list = byProject.get(entry.projectDir);
|
|
24917
|
-
if (list === void 0) byProject.set(entry.projectDir, [entry]);
|
|
24918
|
-
else list.push(entry);
|
|
24919
|
-
}
|
|
24920
|
-
return [...byProject].map(([projectDir, list]) => ({
|
|
24921
|
-
projectDir,
|
|
24922
|
-
label: projectLabel(projectDir, t),
|
|
24923
|
-
latest: list[0].deletedAt,
|
|
24924
|
-
entries: list
|
|
24925
|
-
})).sort((a, b) => b.latest - a.latest);
|
|
24926
|
-
}
|
|
24927
|
-
/**
|
|
24928
|
-
* 「这一次彻底删除会释放多少」:那一格连着**折进来的孩子**一起算——彻底删除按组走(10-15 稿 §11),
|
|
24929
|
-
* 点下去页脚合计掉的就是整组。页脚合计与单条确认卡片共用这一个算式,同一屏不许有两套口径(issue #2)。
|
|
24930
|
-
*/
|
|
24931
|
-
function releaseBytesOf(entry) {
|
|
24932
|
-
return entry.sizeBytes + (entry.subagentBytes ?? 0);
|
|
24933
|
-
}
|
|
24934
25945
|
function TrashPanel({ deps, t = zhTranslate }) {
|
|
24935
|
-
|
|
24936
|
-
const
|
|
24937
|
-
|
|
24938
|
-
|
|
24939
|
-
|
|
24940
|
-
|
|
24941
|
-
|
|
24942
|
-
const
|
|
24943
|
-
const [detailFailure, setDetailFailure] = (0, react.useState)(null);
|
|
24944
|
-
const [drilled, setDrilled] = (0, react.useState)(false);
|
|
24945
|
-
/**
|
|
24946
|
-
* 折叠起来的组(键 = `projectDir`,也就是组头那一份身份)。**只活在面板这次打开**:
|
|
24947
|
-
* 这是一层视图偏好,为它开一条 Node 半的读写通道不值,而宿主 webview 的 origin 是不透明的
|
|
24948
|
-
* `null`(AGENTS.md 实测),`localStorage` 那条路在真机上大概率直接抛。
|
|
24949
|
-
*/
|
|
24950
|
-
const [collapsedGroups, setCollapsedGroups] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
24951
|
-
/**
|
|
24952
|
-
* 多选态与勾选集合(2026-10-15 开的口子先只配了**批量彻底删除**;2026-10-17 补上**批量恢复**)。
|
|
24953
|
-
* 勾选住 `Set<entryId>`,渲染顺序不跟点击顺序——页脚两颗按**清单从上往下**执行,
|
|
24954
|
-
* "我先后点了哪几条"不该影响一批动作的执行顺序读感。
|
|
24955
|
-
*/
|
|
24956
|
-
const [selectMode, setSelectMode] = (0, react.useState)(false);
|
|
24957
|
-
const [checked, setChecked] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
24958
|
-
/** 面板存活期内每条只读一次(本机量级:最大一条 135ms,125 条全读 2.0s ⇒ 不预读、也不重复读)。 */
|
|
24959
|
-
const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
25946
|
+
/** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住控制器 module,查询侧的写动作也吃它。 */
|
|
25947
|
+
const gate = useTrashPanelGate();
|
|
25948
|
+
const query = useTrashQuery({
|
|
25949
|
+
deps,
|
|
25950
|
+
t,
|
|
25951
|
+
around: gate.around
|
|
25952
|
+
});
|
|
25953
|
+
const { entries, listFailure, orphanReport, orphanFailure, orphanNotice, orphanConfirm, setOrphanConfirm, refresh } = query;
|
|
24960
25954
|
const listRef = (0, react.useRef)(null);
|
|
24961
25955
|
const rootRef = (0, react.useRef)(null);
|
|
24962
25956
|
const width = usePanelWidth(rootRef);
|
|
24963
25957
|
/**
|
|
24964
25958
|
* 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
|
|
24965
|
-
*
|
|
25959
|
+
* 可选项也没有动作,白占一半宽度(docs/ui-spec.md)。失败那块自己的「重试」
|
|
24966
25960
|
* 负责把清单读回来,读回来之后右栏跟着回来。
|
|
24967
25961
|
*/
|
|
24968
25962
|
const split = width >= SPLIT_MIN_WIDTH && entries !== null && entries.length > 0;
|
|
@@ -24975,241 +25969,54 @@ window.__ModuleLoader__.load({
|
|
|
24975
25969
|
step: HANDLE_STEP,
|
|
24976
25970
|
label: t("trash.divider.label")
|
|
24977
25971
|
});
|
|
24978
|
-
const revision = useTrashRevision();
|
|
24979
|
-
/**
|
|
24980
|
-
* 重读带**到达序号**:在飞的 `list` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
|
|
24981
|
-
* 后到的旧响应不许盖掉刚发生的动作带来的新状态。只应用序号最大的一份。
|
|
24982
|
-
*/
|
|
24983
|
-
const reloadSeq = (0, react.useRef)(0);
|
|
24984
|
-
const reload = (0, react.useCallback)(async () => {
|
|
24985
|
-
const seq = ++reloadSeq.current;
|
|
24986
|
-
const outcome = await callRemote(() => deps.list());
|
|
24987
|
-
if (seq !== reloadSeq.current) return;
|
|
24988
|
-
if (!outcome.ok) {
|
|
24989
|
-
setListFailure(outcome.failure.message);
|
|
24990
|
-
setEntries(null);
|
|
24991
|
-
return;
|
|
24992
|
-
}
|
|
24993
|
-
setListFailure(null);
|
|
24994
|
-
setEntries(outcome.value);
|
|
24995
|
-
setChecked((current) => {
|
|
24996
|
-
if (current.size === 0) return current;
|
|
24997
|
-
const alive = new Set(outcome.value.map((entry) => entry.id));
|
|
24998
|
-
const next = new Set([...current].filter((id) => alive.has(id)));
|
|
24999
|
-
return next.size === current.size ? current : next;
|
|
25000
|
-
});
|
|
25001
|
-
}, [deps]);
|
|
25002
|
-
(0, react.useEffect)(() => {
|
|
25003
|
-
reload();
|
|
25004
|
-
}, [reload, revision]);
|
|
25005
|
-
/**
|
|
25006
|
-
* 选一条:命中缓存就直接摆,没命中才去读那条会话自己的日志。
|
|
25007
|
-
* `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
|
|
25008
|
-
*/
|
|
25009
|
-
const select = (0, react.useCallback)(async (entry, options = {}) => {
|
|
25010
|
-
setSelectedId(entry.id);
|
|
25011
|
-
if (options.drill === true) setDrilled(true);
|
|
25012
|
-
if (detailCache.current.has(entry.id)) {
|
|
25013
|
-
setLoadingId(null);
|
|
25014
|
-
setDetailFailure(null);
|
|
25015
|
-
return;
|
|
25016
|
-
}
|
|
25017
|
-
setLoadingId(entry.id);
|
|
25018
|
-
setDetailFailure(null);
|
|
25019
|
-
const outcome = await callRemote(() => deps.detail(entry.id));
|
|
25020
|
-
setLoadingId((current) => current === entry.id ? null : current);
|
|
25021
|
-
if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
|
|
25022
|
-
else setDetailFailure({
|
|
25023
|
-
entryId: entry.id,
|
|
25024
|
-
message: outcome.failure.message
|
|
25025
|
-
});
|
|
25026
|
-
}, [deps]);
|
|
25027
25972
|
const groups = (0, react.useMemo)(() => groupByProject(entries ?? [], t), [entries, t]);
|
|
25028
|
-
const isCollapsed
|
|
25029
|
-
|
|
25030
|
-
|
|
25031
|
-
|
|
25032
|
-
|
|
25033
|
-
next.add(projectDir);
|
|
25034
|
-
return next;
|
|
25035
|
-
});
|
|
25036
|
-
};
|
|
25037
|
-
const allCollapsed = groups.length > 0 && groups.every((group) => isCollapsed(group.projectDir));
|
|
25038
|
-
const toggleAllGroups = () => {
|
|
25039
|
-
setCollapsedGroups(allCollapsed ? /* @__PURE__ */ new Set() : new Set(groups.map((group) => group.projectDir)));
|
|
25040
|
-
};
|
|
25041
|
-
/** 按**清单从上往下**排好的勾选 id(页脚两颗与批量执行都读这一份)。
|
|
25042
|
-
* 顺序必须走 `groups`(组间按各组最新一条、组内按删除时间),不能用扁平 `entries`:后者是
|
|
25043
|
-
* 全局 `deletedAt` 序,跨项目多选时与屏幕上的分组顺序不同。折叠组里的条目照样算在内。 */
|
|
25044
|
-
const checkedIds = groups.flatMap((group) => group.entries).filter((entry) => checked.has(entry.id)).map((entry) => entry.id);
|
|
25045
|
-
const toggleCheck = (entryId) => {
|
|
25046
|
-
setChecked((current) => {
|
|
25047
|
-
const next = new Set(current);
|
|
25048
|
-
if (next.delete(entryId)) return next;
|
|
25049
|
-
next.add(entryId);
|
|
25050
|
-
return next;
|
|
25051
|
-
});
|
|
25052
|
-
};
|
|
25053
|
-
const exitSelect = () => {
|
|
25054
|
-
setSelectMode(false);
|
|
25055
|
-
setChecked(/* @__PURE__ */ new Set());
|
|
25056
|
-
};
|
|
25057
|
-
/**
|
|
25058
|
-
* 组头那颗勾选框:说的是"**这个项目的全部会话**",所以判据走 `group.entries`——**折叠起来的条目
|
|
25059
|
-
* 也算在内**(折叠只是不收它们进 DOM 而已,不是它们不存在)。三态里"半选"用原生
|
|
25060
|
-
* `indeterminate`,勾满再点 = 整组取消,而不是取消到半选。
|
|
25061
|
-
*/
|
|
25062
|
-
const groupFullyChecked = (group) => group.entries.length > 0 && group.entries.every((entry) => checked.has(entry.id));
|
|
25063
|
-
const groupPartiallyChecked = (group) => !groupFullyChecked(group) && group.entries.some((entry) => checked.has(entry.id));
|
|
25064
|
-
const toggleGroupCheck = (group) => {
|
|
25065
|
-
const ids = group.entries.map((entry) => entry.id);
|
|
25066
|
-
const clear = groupFullyChecked(group);
|
|
25067
|
-
setChecked((current) => {
|
|
25068
|
-
const next = new Set(current);
|
|
25069
|
-
for (const id of ids) if (clear) next.delete(id);
|
|
25070
|
-
else next.add(id);
|
|
25071
|
-
return next;
|
|
25072
|
-
});
|
|
25073
|
-
};
|
|
25074
|
-
/**
|
|
25075
|
-
* 键盘 ↑↓ 走的是这个顺序(分组只是视觉上的分段,别让它把跨组的相邻关系改乱),
|
|
25076
|
-
* 且**只走展开的组**:折叠起来的行不在 DOM 里,让方向键停在一条看不见的行上,
|
|
25077
|
-
* 就是"按了有反应、屏幕上什么都没发生"。
|
|
25078
|
-
*/
|
|
25079
|
-
const ordered = (0, react.useMemo)(() => groups.filter((group) => !isCollapsed(group.projectDir)).flatMap((group) => group.entries), [groups, collapsedGroups]);
|
|
25080
|
-
(0, react.useEffect)(() => {
|
|
25081
|
-
if (entries === null || selectedId === null) return;
|
|
25082
|
-
if (entries.some((entry) => entry.id === selectedId)) return;
|
|
25083
|
-
detailCache.current.delete(selectedId);
|
|
25084
|
-
setSelectedId(null);
|
|
25085
|
-
setDrilled(false);
|
|
25086
|
-
}, [entries, selectedId]);
|
|
25087
|
-
(0, react.useEffect)(() => {
|
|
25088
|
-
if (split && selectedId === null && ordered.length > 0) select(ordered[0]);
|
|
25089
|
-
}, [
|
|
25090
|
-
split,
|
|
25091
|
-
selectedId,
|
|
25092
|
-
ordered,
|
|
25093
|
-
select
|
|
25094
|
-
]);
|
|
25973
|
+
const { selectMode, checked, checkedIds, ordered, allCollapsed, isCollapsed, enterSelect, exitSelect, toggleGroup, toggleAllGroups, toggleCheck, toggleGroupCheck, groupFullyChecked, groupPartiallyChecked } = useTrashSelection({
|
|
25974
|
+
entries,
|
|
25975
|
+
groups
|
|
25976
|
+
});
|
|
25977
|
+
/** 行的 DOM 查询留给面板;控制器只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
|
|
25095
25978
|
const focusRow = (0, react.useCallback)((entryId) => {
|
|
25096
25979
|
(listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
|
|
25097
25980
|
}, []);
|
|
25098
25981
|
/**
|
|
25099
|
-
*
|
|
25100
|
-
*
|
|
25101
|
-
*
|
|
25102
|
-
* 永远收不到的死路径。退回后焦点交还给选中的那一行(行还没进 DOM,所以记一笔等这一拍渲染完再做)。
|
|
25982
|
+
* 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / Esc 交还焦点 / 写动作与失败落点,整块住在
|
|
25983
|
+
* `use-trash-panel-controller.ts`。布局常量不搬进控制器:面板只交两样从布局算出来的面——
|
|
25984
|
+
* 屏幕上的顺序 `ordered`(只含展开的组)与"这一档要不要自动选中"(`canAutoSelect: split`)。
|
|
25103
25985
|
*/
|
|
25104
|
-
const
|
|
25105
|
-
|
|
25106
|
-
|
|
25107
|
-
|
|
25108
|
-
|
|
25109
|
-
|
|
25110
|
-
|
|
25111
|
-
|
|
25112
|
-
document.addEventListener("keydown", onKey);
|
|
25113
|
-
return () => {
|
|
25114
|
-
document.removeEventListener("keydown", onKey);
|
|
25115
|
-
};
|
|
25116
|
-
}, [drilled, selectedId]);
|
|
25117
|
-
(0, react.useEffect)(() => {
|
|
25118
|
-
if (drilled || refocusRow.current === null) return;
|
|
25119
|
-
focusRow(refocusRow.current);
|
|
25120
|
-
refocusRow.current = null;
|
|
25121
|
-
}, [drilled, focusRow]);
|
|
25986
|
+
const controller = useTrashPanelController({
|
|
25987
|
+
deps,
|
|
25988
|
+
gate,
|
|
25989
|
+
query,
|
|
25990
|
+
ordered,
|
|
25991
|
+
canAutoSelect: split,
|
|
25992
|
+
focusRow
|
|
25993
|
+
});
|
|
25122
25994
|
const onListKeyDown = (event) => {
|
|
25123
|
-
if (ordered.length === 0) return;
|
|
25124
|
-
const index = ordered.findIndex((entry) => entry.id === selectedId);
|
|
25125
|
-
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
25126
|
-
event.preventDefault();
|
|
25127
|
-
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
25128
|
-
const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
|
|
25129
|
-
const target = ordered[next];
|
|
25130
|
-
select(target);
|
|
25131
|
-
focusRow(target.id);
|
|
25132
|
-
return;
|
|
25133
|
-
}
|
|
25134
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
25135
|
-
if (index < 0) return;
|
|
25136
|
-
event.preventDefault();
|
|
25137
|
-
if (selectMode) {
|
|
25138
|
-
toggleCheck(ordered[index].id);
|
|
25139
|
-
return;
|
|
25140
|
-
}
|
|
25141
|
-
select(ordered[index], { drill: true });
|
|
25142
|
-
}
|
|
25143
|
-
};
|
|
25144
|
-
const failRow = (entryId, message) => {
|
|
25145
|
-
setActionFailure((current) => ({
|
|
25146
|
-
...current,
|
|
25147
|
-
[entryId]: message
|
|
25148
|
-
}));
|
|
25149
|
-
};
|
|
25150
|
-
const restore = async (entry) => {
|
|
25151
|
-
setActionFailure((current) => {
|
|
25152
|
-
const next = { ...current };
|
|
25153
|
-
delete next[entry.id];
|
|
25154
|
-
return next;
|
|
25155
|
-
});
|
|
25156
|
-
setBusy(true);
|
|
25157
|
-
const outcome = await callRemote(() => deps.restore(entry.id));
|
|
25158
|
-
setBusy(false);
|
|
25159
|
-
if (!outcome.ok) {
|
|
25160
|
-
failRow(entry.id, outcome.failure.message);
|
|
25161
|
-
return;
|
|
25162
|
-
}
|
|
25163
|
-
detailCache.current.delete(entry.id);
|
|
25164
|
-
await reload();
|
|
25165
|
-
};
|
|
25166
|
-
/** 一段批量 → 组件状态:成功的丢详情缓存,失败的原文落回它自己那一行。整批一次上屏、一次重读。 */
|
|
25167
|
-
const settleOutcomes = (outcomes) => {
|
|
25168
|
-
const failures = [];
|
|
25169
|
-
for (const outcome of outcomes) if (outcome.ok) detailCache.current.delete(outcome.id);
|
|
25170
|
-
else failures.push({
|
|
25171
|
-
id: outcome.id,
|
|
25172
|
-
message: outcome.error.message
|
|
25173
|
-
});
|
|
25174
|
-
if (failures.length > 0) setActionFailure((current) => {
|
|
25175
|
-
const next = { ...current };
|
|
25176
|
-
for (const failure of failures) next[failure.id] = failure.message;
|
|
25177
|
-
return next;
|
|
25178
|
-
});
|
|
25179
|
-
};
|
|
25180
|
-
/**
|
|
25181
|
-
* 批量彻底删除 / 批量恢复**共用这一条**(2026-10-17 批量重构):一次 `apply` 把整批交给 Node 半,
|
|
25182
|
-
* 逐条成败住在 `ApplyOutcome` 里 ⇒ 组件不再自己拼循环、也不再"每条一次全量重读"。
|
|
25183
|
-
*/
|
|
25184
|
-
const confirmApply = async (action, ids) => {
|
|
25185
|
-
setBusy(true);
|
|
25186
|
-
const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
|
|
25187
|
-
setBusy(false);
|
|
25188
|
-
setPending(null);
|
|
25189
|
-
if (!outcome.ok) {
|
|
25190
|
-
for (const id of ids) failRow(id, outcome.failure.message);
|
|
25191
|
-
await reload();
|
|
25192
|
-
return;
|
|
25193
|
-
}
|
|
25194
|
-
settleOutcomes(outcome.value);
|
|
25195
|
-
await reload();
|
|
25196
|
-
};
|
|
25197
|
-
const confirmPurge = async () => {
|
|
25198
|
-
const target = pending;
|
|
25199
|
-
if (target === null) return;
|
|
25200
|
-
if (target.kind === "entries") return confirmApply("purge", target.ids);
|
|
25201
|
-
if (target.kind === "restore") return confirmApply("restore", target.ids);
|
|
25202
|
-
if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
|
|
25203
|
-
setBusy(true);
|
|
25204
|
-
const outcome = await callRemote(() => deps.emptyTrash());
|
|
25205
|
-
setBusy(false);
|
|
25206
|
-
setPending(null);
|
|
25207
|
-
if (!outcome.ok) {
|
|
25208
|
-
setListFailure(outcome.failure.message);
|
|
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);
|
|
25209
26004
|
return;
|
|
25210
26005
|
}
|
|
25211
|
-
|
|
25212
|
-
|
|
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();
|
|
25213
26020
|
};
|
|
25214
26021
|
/**
|
|
25215
26022
|
* 详细信息那块的选中集合:确认框要把"这一批有哪些、要去哪"摆出来,靠的全量条目是从勾选 id 反查
|
|
@@ -25217,8 +26024,9 @@ window.__ModuleLoader__.load({
|
|
|
25217
26024
|
* (重读会把它从勾选里也剔掉)。
|
|
25218
26025
|
*/
|
|
25219
26026
|
const byIdEntries = (0, react.useMemo)(() => new Map((entries ?? []).map((entry) => [entry.id, entry])), [entries]);
|
|
26027
|
+
const pending = controller.pending;
|
|
25220
26028
|
const restoreEntries = pending?.kind === "restore" ? pending.ids.map((id) => byIdEntries.get(id)).filter((entry) => entry !== void 0) : [];
|
|
25221
|
-
const totalBytes = (entries ?? []).reduce((sum, entry) => sum +
|
|
26029
|
+
const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.releaseBytes, 0);
|
|
25222
26030
|
const now = Date.now();
|
|
25223
26031
|
const hasEntries = entries !== null && entries.length > 0;
|
|
25224
26032
|
/**
|
|
@@ -25226,45 +26034,14 @@ window.__ModuleLoader__.load({
|
|
|
25226
26034
|
* 在准备批量恢复时是假话——那类具体警告归各自的确认框,这里只留两件事:回收站在哪、不自动过期。
|
|
25227
26035
|
*/
|
|
25228
26036
|
const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
|
|
25229
|
-
/**
|
|
25230
|
-
* 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:把选中那条所在的组折叠起来
|
|
25231
|
-
* 只是把那一列收掉,右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
|
|
25232
|
-
*/
|
|
25233
|
-
const selected = (entries ?? []).find((entry) => entry.id === selectedId) ?? null;
|
|
25234
26037
|
/** `aria-activedescendant` 只能指向**在 DOM 里**的那一行:折叠起来之后它就是不存在,指过去是假引用。 */
|
|
25235
|
-
const activeDescendantId = ordered.some((entry) => entry.id === selectedId) ? selectedId : null;
|
|
26038
|
+
const activeDescendantId = ordered.some((entry) => entry.id === controller.selectedId) ? controller.selectedId : null;
|
|
25236
26039
|
/**
|
|
25237
26040
|
* 清单里那个唯一的 tab 落点(roving tabindex)。它必须**永远有一行拿着**:窄档不自动选中,
|
|
25238
26041
|
* 只按"选中那行拿 0"来给的话,一次没点过时全行都是 `-1`,键盘根本进不来清单。
|
|
25239
26042
|
* 落点与 `aria-activedescendant` 同一条可见性规则——选中那行被折叠起来时落到第一条可见行。
|
|
25240
26043
|
*/
|
|
25241
26044
|
const tabStopId = activeDescendantId ?? ordered[0]?.id ?? null;
|
|
25242
|
-
const detailState = (0, react.useMemo)(() => {
|
|
25243
|
-
if (selected === null) return { kind: "idle" };
|
|
25244
|
-
if (loadingId === selected.id) return {
|
|
25245
|
-
kind: "loading",
|
|
25246
|
-
entry: selected
|
|
25247
|
-
};
|
|
25248
|
-
const cached = detailCache.current.get(selected.id);
|
|
25249
|
-
if (cached !== void 0) return {
|
|
25250
|
-
kind: "ready",
|
|
25251
|
-
entry: selected,
|
|
25252
|
-
detail: cached
|
|
25253
|
-
};
|
|
25254
|
-
if (detailFailure?.entryId === selected.id) return {
|
|
25255
|
-
kind: "failed",
|
|
25256
|
-
entry: selected,
|
|
25257
|
-
message: detailFailure.message
|
|
25258
|
-
};
|
|
25259
|
-
return {
|
|
25260
|
-
kind: "loading",
|
|
25261
|
-
entry: selected
|
|
25262
|
-
};
|
|
25263
|
-
}, [
|
|
25264
|
-
selected,
|
|
25265
|
-
loadingId,
|
|
25266
|
-
detailFailure
|
|
25267
|
-
]);
|
|
25268
26045
|
/**
|
|
25269
26046
|
* 清单容器(roving tabindex 那个唯一的 tab 巢)。`flex:'1 1 auto'` 是**空态居中链**的一节:
|
|
25270
26047
|
* 空态块自己有 `flex:1` + `justifyContent:center`,但要有**有高度的**父链才居得起来——本层是
|
|
@@ -25334,7 +26111,7 @@ window.__ModuleLoader__.load({
|
|
|
25334
26111
|
variant: "outline",
|
|
25335
26112
|
size: "sm",
|
|
25336
26113
|
style: { flex: "none" },
|
|
25337
|
-
onClick: () => void
|
|
26114
|
+
onClick: () => void refresh(),
|
|
25338
26115
|
children: t("common.retry")
|
|
25339
26116
|
})
|
|
25340
26117
|
]
|
|
@@ -25472,7 +26249,7 @@ window.__ModuleLoader__.load({
|
|
|
25472
26249
|
]
|
|
25473
26250
|
})]
|
|
25474
26251
|
}), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
|
|
25475
|
-
const isSelected = entry.id === selectedId;
|
|
26252
|
+
const isSelected = entry.id === controller.selectedId;
|
|
25476
26253
|
const isChecked = checked.has(entry.id);
|
|
25477
26254
|
const title = entry.title === "" ? t("common.untitled") : entry.title;
|
|
25478
26255
|
const kids = entry.subagentCount ?? 0;
|
|
@@ -25490,7 +26267,7 @@ window.__ModuleLoader__.load({
|
|
|
25490
26267
|
toggleCheck(entry.id);
|
|
25491
26268
|
return;
|
|
25492
26269
|
}
|
|
25493
|
-
select(entry, { drill: !split });
|
|
26270
|
+
controller.select(entry, { drill: !split });
|
|
25494
26271
|
},
|
|
25495
26272
|
children: [
|
|
25496
26273
|
selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
@@ -25542,7 +26319,7 @@ window.__ModuleLoader__.load({
|
|
|
25542
26319
|
},
|
|
25543
26320
|
children: t("trash.row.workspaceGone")
|
|
25544
26321
|
}) : null,
|
|
25545
|
-
actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26322
|
+
controller.actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25546
26323
|
role: "status",
|
|
25547
26324
|
style: {
|
|
25548
26325
|
display: "flex",
|
|
@@ -25560,7 +26337,7 @@ window.__ModuleLoader__.load({
|
|
|
25560
26337
|
},
|
|
25561
26338
|
"aria-hidden": "true",
|
|
25562
26339
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 13 })
|
|
25563
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: actionFailure[entry.id] })]
|
|
26340
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: controller.actionFailure[entry.id] })]
|
|
25564
26341
|
})
|
|
25565
26342
|
]
|
|
25566
26343
|
}),
|
|
@@ -25573,15 +26350,16 @@ window.__ModuleLoader__.load({
|
|
|
25573
26350
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25574
26351
|
variant: "outline",
|
|
25575
26352
|
size: "sm",
|
|
25576
|
-
disabled: busy,
|
|
25577
|
-
onClick: () => void restore(entry),
|
|
26353
|
+
disabled: controller.busy,
|
|
26354
|
+
onClick: () => void controller.restore(entry),
|
|
25578
26355
|
children: t("trash.row.restore")
|
|
25579
26356
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25580
26357
|
variant: "ghost",
|
|
25581
26358
|
size: "sm",
|
|
25582
26359
|
style: QUIET_DANGER,
|
|
25583
26360
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
25584
|
-
|
|
26361
|
+
disabled: controller.busy,
|
|
26362
|
+
onClick: () => controller.setPending({
|
|
25585
26363
|
kind: "entry",
|
|
25586
26364
|
entry
|
|
25587
26365
|
}),
|
|
@@ -25599,13 +26377,11 @@ window.__ModuleLoader__.load({
|
|
|
25599
26377
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25600
26378
|
style: DETAIL_INNER,
|
|
25601
26379
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashDetail, {
|
|
25602
|
-
state:
|
|
25603
|
-
busy,
|
|
26380
|
+
state: controller.detail,
|
|
26381
|
+
busy: controller.busy,
|
|
25604
26382
|
wide: split,
|
|
25605
|
-
onRetry: () =>
|
|
25606
|
-
|
|
25607
|
-
},
|
|
25608
|
-
onBack: () => setDrilled(false),
|
|
26383
|
+
onRetry: () => controller.retry(),
|
|
26384
|
+
onBack: () => controller.back(),
|
|
25609
26385
|
t
|
|
25610
26386
|
})
|
|
25611
26387
|
})
|
|
@@ -25614,7 +26390,7 @@ window.__ModuleLoader__.load({
|
|
|
25614
26390
|
* 那一排**常驻面板体顶端**,不住清单列里:它原先在滚动容器内,长清单滚到底会把开关滚走。
|
|
25615
26391
|
* 宽度上限跟着清单列走,所以它在视觉上仍然"属于清单"。
|
|
25616
26392
|
*
|
|
25617
|
-
* 这里曾有一颗「刷新」图标(2026-10-15
|
|
26393
|
+
* 这里曾有一颗「刷新」图标(2026-10-15 收进那一排、同日降级成图标,**2026-10-16
|
|
25618
26394
|
* 按用户裁定删除**):清单现在是"每次读都对账"(见 `reconcile.ts`),开面板、切走再切回、
|
|
25619
26395
|
* 重启——任何一次读都会先同步,那颗按钮不再承担别人替代不了的活。
|
|
25620
26396
|
*/
|
|
@@ -25637,7 +26413,7 @@ window.__ModuleLoader__.load({
|
|
|
25637
26413
|
"aria-pressed": selectMode,
|
|
25638
26414
|
onClick: () => {
|
|
25639
26415
|
if (selectMode) exitSelect();
|
|
25640
|
-
else
|
|
26416
|
+
else enterSelect();
|
|
25641
26417
|
},
|
|
25642
26418
|
children: selectMode ? t("trash.toolbar.selectExit") : t("trash.toolbar.select")
|
|
25643
26419
|
})]
|
|
@@ -25692,6 +26468,135 @@ window.__ModuleLoader__.load({
|
|
|
25692
26468
|
}) : null]
|
|
25693
26469
|
})
|
|
25694
26470
|
}),
|
|
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,
|
|
25695
26600
|
toolbar,
|
|
25696
26601
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25697
26602
|
style: BODY,
|
|
@@ -25706,7 +26611,7 @@ window.__ModuleLoader__.load({
|
|
|
25706
26611
|
...resize.handleProps
|
|
25707
26612
|
}),
|
|
25708
26613
|
detailPane
|
|
25709
|
-
] }) : drilled && selected !== null ? detailPane : listPane({
|
|
26614
|
+
] }) : controller.drilled && controller.selected !== null ? detailPane : listPane({
|
|
25710
26615
|
...LIST_COLUMN,
|
|
25711
26616
|
flex: "1 1 auto"
|
|
25712
26617
|
})
|
|
@@ -25753,8 +26658,8 @@ window.__ModuleLoader__.load({
|
|
|
25753
26658
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25754
26659
|
variant: "outline",
|
|
25755
26660
|
size: "sm",
|
|
25756
|
-
disabled: checkedIds.length === 0 || busy,
|
|
25757
|
-
onClick: () => setPending({
|
|
26661
|
+
disabled: checkedIds.length === 0 || controller.busy,
|
|
26662
|
+
onClick: () => controller.setPending({
|
|
25758
26663
|
kind: "restore",
|
|
25759
26664
|
ids: checkedIds
|
|
25760
26665
|
}),
|
|
@@ -25770,8 +26675,8 @@ window.__ModuleLoader__.load({
|
|
|
25770
26675
|
size: "sm",
|
|
25771
26676
|
style: BULK_DANGER,
|
|
25772
26677
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
25773
|
-
disabled: checkedIds.length === 0 || busy,
|
|
25774
|
-
onClick: () => setPending({
|
|
26678
|
+
disabled: checkedIds.length === 0 || controller.busy,
|
|
26679
|
+
onClick: () => controller.setPending({
|
|
25775
26680
|
kind: "entries",
|
|
25776
26681
|
ids: checkedIds
|
|
25777
26682
|
}),
|
|
@@ -25782,7 +26687,8 @@ window.__ModuleLoader__.load({
|
|
|
25782
26687
|
size: "sm",
|
|
25783
26688
|
style: BULK_DANGER,
|
|
25784
26689
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
25785
|
-
|
|
26690
|
+
disabled: controller.busy,
|
|
26691
|
+
onClick: () => controller.setPending({ kind: "all" }),
|
|
25786
26692
|
children: t("trash.emptyAll")
|
|
25787
26693
|
})
|
|
25788
26694
|
})]
|
|
@@ -25790,140 +26696,20 @@ window.__ModuleLoader__.load({
|
|
|
25790
26696
|
pending === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashConfirm, {
|
|
25791
26697
|
pending,
|
|
25792
26698
|
restoreEntries,
|
|
25793
|
-
busy,
|
|
26699
|
+
busy: controller.busy,
|
|
25794
26700
|
t,
|
|
25795
|
-
onCancel: () => setPending(null),
|
|
25796
|
-
onConfirmPurge: () => void confirmPurge(),
|
|
25797
|
-
onConfirmRestore: (ids) => void confirmApply("restore", ids)
|
|
25798
|
-
})
|
|
25799
|
-
]
|
|
25800
|
-
});
|
|
25801
|
-
}
|
|
25802
|
-
/**
|
|
25803
|
-
* 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
|
|
25804
|
-
* 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
|
|
25805
|
-
* 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
|
|
25806
|
-
* 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
|
|
25807
|
-
*/
|
|
25808
|
-
function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
|
|
25809
|
-
if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
25810
|
-
open: true,
|
|
25811
|
-
onClose: onCancel,
|
|
25812
|
-
title: t("trash.confirm.restore", { count: pending.ids.length }),
|
|
25813
|
-
closeLabel: t("common.close"),
|
|
25814
|
-
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25815
|
-
variant: "outline",
|
|
25816
|
-
onClick: onCancel,
|
|
25817
|
-
children: t("common.cancel")
|
|
25818
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25819
|
-
variant: "primary",
|
|
25820
|
-
disabled: busy,
|
|
25821
|
-
onClick: () => onConfirmRestore(pending.ids),
|
|
25822
|
-
children: t("trash.confirm.restoreNow")
|
|
25823
|
-
})] }),
|
|
25824
|
-
children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25825
|
-
style: {
|
|
25826
|
-
display: "flex",
|
|
25827
|
-
flexDirection: "column",
|
|
25828
|
-
gap: 6,
|
|
25829
|
-
maxHeight: 280,
|
|
25830
|
-
overflow: "auto",
|
|
25831
|
-
marginBlockEnd: 10
|
|
25832
|
-
},
|
|
25833
|
-
children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25834
|
-
style: {
|
|
25835
|
-
...CARD,
|
|
25836
|
-
flexDirection: "column",
|
|
25837
|
-
alignItems: "stretch",
|
|
25838
|
-
gap: 2,
|
|
25839
|
-
padding: "9px 12px"
|
|
25840
|
-
},
|
|
25841
|
-
children: [
|
|
25842
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25843
|
-
style: {
|
|
25844
|
-
...ELLIPSIS,
|
|
25845
|
-
fontSize: 13.5,
|
|
25846
|
-
lineHeight: "19px"
|
|
25847
|
-
},
|
|
25848
|
-
children: entry.title === "" ? t("common.untitled") : entry.title
|
|
25849
|
-
}),
|
|
25850
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25851
|
-
style: {
|
|
25852
|
-
...ELLIPSIS,
|
|
25853
|
-
fontSize: 12,
|
|
25854
|
-
lineHeight: "16px",
|
|
25855
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
25856
|
-
},
|
|
25857
|
-
children: projectLabel(entry.projectDir, t)
|
|
25858
|
-
}),
|
|
25859
|
-
entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25860
|
-
style: {
|
|
25861
|
-
fontSize: 12,
|
|
25862
|
-
lineHeight: "16px",
|
|
25863
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
25864
|
-
},
|
|
25865
|
-
children: t("trash.row.workspaceGone")
|
|
25866
|
-
}) : null
|
|
25867
|
-
]
|
|
25868
|
-
}, entry.id))
|
|
25869
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
|
|
25870
|
-
});
|
|
25871
|
-
const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
|
|
25872
|
-
const strong = t("trash.confirm.body.strong");
|
|
25873
|
-
const body = splitOn(t("trash.confirm.body", { strong }), strong);
|
|
25874
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
25875
|
-
open: true,
|
|
25876
|
-
onClose: onCancel,
|
|
25877
|
-
title,
|
|
25878
|
-
closeLabel: t("common.close"),
|
|
25879
|
-
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25880
|
-
variant: "outline",
|
|
25881
|
-
onClick: onCancel,
|
|
25882
|
-
children: t("common.cancel")
|
|
25883
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25884
|
-
variant: "primary",
|
|
25885
|
-
style: BULK_DANGER,
|
|
25886
|
-
disabled: busy,
|
|
25887
|
-
onClick: onConfirmPurge,
|
|
25888
|
-
children: t("trash.confirm.purge")
|
|
25889
|
-
})] }),
|
|
25890
|
-
children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25891
|
-
style: {
|
|
25892
|
-
...CARD,
|
|
25893
|
-
flexDirection: "column",
|
|
25894
|
-
alignItems: "stretch",
|
|
25895
|
-
gap: 2,
|
|
25896
|
-
padding: "10px 12px",
|
|
25897
|
-
marginBlockEnd: 10
|
|
25898
|
-
},
|
|
25899
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25900
|
-
style: {
|
|
25901
|
-
...ELLIPSIS,
|
|
25902
|
-
fontSize: 13.5,
|
|
25903
|
-
lineHeight: "19px"
|
|
25904
|
-
},
|
|
25905
|
-
children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
|
|
25906
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25907
|
-
style: {
|
|
25908
|
-
...ELLIPSIS,
|
|
25909
|
-
fontSize: 12,
|
|
25910
|
-
lineHeight: "16px",
|
|
25911
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
25912
|
-
},
|
|
25913
|
-
children: t("trash.confirm.one.meta", {
|
|
25914
|
-
project: projectLabel(pending.entry.projectDir, t),
|
|
25915
|
-
at: absoluteText(pending.entry.deletedAt),
|
|
25916
|
-
size: fileSizeText(releaseBytesOf(pending.entry))
|
|
25917
|
-
})
|
|
25918
|
-
})]
|
|
25919
|
-
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
|
|
25920
|
-
body[0],
|
|
25921
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
|
|
25922
|
-
style: { color: "var(--dsw-alias-state-error-primary)" },
|
|
25923
|
-
children: body[1]
|
|
26701
|
+
onCancel: () => controller.setPending(null),
|
|
26702
|
+
onConfirmPurge: () => void controller.confirmPurge(),
|
|
26703
|
+
onConfirmRestore: (ids) => void controller.confirmApply("restore", ids)
|
|
25924
26704
|
}),
|
|
25925
|
-
|
|
25926
|
-
|
|
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
|
|
26712
|
+
]
|
|
25927
26713
|
});
|
|
25928
26714
|
}
|
|
25929
26715
|
//#endregion
|
|
@@ -25931,19 +26717,19 @@ window.__ModuleLoader__.load({
|
|
|
25931
26717
|
/**
|
|
25932
26718
|
* 浏览器半入口(CJS 单文件闭包工厂的 `factory` 体,构建契约见 tsdown.config.ts)。
|
|
25933
26719
|
*
|
|
25934
|
-
*
|
|
26720
|
+
* 共交付**十个**槽位贡献(2026-10-17 起,批量删除再加五件),分四类:
|
|
25935
26721
|
*
|
|
25936
26722
|
* - `sidebar.workspaces.session.menu.item` —— 两行:「多选」入口(`BatchSelectMenuItem`,order 850)
|
|
25937
26723
|
* 与「删除」菜单行(`DeleteMenuItem`,order 900)。多选入口按下后进入多选模式(再按一次退出)
|
|
25938
26724
|
* 并收菜单;删除行**只发请求**(把 `{sessionId, title}` 写进 `delete-confirm.tsx` 的模块级现场)。
|
|
25939
|
-
* - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox
|
|
26725
|
+
* - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,2026-10-17)。
|
|
25940
26726
|
* **只在多选模式内渲染**(入口在「⋯ → 多选」,不常驻)。宿主在「有状态点(运行中/待确认/未读完成)」
|
|
25941
26727
|
* 与归档行不渲染这一格 ⇒ 那些行由下面行尾那颗兜底;槽位只投 `sessionId`,标题
|
|
25942
26728
|
* (`displayTitle`)从 root hook 的 `byId[id].title` 读。
|
|
25943
26729
|
* - `sidebar.workspaces.session.row.action` —— 行尾悬停排的**兜底勾选开关**(`BatchPickButton`)。
|
|
25944
26730
|
* 座位原文:"一行一颗图标按钮(动作不适用于这一行时可以不渲染)、动作归自己",且**排内点击不误开
|
|
25945
26731
|
* 会话** ⇒ 兜底开关不需要处理事件传播。两条与前一条不会同时出现(`ui.css` 的 `:has` 隐藏规则)。
|
|
25946
|
-
* 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`
|
|
26732
|
+
* 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`host-rows.ts`)。
|
|
25947
26733
|
* 与 overlay 上的**浮条**(`BatchBar`)、**批量确认框**(`BatchConfirmHost`)合起来才是批量删除那一套。
|
|
25948
26734
|
* 模式内点整行会拦截宿主的会话切换(document 捕获守卫);执行仍走 `POST /apply`,单删那三件一律不动。
|
|
25949
26735
|
* - `shell.overlay` —— 菜单之外的**四个**常驻宿主(各一次 `slots.inject`,与宿主自己对
|
|
@@ -25959,7 +26745,7 @@ window.__ModuleLoader__.load({
|
|
|
25959
26745
|
* !this.hasMainPanel(panelId)) throw new Error(...)`(asar @21199710)——只有行、没有面板时,
|
|
25960
26746
|
* 用户点一下就是运行期抛错;反过来先有面板也没有入口。
|
|
25961
26747
|
*
|
|
25962
|
-
* **为什么不是底栏(2026-09-30
|
|
26748
|
+
* **为什么不是底栏(2026-09-30 实测裁决,判据全在下面三行)**:`sidebar.footer.action` 的**惯用形**是
|
|
25963
26749
|
* 「42px 整行 + 图标 + 文字 + 右对齐 12px 计数」——官方 `client-ui-cordis` 的占用者 CSS 是
|
|
25964
26750
|
* `.layer{flex:none;width:100%;height:42px}`,而座位容器只有 `.footerActions{display:flex}`
|
|
25965
26751
|
* (无 gap、无 wrap)⇒ 我们交的 `Button ghost/sm` 小药丸贴底栏,与下方 42px 的「设置」行不同形。
|
|
@@ -26106,7 +26892,7 @@ window.__ModuleLoader__.load({
|
|
|
26106
26892
|
* **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
|
|
26107
26893
|
*
|
|
26108
26894
|
* - 探测不过(打不通 / 回的不是信封)⇒ **早返回**:一个槽位贡献都不注册。页面上的表现是"本插件
|
|
26109
|
-
* 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI
|
|
26895
|
+
* 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
|
|
26110
26896
|
* - 探测过(哪怕 `ping` 回的是 `{ok:false,error}`)⇒ 注册:那次往返证明路由在、依赖接上了、
|
|
26111
26897
|
* 错误映射也生效了,失败该做的是把原文交给用户看。
|
|
26112
26898
|
*
|
|
@@ -26117,6 +26903,7 @@ window.__ModuleLoader__.load({
|
|
|
26117
26903
|
* 漏一半的代价是**宿主那句响亮的抛错**,不是静默降级。
|
|
26118
26904
|
*/
|
|
26119
26905
|
async function activate(client) {
|
|
26906
|
+
bindMainView(client);
|
|
26120
26907
|
const raw = createHttpRemote();
|
|
26121
26908
|
if (!await probeChannel(raw)) return;
|
|
26122
26909
|
const deps = buildDeps(raw);
|