@xrn1997/dsh-session-delete 0.2.0 → 0.2.2
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 +52 -44
- package/lib/client.js +912 -487
- package/lib/index.js +1746 -1113
- package/lib/types/api/dispatch.d.ts +23 -39
- package/lib/types/api/route-table.d.ts +35 -0
- package/lib/types/api/trash-rows.d.ts +24 -0
- package/lib/types/client/copy.d.ts +13 -0
- package/lib/types/client/remote.d.ts +49 -70
- 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-view.d.ts +38 -0
- package/lib/types/client/undo-toast.d.ts +2 -2
- 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/index.d.ts +4 -3
- package/lib/types/ports/host-port.d.ts +33 -1
- package/lib/types/remote/map-error.d.ts +1 -1
- package/lib/types/shared/wire.d.ts +103 -9
- package/lib/types/trash/detail.d.ts +5 -23
- package/lib/types/trash/entry.d.ts +25 -2
- package/lib/types/trash/groups.d.ts +45 -0
- package/lib/types/verbs/delete-session.d.ts +5 -8
- package/lib/types/verbs/errors.d.ts +13 -6
- package/lib/types/verbs/member-journal.d.ts +26 -0
- package/lib/types/verbs/orphan-subagents.d.ts +82 -0
- package/lib/types/verbs/purge-trash.d.ts +13 -1
- package/lib/types/verbs/restore-session.d.ts +28 -8
- package/package.json +8 -3
- /package/lib/types/verbs/{apply.d.ts → batch.d.ts} +0 -0
package/lib/client.js
CHANGED
|
@@ -148,6 +148,7 @@ window.__ModuleLoader__.load({
|
|
|
148
148
|
"trash.row.deletedAgo": "删除于 {n}{unit}前",
|
|
149
149
|
"trash.row.subagents": "含 {n} 个子会话",
|
|
150
150
|
"trash.row.strandedSubagents": "盘上还剩 {n} 个子会话",
|
|
151
|
+
"trash.row.workspaceGone": "原工作区已不存在,恢复后归入未分组",
|
|
151
152
|
"trash.unit.minutes": "分钟",
|
|
152
153
|
"trash.unit.hours": "小时",
|
|
153
154
|
"trash.unit.days": "天",
|
|
@@ -178,6 +179,18 @@ window.__ModuleLoader__.load({
|
|
|
178
179
|
"trash.confirm.restore": "恢复 {count} 个会话?",
|
|
179
180
|
"trash.confirm.restore.body": "这一批会回到它们各自的原项目目录,恢复到会话列表里。",
|
|
180
181
|
"trash.confirm.restoreNow": "恢复",
|
|
182
|
+
"trash.orphans.banner": "回收站之外发现 {count} 个无主子会话",
|
|
183
|
+
"trash.orphans.meta": "共 {size},来自 {parents} 个失踪的父会话",
|
|
184
|
+
"trash.orphans.open": "查看并清理",
|
|
185
|
+
"trash.orphans.confirm": "清理 {count} 个无主子会话?",
|
|
186
|
+
"trash.orphans.body": "这些子会话的父会话已经不在会话列表或回收站里,清理后不可恢复。正在运行的子会话会被拒绝,不会被删除。",
|
|
187
|
+
"trash.orphans.group": "{project} · {count} 个 · {size}",
|
|
188
|
+
"trash.orphans.cacheNote": "本次只清理会话目录,不包含宿主投影缓存和附件。",
|
|
189
|
+
"trash.orphans.clean": "清理",
|
|
190
|
+
"trash.orphans.failure": "残留检查/清理失败:{message}",
|
|
191
|
+
"trash.orphans.cleanFailed": "{count} 组未清理:{message}",
|
|
192
|
+
"trash.orphans.cleanPartial": "已先删掉 {removed} 个会话目录({size})",
|
|
193
|
+
"trash.orphans.cleanGone": "有 {count} 组已不在了(可能已被别处清理,或父会话已恢复)",
|
|
181
194
|
"detail.notSelected": "选中一条会话,看它讲了什么",
|
|
182
195
|
"detail.loading": "正在读这条会话…",
|
|
183
196
|
"detail.readFailed": "这条会话的正文读不出来",
|
|
@@ -250,6 +263,7 @@ window.__ModuleLoader__.load({
|
|
|
250
263
|
"trash.row.deletedAgo": "Deleted {n}{unit} ago",
|
|
251
264
|
"trash.row.subagents": "has {n} subagent sessions",
|
|
252
265
|
"trash.row.strandedSubagents": "{n} subagent sessions still on disk",
|
|
266
|
+
"trash.row.workspaceGone": "Its workspace no longer exists; it will be restored under “Ungrouped”",
|
|
253
267
|
"trash.unit.minutes": "min",
|
|
254
268
|
"trash.unit.hours": "h",
|
|
255
269
|
"trash.unit.days": "d",
|
|
@@ -280,6 +294,18 @@ window.__ModuleLoader__.load({
|
|
|
280
294
|
"trash.confirm.restore": "Restore these {count} sessions?",
|
|
281
295
|
"trash.confirm.restore.body": "They go back to their original project folders and reappear in the session list.",
|
|
282
296
|
"trash.confirm.restoreNow": "Restore",
|
|
297
|
+
"trash.orphans.banner": "Found {count} orphan subagent sessions outside the trash",
|
|
298
|
+
"trash.orphans.meta": "{size} total, from {parents} missing parent sessions",
|
|
299
|
+
"trash.orphans.open": "Review and clean",
|
|
300
|
+
"trash.orphans.confirm": "Clean {count} orphan subagent sessions?",
|
|
301
|
+
"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.",
|
|
302
|
+
"trash.orphans.group": "{project} · {count} sessions · {size}",
|
|
303
|
+
"trash.orphans.cacheNote": "This cleans session directories only; host projection caches and attachments are not included.",
|
|
304
|
+
"trash.orphans.clean": "Clean",
|
|
305
|
+
"trash.orphans.failure": "Orphan check/cleanup failed: {message}",
|
|
306
|
+
"trash.orphans.cleanFailed": "{count} groups were not cleaned: {message}",
|
|
307
|
+
"trash.orphans.cleanPartial": "Removed {removed} session directories first ({size})",
|
|
308
|
+
"trash.orphans.cleanGone": "{count} groups were already gone (cleaned elsewhere or their parent was restored)",
|
|
283
309
|
"detail.notSelected": "Select a session to see what it was about",
|
|
284
310
|
"detail.loading": "Reading this session…",
|
|
285
311
|
"detail.readFailed": "Could not read this session’s log",
|
|
@@ -407,7 +433,7 @@ window.__ModuleLoader__.load({
|
|
|
407
433
|
//#endregion
|
|
408
434
|
//#region src/shared/wire.ts
|
|
409
435
|
/**
|
|
410
|
-
* 跨半 wire 契约:`/dsh-session-delete-api`
|
|
436
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
|
|
411
437
|
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
412
438
|
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
413
439
|
*
|
|
@@ -430,13 +456,15 @@ window.__ModuleLoader__.load({
|
|
|
430
456
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
431
457
|
*/
|
|
432
458
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
433
|
-
/**
|
|
459
|
+
/** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
|
|
434
460
|
* 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
|
|
435
461
|
* - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
|
|
436
462
|
* - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
|
|
437
463
|
* - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
|
|
438
|
-
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
|
|
439
|
-
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
464
|
+
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
465
|
+
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
466
|
+
* - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
|
|
467
|
+
* 第五个动作,这一段只负责"有多少、在哪、多大"。 */
|
|
440
468
|
const ROUTES = {
|
|
441
469
|
list: "/list",
|
|
442
470
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
@@ -446,7 +474,9 @@ window.__ModuleLoader__.load({
|
|
|
446
474
|
/** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
|
|
447
475
|
apply: "/apply",
|
|
448
476
|
/** 清空回收站。 */
|
|
449
|
-
empty: "/empty"
|
|
477
|
+
empty: "/empty",
|
|
478
|
+
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
479
|
+
orphans: "/orphans"
|
|
450
480
|
};
|
|
451
481
|
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
|
|
452
482
|
const PARAMS = {
|
|
@@ -467,6 +497,18 @@ window.__ModuleLoader__.load({
|
|
|
467
497
|
const candidate = value;
|
|
468
498
|
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
469
499
|
}
|
|
500
|
+
const SESSION_DELETE_CODES = {
|
|
501
|
+
live: "sessiondelete/live",
|
|
502
|
+
"not-found": "sessiondelete/not-found",
|
|
503
|
+
"trash-empty": "sessiondelete/trash-empty",
|
|
504
|
+
"project-missing": "sessiondelete/project-missing",
|
|
505
|
+
io: "sessiondelete/io",
|
|
506
|
+
"bad-method": "sessiondelete/bad-method",
|
|
507
|
+
"not-found-route": "sessiondelete/not-found-route",
|
|
508
|
+
"bad-request": "sessiondelete/bad-request"
|
|
509
|
+
};
|
|
510
|
+
/** 页脚/文案里带尾斜杠的那一版(`splitOn` 按它切一刀再把目录名包成 code)。 */
|
|
511
|
+
const TRASH_DIR_LABEL = `session-trash/`;
|
|
470
512
|
/**
|
|
471
513
|
* 宿主 `projectKey` 的 **`~XXXX` 转义还原**。
|
|
472
514
|
*
|
|
@@ -488,7 +530,7 @@ window.__ModuleLoader__.load({
|
|
|
488
530
|
* 加上信封判别与注入面那一处「展开成裸值」的适配。
|
|
489
531
|
*
|
|
490
532
|
* 通道形状(裁决):真相/账本/判据全在宿主那边,Node 半用宿主**自己的 web server** 开一条
|
|
491
|
-
*
|
|
533
|
+
* 插件专属前缀路由把六个段名(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)搬出来
|
|
492
534
|
* (`src/api/dispatch.ts`),浏览器半同源调用。**不是重造后端**:
|
|
493
535
|
* 路由层只做搬运与信封。为什么不用官方的 `remote.<namespace>`:那条路要**生成的 Typert 远程描述符**
|
|
494
536
|
* 加一次 `$mount`,而盒内装配清单写死在 `dsh-api-remotes` 里、全宿主没有动态发现,第三方只能自挂自,
|
|
@@ -511,11 +553,13 @@ window.__ModuleLoader__.load({
|
|
|
511
553
|
* 所以到了组件面前只剩「裸值 / throw」两支,调用方只写 `if (!outcome.ok)`,正常流里没有 try/catch;
|
|
512
554
|
* 非本信封形状的异常(网络层、TypeError…)也不丢原文(见 `failureOf`)。
|
|
513
555
|
*
|
|
514
|
-
* 面(`list` / `ping` / `detail` / `apply` / `empty`)与 Node
|
|
515
|
-
* `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`
|
|
556
|
+
* 面(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)与 Node 半路由的六个段名一一对应(段名本身的家是
|
|
557
|
+
* `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`orphans` 回一份**纯读**的
|
|
558
|
+
* 残留扫描报告,`detail` 回单条正文,
|
|
516
559
|
* `apply` 回**一组逐条结果**,`empty` 回聚合回执。2026-10-17 之前是六个动作段(一条一段),
|
|
517
560
|
* 批量只能由调用方循环拼——那正是「恢复 100 条很慢」的形状来源,收成一段之后这条通道不再有
|
|
518
|
-
* "第二条执行路径"
|
|
561
|
+
* "第二条执行路径"。**清理无主子会话也不另开一段**:它是 `apply` 的第五个动作,`orphans` 只负责
|
|
562
|
+
* "有多少、在哪、多大"。
|
|
519
563
|
*/
|
|
520
564
|
/** 任意异常 → 失败面。非带标记的失败(网络层、TypeError…)也保留原文,绝不吞;带标记的失败把
|
|
521
565
|
* `details` 第三件一起带出。 */
|
|
@@ -609,6 +653,7 @@ window.__ModuleLoader__.load({
|
|
|
609
653
|
return {
|
|
610
654
|
list: () => requestEnvelope(prefix + ROUTES.list, { method: "GET" }),
|
|
611
655
|
ping: () => requestEnvelope(prefix + ROUTES.ping, { method: "GET" }),
|
|
656
|
+
orphans: () => requestEnvelope(prefix + ROUTES.orphans, { method: "GET" }),
|
|
612
657
|
detail: (entryId) => requestEnvelope(prefix + ROUTES.detail, post({ [PARAMS.entryId]: entryId })),
|
|
613
658
|
apply: (action, ids) => requestEnvelope(prefix + ROUTES.apply, post({
|
|
614
659
|
[PARAMS.action]: action,
|
|
@@ -636,15 +681,25 @@ window.__ModuleLoader__.load({
|
|
|
636
681
|
}
|
|
637
682
|
}
|
|
638
683
|
/**
|
|
639
|
-
*
|
|
684
|
+
* **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
|
|
685
|
+
*
|
|
686
|
+
* 三条判据都在这一处:
|
|
687
|
+
* - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
|
|
688
|
+
* - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
|
|
689
|
+
* - `null` 是批量里"已被同批前一条带走"的幂等值——单条调用(N=1)永远不会命中它。
|
|
690
|
+
*/
|
|
691
|
+
async function applyOne(deps, action, id) {
|
|
692
|
+
const outcome = (await deps.applyOutcomes(action, [id])).find((candidate) => candidate.id === id);
|
|
693
|
+
if (outcome === void 0) throw new Error(`批量通道没回这一条的结果:${action} ${id}`);
|
|
694
|
+
if (!outcome.ok) throwRemoteFailure(outcome.error);
|
|
695
|
+
return outcome;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
|
|
640
699
|
*
|
|
641
700
|
* **为什么必须有这一步**:通道那头的调用 resolve 成 `RemoteResult<T>`(`{ ok: true, value }` /
|
|
642
701
|
* `{ ok: false, error }`),失败是**返回**而不是 reject。组件面要的是「拿到值 / 抛异常」,
|
|
643
|
-
* 所以在这里展开一次:`ok ? value : throw
|
|
644
|
-
* (以及钉住它们的用例)保持裸值形状不变。
|
|
645
|
-
*
|
|
646
|
-
* **单条支里只有一条落地**:没有它(幽灵 id、或路由整次没办成)就 throw——"批量只回了一条别的"
|
|
647
|
-
* 不是成功,也不是 0。
|
|
702
|
+
* 所以在这里展开一次:`ok ? value : throw`。
|
|
648
703
|
*/
|
|
649
704
|
function buildDeps(raw) {
|
|
650
705
|
/** 一次 `apply`:顶层信封先展开(那是"这次调用办没办成"),逐条结果原样交给调用方。 */
|
|
@@ -653,22 +708,12 @@ window.__ModuleLoader__.load({
|
|
|
653
708
|
if (!result.ok) throwRemoteFailure(result.error);
|
|
654
709
|
return result.value;
|
|
655
710
|
};
|
|
656
|
-
/** N=1 的便捷支:**按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上)。
|
|
657
|
-
* 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里)。 */
|
|
658
|
-
const applyOne = async (action, id) => {
|
|
659
|
-
const outcome = (await applyOnce(action, [id])).find((candidate) => candidate.id === id);
|
|
660
|
-
if (outcome === void 0) throw new Error(`批量通道没回这一条的结果:${action} ${id}`);
|
|
661
|
-
if (!outcome.ok) throwRemoteFailure(outcome.error);
|
|
662
|
-
return outcome;
|
|
663
|
-
};
|
|
664
711
|
return {
|
|
665
712
|
list: () => raw.list().then((result) => unwrapRemoteResult(result)),
|
|
713
|
+
scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
|
|
666
714
|
detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
|
|
667
715
|
applyOutcomes: applyOnce,
|
|
668
|
-
emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
|
|
669
|
-
delete: async (sessionId) => (await applyOne("delete", sessionId)).value,
|
|
670
|
-
restore: async (entryId) => (await applyOne("restore", entryId)).value,
|
|
671
|
-
countSubagents: async (sessionId) => (await applyOne("count", sessionId)).value
|
|
716
|
+
emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
|
|
672
717
|
};
|
|
673
718
|
}
|
|
674
719
|
//#endregion
|
|
@@ -1621,7 +1666,7 @@ window.__ModuleLoader__.load({
|
|
|
1621
1666
|
/** 撤销:单条 `restore(entryId)`、批量**一次** `applyOutcomes('restore', entryIds)`;失败就把原因换到同一条横幅上,不静默。 */
|
|
1622
1667
|
async function undo(deps, current) {
|
|
1623
1668
|
if (current.kind === "one" && current.entryId === "") return;
|
|
1624
|
-
const outcome = current.kind === "one" ? await callRemote(() => deps
|
|
1669
|
+
const outcome = current.kind === "one" ? await callRemote(() => applyOne(deps, "restore", current.entryId).then((item) => item.value)) : await callRemote(() => deps.applyOutcomes("restore", current.entryIds));
|
|
1625
1670
|
if (outcome.ok) {
|
|
1626
1671
|
invalidateTrash();
|
|
1627
1672
|
clearUndoNotice();
|
|
@@ -1763,7 +1808,7 @@ window.__ModuleLoader__.load({
|
|
|
1763
1808
|
(0, react.useEffect)(() => {
|
|
1764
1809
|
let alive = true;
|
|
1765
1810
|
(async () => {
|
|
1766
|
-
const outcome = await callRemote(() => deps
|
|
1811
|
+
const outcome = await callRemote(() => applyOne(deps, "count", current.sessionId).then((item) => item.value));
|
|
1767
1812
|
if (alive) setSubagents(outcome.ok ? outcome.value : 0);
|
|
1768
1813
|
})();
|
|
1769
1814
|
return () => {
|
|
@@ -1773,7 +1818,7 @@ window.__ModuleLoader__.load({
|
|
|
1773
1818
|
const confirm = async () => {
|
|
1774
1819
|
setBusy(true);
|
|
1775
1820
|
setFailure(null);
|
|
1776
|
-
const outcome = await callRemote(() => deps
|
|
1821
|
+
const outcome = await callRemote(() => applyOne(deps, "delete", current.sessionId).then((item) => item.value));
|
|
1777
1822
|
setBusy(false);
|
|
1778
1823
|
if (!outcome.ok) {
|
|
1779
1824
|
setFailure(outcome.failure.message);
|
|
@@ -24696,180 +24741,7 @@ window.__ModuleLoader__.load({
|
|
|
24696
24741
|
return width;
|
|
24697
24742
|
}
|
|
24698
24743
|
//#endregion
|
|
24699
|
-
//#region src/client/trash-
|
|
24700
|
-
/**
|
|
24701
|
-
* 回收站清单面板(设计稿 §7 状态 ④⑤ + 2026-10-15 §2 的两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
|
|
24702
|
-
*
|
|
24703
|
-
* 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
|
|
24704
|
-
* (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
|
|
24705
|
-
* `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
|
|
24706
|
-
* 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
|
|
24707
|
-
*
|
|
24708
|
-
* **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(判据全在
|
|
24709
|
-
* `2026-10-15-trash-detail-design.md`,本节只记结论):
|
|
24710
|
-
* ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
|
|
24711
|
-
* 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
|
|
24712
|
-
* ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
|
|
24713
|
-
* window.resize 会漏**——宿主侧栏折叠/展开也改中央列宽,而且量的是面板自己那块的 **border-box 宽**。
|
|
24714
|
-
* 窄于此走下钻(详情铺满 + 返回),因为 2026-10-01 真机在 520px 窗量过中央列只有 405px,两栏在那
|
|
24715
|
-
* 儿两栏都读不了;900 这个数是按两栏各自要读得开算出来的(见 `SPLIT_MIN_WIDTH` 处);
|
|
24716
|
-
* ③ **动作留在清单行右侧**(2026-10-15 试过"退出行、住进详情",按真机反馈回到 2026-10-01 的形状):
|
|
24717
|
-
* 那一版左栏为了只放标题被压到 316px,用户第一眼就是"左侧太窄"。详情只负责看,失败原文也跟着
|
|
24718
|
-
* 动作留在它自己那一行。
|
|
24719
|
-
*
|
|
24720
|
-
* **卡片配色沿用 2026-10-01 那批**(宿主设置面卡片的底层 token,不是随包发布的 settings 别名):
|
|
24721
|
-
* `background:var(--dsw-alias-bg-layer-2)` + `border:.5px solid var(--dsw-alias-border-l4)` +
|
|
24722
|
-
* `border-radius:var(--dsw-radius-lg)`(20px 的 `xl` 会把 60px 上下的行吹成胶囊)。
|
|
24723
|
-
* 选中态换一枚 token(2026-10-15 从 asar 的 `dsh-client-ui-theme/lib/client.js` 逐字读出):
|
|
24724
|
-
* `--dsw-alias-button-ghost-active-fill`(浅 `#ebeef2` / 深 `#43454a`)配
|
|
24725
|
-
* `--dsw-alias-button-ghost-active-border`(`#979da6` / `#81858c`)——宿主自己侧栏选中项用的
|
|
24726
|
-
* `--dsw-specific-sidebar-nav-item-active` 与前者取值逐字相同,所以这是宿主"选中"那一档,不是我挑的。
|
|
24727
|
-
*
|
|
24728
|
-
* 时间分桶走 `relativeTime`(逐字抄自官方,见 `ui/format.ts`),项目名从宿主 `projectKey` 反推
|
|
24729
|
-
* (`unescapeProjectKey`,两半共用),组头 `title` 给完整原串。**行分隔**交给卡片自己的边框。
|
|
24730
|
-
*
|
|
24731
|
-
* 三条界面规则没有因为改版让路:
|
|
24732
|
-
* - **空态**:`回收站是空的`,且底部**不出现**「清空回收站…」(没有东西可清)。
|
|
24733
|
-
* - **失败写在出事的那一块上**:清单读不到 ⇒ 单独一块摆原文 + 重试(不是空态,"读不到"与"空的"是
|
|
24734
|
-
* 两件事);某条会话的正文读不到 ⇒ 只在详情那块说,清单照常可选;恢复/彻底删除失败 ⇒ 原因留在
|
|
24735
|
-
* 详情(按条目 id 存,切走再回来还在)。两块都标 `role="status"`——但**插入即播报并不保证**
|
|
24736
|
-
* (`aria-live` 看的是"已在场的区域发生了变更"),要稳得常驻一个只给读屏看的播报台,本机没有 AT
|
|
24737
|
-
* 可验,所以留在这里当已知边界,不写成已经做到。
|
|
24738
|
-
* - **危险层级**:整页只有一块实心红(页脚「清空回收站…」,确认框里那块是它的后续);行与详情里的
|
|
24739
|
-
* 「彻底删除…」是**安静的红**——只写 `color:--dsw-alias-state-error-primary`、**不写 background**
|
|
24740
|
-
* (作者态内联 `background` 永远赢过类规则,而 `ui.css` 里 `.sd-button-ghost:hover` / `:active` 都是
|
|
24741
|
-
* 普通类 ⇒ 铺了底的动作在点错之前不给任何视觉回应)。批量与确认框那款实心红的写法(`variant="primary"`
|
|
24742
|
-
* + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
|
|
24743
|
-
*
|
|
24744
|
-
* 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
|
|
24745
|
-
* 结构与层级归设计稿 §7 与 2026-10-15 §2,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
|
|
24746
|
-
* 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
|
|
24747
|
-
*
|
|
24748
|
-
* **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
|
|
24749
|
-
* ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
|
|
24750
|
-
* (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`),量法见 2026-10-15 §4 第 5 条。
|
|
24751
|
-
*/
|
|
24752
|
-
/**
|
|
24753
|
-
* 整页那唯一一块实心红:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
|
|
24754
|
-
* 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
|
|
24755
|
-
*/
|
|
24756
|
-
const BULK_DANGER = {
|
|
24757
|
-
"--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
|
|
24758
|
-
"--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
|
|
24759
|
-
};
|
|
24760
|
-
/** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。 */
|
|
24761
|
-
const TRASH_DIR = "session-trash/";
|
|
24762
|
-
/**
|
|
24763
|
-
* 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(设计稿 2026-10-15 §2.1,2026-10-15 真机后定值)。
|
|
24764
|
-
*
|
|
24765
|
-
* 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
|
|
24766
|
-
* 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
|
|
24767
|
-
* 详情至少要 399 ⇒ 地板 489,剩下的给清单。窄于此与其挤成两条读不了的柱子,不如下钻。
|
|
24768
|
-
* 900 这一档清单被夹到 411(比默认窄 9px),是 2026-10-15 按真 Chromium 量过之后定的:当时按"分隔 1px"
|
|
24769
|
-
* 算成 420 + 32 + 1 + 399 + 48,实际那格是 10px,于是清单让 9px 给详情,两边的可读下限都守住。
|
|
24770
|
-
*/
|
|
24771
|
-
const SPLIT_MIN_WIDTH = 900;
|
|
24772
|
-
/**
|
|
24773
|
-
* 左栏的**默认**宽(2026-10-15 起可拖,见下面 `LIST_COLUMN_MIN` / `DETAIL_RESERVE`)。
|
|
24774
|
-
* 这个数是**为"行内有动作"设计的**(2026-10-15 真机反馈后定):两颗按钮(「恢复」描边 +
|
|
24775
|
-
* 「彻底删除…」安静的红)连 gap 约 152px,卡片左右内边距 28px,剩下 ~240px 给标题与
|
|
24776
|
-
* 「删除于 … · 大小」那一行——316 是"只放标题"那一版的宽度,动作搬回来之后它就不成立了。
|
|
24777
|
-
*/
|
|
24778
|
-
const LIST_COLUMN_DEFAULT = 420;
|
|
24779
|
-
/**
|
|
24780
|
-
* 左栏下限。**量出来的,不是算出来的**(2026-10-15,真 Chromium 上按 10px 一档扫 240→420):
|
|
24781
|
-
* 两颗按钮从来不会被挤掉——它们 `flex:none`,再窄也是**文字列**缩,所以"放得下按钮"根本不是约束
|
|
24782
|
-
* (我第一版按这个算出 280,是错的)。真正的下沿是「删除于 … · 224KB」那一行什么时候不再被省略号
|
|
24783
|
-
* 截断:280 差 50px、320 差 10px、**330 归零**。定在 330。
|
|
24784
|
-
*/
|
|
24785
|
-
const LIST_COLUMN_MIN = 330;
|
|
24786
|
-
/**
|
|
24787
|
-
* 拖到最右时给详情那一侧留的地板:正文可读最小宽 399 + 那一格的 90(两道 gap 32、把手 10、页内边距 48)。
|
|
24788
|
-
* 于是 `900 − 489 = 411`——断点下沿那一档**清单抢不动宽度**(而且它比默认 420 还窄 9px),这是对的:
|
|
24789
|
-
* 那一档两栏刚好塞满,再让清单抢宽就是把详情挤回"读不开"的样子(上一轮"右侧没有渲染"就是这么来的)。
|
|
24790
|
-
*/
|
|
24791
|
-
const DETAIL_RESERVE = 489;
|
|
24792
|
-
/** 分隔线键盘一格的像素数(←→)。 */
|
|
24793
|
-
const HANDLE_STEP = 20;
|
|
24794
|
-
/** 详情正文的收口宽度:中央列再宽也不把文字拉成长线(原来那条"整列限宽 760"的关心得换成只限正文)。 */
|
|
24795
|
-
const DETAIL_TEXT_MAX_WIDTH = 860;
|
|
24796
|
-
/**
|
|
24797
|
-
* 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
|
|
24798
|
-
*
|
|
24799
|
-
* `boxSizing:'border-box'` **不是装饰**:宿主文档没有全局 border-box reset(220 处 `box-sizing`
|
|
24800
|
-
* 声明全是组件自己带的),默认是 content-box ⇒ `height:100%` 再加 20px 上边距会溢出 20px,
|
|
24801
|
-
* 把页脚整块推到中央列底边之外(真机浅色截图上页脚贴着窗口下缘就是这个)。
|
|
24802
|
-
*/
|
|
24803
|
-
const PAGE = {
|
|
24804
|
-
display: "flex",
|
|
24805
|
-
flexDirection: "column",
|
|
24806
|
-
boxSizing: "border-box",
|
|
24807
|
-
height: "100%",
|
|
24808
|
-
minWidth: 0,
|
|
24809
|
-
padding: "20px 24px 12px"
|
|
24810
|
-
};
|
|
24811
|
-
/** 两栏/下钻那一行(页脚之上,自收滚动)。 */
|
|
24812
|
-
const BODY = {
|
|
24813
|
-
display: "flex",
|
|
24814
|
-
flex: "1 1 auto",
|
|
24815
|
-
gap: 16,
|
|
24816
|
-
minHeight: 0,
|
|
24817
|
-
minWidth: 0
|
|
24818
|
-
};
|
|
24819
|
-
/**
|
|
24820
|
-
* 清单那一栏的**外壳**(宽与 `flex` 不在这里——由调用点在渲染时现给:两栏时是可拖的定宽,
|
|
24821
|
-
* 单列时 `flex:'1 1 auto'` 铺满)。两栏不成立时(窄档,或宽档但清单为空/读不到)同一个配方
|
|
24822
|
-
* 直接铺满:**不再限宽 760 居中**,那条关心得只适用于正文(见 `DETAIL_TEXT_MAX_WIDTH`)。
|
|
24823
|
-
*
|
|
24824
|
-
* **滚动条不用注入任何 CSS 就已经是宿主的**:宿主在 `body` 上给出一组量(`--dsh-scrollbar-width:5px`
|
|
24825
|
-
* 等),再用全局 `::-webkit-scrollbar*` 规则消费它们(asar @45064180 起)⇒ 这里一个 `overflow:auto`
|
|
24826
|
-
* 拿到的就是 5px 圆角 token 条。下面把 thumb 提到 `-l2` 那一档,与宿主自己的长滚动面同档。
|
|
24827
|
-
*/
|
|
24828
|
-
const LIST_COLUMN = {
|
|
24829
|
-
display: "flex",
|
|
24830
|
-
flexDirection: "column",
|
|
24831
|
-
boxSizing: "border-box",
|
|
24832
|
-
gap: 7,
|
|
24833
|
-
minWidth: 0,
|
|
24834
|
-
minHeight: 0,
|
|
24835
|
-
overflow: "auto",
|
|
24836
|
-
paddingRight: 10,
|
|
24837
|
-
paddingBottom: 8,
|
|
24838
|
-
"--dsh-scrollbar-thumb": "var(--dsw-alias-scrollbar-bg-l2)",
|
|
24839
|
-
"--dsh-scrollbar-thumb-hover": "var(--dsw-alias-scrollbar-hover-l2)"
|
|
24840
|
-
};
|
|
24841
|
-
/** 详情那一栏的外壳(滚动归它自己,正文限宽在内部)。 */
|
|
24842
|
-
const DETAIL_COLUMN = {
|
|
24843
|
-
display: "flex",
|
|
24844
|
-
flex: "1 1 auto",
|
|
24845
|
-
minWidth: 0,
|
|
24846
|
-
minHeight: 0,
|
|
24847
|
-
overflow: "auto",
|
|
24848
|
-
paddingRight: 10,
|
|
24849
|
-
paddingBottom: 8,
|
|
24850
|
-
"--dsh-scrollbar-thumb": "var(--dsw-alias-scrollbar-bg-l2)",
|
|
24851
|
-
"--dsh-scrollbar-thumb-hover": "var(--dsw-alias-scrollbar-hover-l2)"
|
|
24852
|
-
};
|
|
24853
|
-
const DETAIL_INNER = {
|
|
24854
|
-
minWidth: 0,
|
|
24855
|
-
maxWidth: DETAIL_TEXT_MAX_WIDTH,
|
|
24856
|
-
width: "100%"
|
|
24857
|
-
};
|
|
24858
|
-
/**
|
|
24859
|
-
* 那一排视图开关(全部折叠 / 全部展开、多选;「刷新」图标 2026-10-16 已删,见 `toolbar` 那处注释)。
|
|
24860
|
-
* 它**住在面板体顶端、不在清单列里**:原先在滚动容器内,长清单滚到底会把开关滚走(2026-10-15
|
|
24861
|
-
* 真机反馈后搬出来的理由,与已经删掉的「整条左栏收起」无关)。宽度上限由用它的地方现给——两栏时
|
|
24862
|
-
* 跟着可拖的清单列宽,单列时不设上限。
|
|
24863
|
-
*/
|
|
24864
|
-
const TOOLBAR = {
|
|
24865
|
-
display: "flex",
|
|
24866
|
-
alignItems: "center",
|
|
24867
|
-
gap: 6,
|
|
24868
|
-
flex: "none",
|
|
24869
|
-
minWidth: 0,
|
|
24870
|
-
width: "100%",
|
|
24871
|
-
paddingBottom: 8
|
|
24872
|
-
};
|
|
24744
|
+
//#region src/client/trash-view.ts
|
|
24873
24745
|
/** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
|
|
24874
24746
|
const CARD = {
|
|
24875
24747
|
display: "flex",
|
|
@@ -24881,27 +24753,25 @@ window.__ModuleLoader__.load({
|
|
|
24881
24753
|
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
24882
24754
|
borderRadius: "var(--dsw-radius-lg)"
|
|
24883
24755
|
};
|
|
24884
|
-
/** 选中行:底色与描边都换成宿主"选中"
|
|
24756
|
+
/** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
|
|
24885
24757
|
const CARD_SELECTED = {
|
|
24886
24758
|
...CARD,
|
|
24887
24759
|
background: "var(--dsw-alias-button-ghost-active-fill)",
|
|
24888
24760
|
border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
|
|
24889
24761
|
};
|
|
24890
24762
|
/**
|
|
24891
|
-
*
|
|
24892
|
-
*
|
|
24763
|
+
* **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
|
|
24764
|
+
* 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
|
|
24893
24765
|
*/
|
|
24894
|
-
|
|
24895
|
-
|
|
24896
|
-
|
|
24897
|
-
|
|
24898
|
-
unit: unitWord(unit, t)
|
|
24899
|
-
});
|
|
24900
|
-
}
|
|
24766
|
+
const BULK_DANGER = {
|
|
24767
|
+
"--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
|
|
24768
|
+
"--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
|
|
24769
|
+
};
|
|
24901
24770
|
/**
|
|
24902
|
-
*
|
|
24903
|
-
*
|
|
24904
|
-
*
|
|
24771
|
+
* 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
|
|
24772
|
+
* 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
|
|
24773
|
+
* 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
|
|
24774
|
+
* 字母表只有一份:还原走 `shared/wire.ts`。
|
|
24905
24775
|
*/
|
|
24906
24776
|
function projectLabel(projectDir, t) {
|
|
24907
24777
|
const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
|
|
@@ -24922,84 +24792,662 @@ window.__ModuleLoader__.load({
|
|
|
24922
24792
|
entries: list
|
|
24923
24793
|
})).sort((a, b) => b.latest - a.latest);
|
|
24924
24794
|
}
|
|
24795
|
+
//#endregion
|
|
24796
|
+
//#region src/client/trash-confirm.tsx
|
|
24797
|
+
/** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
|
|
24798
|
+
const CARD_LIST = {
|
|
24799
|
+
display: "flex",
|
|
24800
|
+
flexDirection: "column",
|
|
24801
|
+
gap: 6,
|
|
24802
|
+
maxHeight: 280,
|
|
24803
|
+
overflow: "auto",
|
|
24804
|
+
marginBlockEnd: 10
|
|
24805
|
+
};
|
|
24925
24806
|
/**
|
|
24926
|
-
*
|
|
24927
|
-
*
|
|
24807
|
+
* 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
|
|
24808
|
+
* 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
|
|
24809
|
+
* 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
|
|
24810
|
+
* 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
|
|
24928
24811
|
*/
|
|
24929
|
-
function
|
|
24930
|
-
|
|
24931
|
-
|
|
24932
|
-
|
|
24933
|
-
|
|
24934
|
-
|
|
24935
|
-
|
|
24936
|
-
|
|
24937
|
-
|
|
24938
|
-
|
|
24939
|
-
|
|
24940
|
-
|
|
24941
|
-
|
|
24942
|
-
|
|
24943
|
-
|
|
24944
|
-
|
|
24945
|
-
|
|
24946
|
-
|
|
24947
|
-
|
|
24948
|
-
|
|
24949
|
-
|
|
24950
|
-
|
|
24951
|
-
|
|
24952
|
-
|
|
24953
|
-
|
|
24954
|
-
|
|
24955
|
-
|
|
24956
|
-
|
|
24957
|
-
|
|
24958
|
-
|
|
24959
|
-
|
|
24960
|
-
|
|
24961
|
-
|
|
24962
|
-
|
|
24963
|
-
|
|
24964
|
-
|
|
24965
|
-
|
|
24966
|
-
|
|
24967
|
-
|
|
24968
|
-
|
|
24969
|
-
|
|
24970
|
-
|
|
24971
|
-
|
|
24972
|
-
|
|
24973
|
-
|
|
24974
|
-
|
|
24975
|
-
|
|
24976
|
-
|
|
24977
|
-
|
|
24978
|
-
|
|
24979
|
-
|
|
24980
|
-
|
|
24981
|
-
|
|
24982
|
-
|
|
24983
|
-
|
|
24984
|
-
|
|
24985
|
-
|
|
24986
|
-
|
|
24987
|
-
|
|
24988
|
-
|
|
24989
|
-
|
|
24990
|
-
|
|
24991
|
-
|
|
24812
|
+
function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
|
|
24813
|
+
if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
24814
|
+
open: true,
|
|
24815
|
+
onClose: () => {
|
|
24816
|
+
if (!busy) onCancel();
|
|
24817
|
+
},
|
|
24818
|
+
title: t("trash.confirm.restore", { count: pending.ids.length }),
|
|
24819
|
+
closeLabel: t("common.close"),
|
|
24820
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24821
|
+
variant: "outline",
|
|
24822
|
+
disabled: busy,
|
|
24823
|
+
onClick: onCancel,
|
|
24824
|
+
children: t("common.cancel")
|
|
24825
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24826
|
+
variant: "primary",
|
|
24827
|
+
disabled: busy,
|
|
24828
|
+
onClick: () => onConfirmRestore(pending.ids),
|
|
24829
|
+
children: t("trash.confirm.restoreNow")
|
|
24830
|
+
})] }),
|
|
24831
|
+
children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24832
|
+
style: CARD_LIST,
|
|
24833
|
+
children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
24834
|
+
style: {
|
|
24835
|
+
...CARD,
|
|
24836
|
+
flexDirection: "column",
|
|
24837
|
+
alignItems: "stretch",
|
|
24838
|
+
gap: 2,
|
|
24839
|
+
padding: "9px 12px"
|
|
24840
|
+
},
|
|
24841
|
+
children: [
|
|
24842
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24843
|
+
style: {
|
|
24844
|
+
...ELLIPSIS,
|
|
24845
|
+
fontSize: 13.5,
|
|
24846
|
+
lineHeight: "19px"
|
|
24847
|
+
},
|
|
24848
|
+
children: entry.title === "" ? t("common.untitled") : entry.title
|
|
24849
|
+
}),
|
|
24850
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24851
|
+
style: {
|
|
24852
|
+
...ELLIPSIS,
|
|
24853
|
+
fontSize: 12,
|
|
24854
|
+
lineHeight: "16px",
|
|
24855
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
24856
|
+
},
|
|
24857
|
+
children: projectLabel(entry.projectDir, t)
|
|
24858
|
+
}),
|
|
24859
|
+
entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24860
|
+
style: {
|
|
24861
|
+
fontSize: 12,
|
|
24862
|
+
lineHeight: "16px",
|
|
24863
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
24864
|
+
},
|
|
24865
|
+
children: t("trash.row.workspaceGone")
|
|
24866
|
+
}) : null
|
|
24867
|
+
]
|
|
24868
|
+
}, entry.id))
|
|
24869
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
|
|
24870
|
+
});
|
|
24871
|
+
const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
|
|
24872
|
+
const strong = t("trash.confirm.body.strong");
|
|
24873
|
+
const body = splitOn(t("trash.confirm.body", { strong }), strong);
|
|
24874
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
24875
|
+
open: true,
|
|
24876
|
+
onClose: () => {
|
|
24877
|
+
if (!busy) onCancel();
|
|
24878
|
+
},
|
|
24879
|
+
title,
|
|
24880
|
+
closeLabel: t("common.close"),
|
|
24881
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24882
|
+
variant: "outline",
|
|
24883
|
+
disabled: busy,
|
|
24884
|
+
onClick: onCancel,
|
|
24885
|
+
children: t("common.cancel")
|
|
24886
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24887
|
+
variant: "primary",
|
|
24888
|
+
style: BULK_DANGER,
|
|
24889
|
+
disabled: busy,
|
|
24890
|
+
onClick: onConfirmPurge,
|
|
24891
|
+
children: t("trash.confirm.purge")
|
|
24892
|
+
})] }),
|
|
24893
|
+
children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
24894
|
+
style: {
|
|
24895
|
+
...CARD,
|
|
24896
|
+
flexDirection: "column",
|
|
24897
|
+
alignItems: "stretch",
|
|
24898
|
+
gap: 2,
|
|
24899
|
+
padding: "10px 12px",
|
|
24900
|
+
marginBlockEnd: 10
|
|
24901
|
+
},
|
|
24902
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24903
|
+
style: {
|
|
24904
|
+
...ELLIPSIS,
|
|
24905
|
+
fontSize: 13.5,
|
|
24906
|
+
lineHeight: "19px"
|
|
24907
|
+
},
|
|
24908
|
+
children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
|
|
24909
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24910
|
+
style: {
|
|
24911
|
+
...ELLIPSIS,
|
|
24912
|
+
fontSize: 12,
|
|
24913
|
+
lineHeight: "16px",
|
|
24914
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
24915
|
+
},
|
|
24916
|
+
children: t("trash.confirm.one.meta", {
|
|
24917
|
+
project: projectLabel(pending.entry.projectDir, t),
|
|
24918
|
+
at: absoluteText(pending.entry.deletedAt),
|
|
24919
|
+
size: fileSizeText(pending.entry.releaseBytes)
|
|
24920
|
+
})
|
|
24921
|
+
})]
|
|
24922
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
|
|
24923
|
+
body[0],
|
|
24924
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
|
|
24925
|
+
style: { color: "var(--dsw-alias-state-error-primary)" },
|
|
24926
|
+
children: body[1]
|
|
24927
|
+
}),
|
|
24928
|
+
body[2]
|
|
24929
|
+
] })]
|
|
24930
|
+
});
|
|
24931
|
+
}
|
|
24932
|
+
/** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
|
|
24933
|
+
function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
|
|
24934
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
24935
|
+
open: true,
|
|
24936
|
+
onClose: () => {
|
|
24937
|
+
if (!busy) onCancel();
|
|
24938
|
+
},
|
|
24939
|
+
title: t("trash.orphans.confirm", { count: report.totalSessions }),
|
|
24940
|
+
closeLabel: t("common.close"),
|
|
24941
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24942
|
+
variant: "outline",
|
|
24943
|
+
disabled: busy,
|
|
24944
|
+
onClick: onCancel,
|
|
24945
|
+
children: t("common.cancel")
|
|
24946
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
24947
|
+
variant: "primary",
|
|
24948
|
+
style: BULK_DANGER,
|
|
24949
|
+
disabled: busy,
|
|
24950
|
+
onClick: onConfirm,
|
|
24951
|
+
children: t("trash.orphans.clean")
|
|
24952
|
+
})] }),
|
|
24953
|
+
children: [
|
|
24954
|
+
report.groups.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24955
|
+
style: CARD_LIST,
|
|
24956
|
+
children: report.groups.map((group) => {
|
|
24957
|
+
const text = t("trash.orphans.group", {
|
|
24958
|
+
project: projectLabel(group.projectDir, t),
|
|
24959
|
+
count: group.sessions,
|
|
24960
|
+
size: fileSizeText(group.bytes)
|
|
24961
|
+
});
|
|
24962
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
24963
|
+
title: text,
|
|
24964
|
+
style: {
|
|
24965
|
+
...CARD,
|
|
24966
|
+
padding: "9px 12px",
|
|
24967
|
+
fontSize: 13,
|
|
24968
|
+
lineHeight: "19px"
|
|
24969
|
+
},
|
|
24970
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
24971
|
+
style: {
|
|
24972
|
+
...ELLIPSIS,
|
|
24973
|
+
minWidth: 0,
|
|
24974
|
+
flex: "1 1 auto"
|
|
24975
|
+
},
|
|
24976
|
+
children: text
|
|
24977
|
+
})
|
|
24978
|
+
}, group.projectDir);
|
|
24979
|
+
})
|
|
24980
|
+
}),
|
|
24981
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.orphans.body") }),
|
|
24982
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
24983
|
+
style: {
|
|
24984
|
+
fontSize: 12,
|
|
24985
|
+
lineHeight: "17px",
|
|
24986
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
24987
|
+
},
|
|
24988
|
+
children: t("trash.orphans.cacheNote")
|
|
24989
|
+
})
|
|
24990
|
+
]
|
|
24991
|
+
});
|
|
24992
|
+
}
|
|
24993
|
+
//#endregion
|
|
24994
|
+
//#region src/client/use-trash-query.ts
|
|
24995
|
+
/**
|
|
24996
|
+
* 清册读数与无主子会话的**取数编排**(架构复核候选 3 的第二道内部 seam)。
|
|
24997
|
+
*
|
|
24998
|
+
* 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
|
|
24999
|
+
* ① **到达序号**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
|
|
25000
|
+
* 后到的旧响应不许盖掉刚发生的动作带来的新状态;
|
|
25001
|
+
* ② **先清单后扫描**:`GET /orphans` 判"父没了"吃的是清册,而清册要由 `/list` 那次对账才与
|
|
25002
|
+
* `session-trash/` 的目录持平——反过来接,刚被手工搬进回收站的父会被报成失踪父;
|
|
25003
|
+
* ③ **读不到就说读不到**:`/orphans` 回 `sessiondelete/io` 是一次**失败的检查**,不许折成"零条残留"。
|
|
25004
|
+
*
|
|
25005
|
+
* 清理(`/apply` 的 `orphan-clean`)也住这里:它与扫描共用同一份报告与同一组提示,"读"与"写"分家
|
|
25006
|
+
* 会让 `busy` 与重读顺序那条判据劈成两半。`busy` 本身不归它管——那是面板的一枚全局闸,通过
|
|
25007
|
+
* `around` 传进来(`around` 里跑的就是"这一趟还算忙")。
|
|
25008
|
+
*/
|
|
25009
|
+
function useTrashQuery({ deps, t, around }) {
|
|
25010
|
+
const [entries, setEntries] = (0, react.useState)(null);
|
|
25011
|
+
const [listFailure, setListFailure] = (0, react.useState)(null);
|
|
25012
|
+
const [orphanReport, setOrphanReport] = (0, react.useState)(null);
|
|
25013
|
+
const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
|
|
25014
|
+
const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
|
|
25015
|
+
const [orphanConfirm, setOrphanConfirm] = (0, react.useState)(false);
|
|
25016
|
+
const reloadSeq = (0, react.useRef)(0);
|
|
25017
|
+
const reload = (0, react.useCallback)(async () => {
|
|
25018
|
+
const seq = ++reloadSeq.current;
|
|
25019
|
+
const outcome = await callRemote(() => deps.list());
|
|
25020
|
+
if (seq !== reloadSeq.current) return false;
|
|
25021
|
+
if (!outcome.ok) {
|
|
25022
|
+
setListFailure(outcome.failure.message);
|
|
25023
|
+
setEntries(null);
|
|
25024
|
+
return false;
|
|
25025
|
+
}
|
|
25026
|
+
setListFailure(null);
|
|
24992
25027
|
setEntries(outcome.value);
|
|
25028
|
+
return true;
|
|
25029
|
+
}, [deps]);
|
|
25030
|
+
const orphanScanSeq = (0, react.useRef)(0);
|
|
25031
|
+
const scanOrphans = (0, react.useCallback)(async () => {
|
|
25032
|
+
const seq = ++orphanScanSeq.current;
|
|
25033
|
+
const outcome = await callRemote(() => deps.scanOrphans());
|
|
25034
|
+
if (seq !== orphanScanSeq.current) return;
|
|
25035
|
+
if (!outcome.ok) {
|
|
25036
|
+
setOrphanReport(null);
|
|
25037
|
+
setOrphanNotice(null);
|
|
25038
|
+
setOrphanConfirm(false);
|
|
25039
|
+
setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
|
|
25040
|
+
return;
|
|
25041
|
+
}
|
|
25042
|
+
setOrphanReport(outcome.value);
|
|
25043
|
+
setOrphanFailure(null);
|
|
25044
|
+
setOrphanNotice(null);
|
|
25045
|
+
}, [deps, t]);
|
|
25046
|
+
const refresh = (0, react.useCallback)(async () => {
|
|
25047
|
+
if (!await reload()) {
|
|
25048
|
+
setOrphanReport(null);
|
|
25049
|
+
setOrphanFailure(null);
|
|
25050
|
+
setOrphanNotice(null);
|
|
25051
|
+
setOrphanConfirm(false);
|
|
25052
|
+
return;
|
|
25053
|
+
}
|
|
25054
|
+
await scanOrphans();
|
|
25055
|
+
}, [reload, scanOrphans]);
|
|
25056
|
+
const emptyTrash = (0, react.useCallback)(() => around(async () => {
|
|
25057
|
+
const outcome = await callRemote(() => deps.emptyTrash());
|
|
25058
|
+
if (!outcome.ok) {
|
|
25059
|
+
setListFailure(outcome.failure.message);
|
|
25060
|
+
return false;
|
|
25061
|
+
}
|
|
25062
|
+
await refresh();
|
|
25063
|
+
return true;
|
|
25064
|
+
}), [
|
|
25065
|
+
around,
|
|
25066
|
+
deps,
|
|
25067
|
+
refresh
|
|
25068
|
+
]);
|
|
25069
|
+
return {
|
|
25070
|
+
entries,
|
|
25071
|
+
listFailure,
|
|
25072
|
+
orphanReport,
|
|
25073
|
+
orphanFailure,
|
|
25074
|
+
orphanNotice,
|
|
25075
|
+
orphanConfirm,
|
|
25076
|
+
setOrphanConfirm,
|
|
25077
|
+
openOrphanPreview: () => {
|
|
25078
|
+
setOrphanFailure(null);
|
|
25079
|
+
setOrphanNotice(null);
|
|
25080
|
+
setOrphanConfirm(true);
|
|
25081
|
+
},
|
|
25082
|
+
reload,
|
|
25083
|
+
scanOrphans,
|
|
25084
|
+
refresh,
|
|
25085
|
+
cleanOrphans: (0, react.useCallback)(() => {
|
|
25086
|
+
if (orphanReport === null || orphanReport.roots.length === 0) {
|
|
25087
|
+
setOrphanConfirm(false);
|
|
25088
|
+
return Promise.resolve();
|
|
25089
|
+
}
|
|
25090
|
+
return around(async () => {
|
|
25091
|
+
setOrphanFailure(null);
|
|
25092
|
+
setOrphanNotice(null);
|
|
25093
|
+
const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", orphanReport.roots));
|
|
25094
|
+
setOrphanConfirm(false);
|
|
25095
|
+
await refresh();
|
|
25096
|
+
if (!outcome.ok) {
|
|
25097
|
+
setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
|
|
25098
|
+
return;
|
|
25099
|
+
}
|
|
25100
|
+
const failures = outcome.value.filter((item) => !item.ok);
|
|
25101
|
+
const gone = failures.filter((item) => item.error.code === SESSION_DELETE_CODES["not-found"]);
|
|
25102
|
+
const failed = failures.filter((item) => item.error.code !== SESSION_DELETE_CODES["not-found"]);
|
|
25103
|
+
const partialRemoved = failed.reduce((sum, item) => sum + detailNumber(item.error.details, "removed"), 0);
|
|
25104
|
+
const partialBytes = failed.reduce((sum, item) => sum + detailNumber(item.error.details, "freedBytes"), 0);
|
|
25105
|
+
if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
|
|
25106
|
+
if (failed.length > 0) {
|
|
25107
|
+
const base = t("trash.orphans.cleanFailed", {
|
|
25108
|
+
count: failed.length,
|
|
25109
|
+
message: failed[0].error.message
|
|
25110
|
+
});
|
|
25111
|
+
const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
|
|
25112
|
+
removed: partialRemoved,
|
|
25113
|
+
size: fileSizeText(partialBytes)
|
|
25114
|
+
})}` : "";
|
|
25115
|
+
setOrphanFailure(`${base}${partial}`);
|
|
25116
|
+
}
|
|
25117
|
+
});
|
|
25118
|
+
}, [
|
|
25119
|
+
around,
|
|
25120
|
+
deps,
|
|
25121
|
+
orphanReport,
|
|
25122
|
+
refresh,
|
|
25123
|
+
t
|
|
25124
|
+
]),
|
|
25125
|
+
emptyTrash
|
|
25126
|
+
};
|
|
25127
|
+
}
|
|
25128
|
+
/** 失败 `details` 里的非负数字(`orphan-clean` 的部分回执 `removed` / `freedBytes`);其余一律当 0。 */
|
|
25129
|
+
function detailNumber(details, key) {
|
|
25130
|
+
const value = details?.[key];
|
|
25131
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
25132
|
+
}
|
|
25133
|
+
//#endregion
|
|
25134
|
+
//#region src/client/use-trash-selection.ts
|
|
25135
|
+
/**
|
|
25136
|
+
* 清单上的**选择面**(架构复核候选 3 的第三道内部 seam):多选态、折叠起来的组、勾选集合,
|
|
25137
|
+
* 以及由它们派生的三样只读(`checkedIds` / `ordered` / 两种三态判定)。
|
|
25138
|
+
*
|
|
25139
|
+
* 从 `trash-panel.tsx` 的函数体里整块搬出来的两条承重规矩:
|
|
25140
|
+
* - **勾选顺序 = 清单从上往下**(组间按各组最新一条、组内按删除时间),不是点击顺序:页脚两颗按
|
|
25141
|
+
* **屏幕上的顺序**执行,否则"我先后点了哪几条"会改变一批动作的执行次序读感。折叠组里的条目
|
|
25142
|
+
* 照样算在内——折叠只是不收它们进 DOM。
|
|
25143
|
+
* - **清单换了就把已经不存在的勾选剔掉**:别处(侧栏菜单、撤销提示)动过回收站时,留着一条
|
|
25144
|
+
* "看不见的勾选"会让页脚那颗带着它去删一条已经不在的东西。
|
|
25145
|
+
*
|
|
25146
|
+
* 组装这件事不在这里:`groups` 由面板(或它的取数一侧)算好再传进来,这个 module 只回答
|
|
25147
|
+
* "哪些被勾了、屏幕上从上到下是哪些行"。
|
|
25148
|
+
*/
|
|
25149
|
+
function useTrashSelection({ entries, groups }) {
|
|
25150
|
+
const [selectMode, setSelectMode] = (0, react.useState)(false);
|
|
25151
|
+
const [checked, setChecked] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
25152
|
+
/**
|
|
25153
|
+
* 折叠起来的组(键 = `projectDir`,也就是组头那一份身份)。**只活在面板这次打开**:
|
|
25154
|
+
* 这是一层视图偏好,为它开一条 Node 半的读写通道不值,而宿主 webview 的 origin 是不透明的
|
|
25155
|
+
* `null`(AGENTS.md 实测),`localStorage` 那条路在真机上大概率直接抛。
|
|
25156
|
+
*/
|
|
25157
|
+
const [collapsedGroups, setCollapsedGroups] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
25158
|
+
(0, react.useEffect)(() => {
|
|
25159
|
+
if (entries === null) return;
|
|
24993
25160
|
setChecked((current) => {
|
|
24994
25161
|
if (current.size === 0) return current;
|
|
24995
|
-
const alive = new Set(
|
|
25162
|
+
const alive = new Set(entries.map((entry) => entry.id));
|
|
24996
25163
|
const next = new Set([...current].filter((id) => alive.has(id)));
|
|
24997
25164
|
return next.size === current.size ? current : next;
|
|
24998
25165
|
});
|
|
24999
|
-
}, [
|
|
25166
|
+
}, [entries]);
|
|
25167
|
+
const checkedIds = (0, react.useMemo)(() => groups.flatMap((group) => group.entries).filter((entry) => checked.has(entry.id)).map((entry) => entry.id), [groups, checked]);
|
|
25168
|
+
const isCollapsed = (projectDir) => collapsedGroups.has(projectDir);
|
|
25169
|
+
const allCollapsed = groups.length > 0 && groups.every((group) => isCollapsed(group.projectDir));
|
|
25170
|
+
const ordered = (0, react.useMemo)(() => groups.filter((group) => !isCollapsed(group.projectDir)).flatMap((group) => group.entries), [groups, collapsedGroups]);
|
|
25171
|
+
const groupFullyChecked = (group) => group.entries.length > 0 && group.entries.every((entry) => checked.has(entry.id));
|
|
25172
|
+
const groupPartiallyChecked = (group) => !groupFullyChecked(group) && group.entries.some((entry) => checked.has(entry.id));
|
|
25173
|
+
return {
|
|
25174
|
+
selectMode,
|
|
25175
|
+
checked,
|
|
25176
|
+
checkedIds,
|
|
25177
|
+
ordered,
|
|
25178
|
+
allCollapsed,
|
|
25179
|
+
isCollapsed,
|
|
25180
|
+
enterSelect: () => {
|
|
25181
|
+
setSelectMode(true);
|
|
25182
|
+
},
|
|
25183
|
+
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
25184
|
+
exitSelect: () => {
|
|
25185
|
+
setSelectMode(false);
|
|
25186
|
+
setChecked(/* @__PURE__ */ new Set());
|
|
25187
|
+
},
|
|
25188
|
+
toggleGroup: (projectDir) => {
|
|
25189
|
+
setCollapsedGroups((current) => {
|
|
25190
|
+
const next = new Set(current);
|
|
25191
|
+
if (next.delete(projectDir)) return next;
|
|
25192
|
+
next.add(projectDir);
|
|
25193
|
+
return next;
|
|
25194
|
+
});
|
|
25195
|
+
},
|
|
25196
|
+
toggleAllGroups: () => {
|
|
25197
|
+
setCollapsedGroups(allCollapsed ? /* @__PURE__ */ new Set() : new Set(groups.map((group) => group.projectDir)));
|
|
25198
|
+
},
|
|
25199
|
+
toggleCheck: (entryId) => {
|
|
25200
|
+
setChecked((current) => {
|
|
25201
|
+
const next = new Set(current);
|
|
25202
|
+
if (next.delete(entryId)) return next;
|
|
25203
|
+
next.add(entryId);
|
|
25204
|
+
return next;
|
|
25205
|
+
});
|
|
25206
|
+
},
|
|
25207
|
+
toggleGroupCheck: (group) => {
|
|
25208
|
+
const ids = group.entries.map((entry) => entry.id);
|
|
25209
|
+
const clear = groupFullyChecked(group);
|
|
25210
|
+
setChecked((current) => {
|
|
25211
|
+
const next = new Set(current);
|
|
25212
|
+
for (const id of ids) if (clear) next.delete(id);
|
|
25213
|
+
else next.add(id);
|
|
25214
|
+
return next;
|
|
25215
|
+
});
|
|
25216
|
+
},
|
|
25217
|
+
groupFullyChecked,
|
|
25218
|
+
groupPartiallyChecked
|
|
25219
|
+
};
|
|
25220
|
+
}
|
|
25221
|
+
//#endregion
|
|
25222
|
+
//#region src/client/trash-panel.tsx
|
|
25223
|
+
/**
|
|
25224
|
+
* 回收站清单面板(设计稿 §7 状态 ④⑤ + 2026-10-15 §2 的两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
|
|
25225
|
+
*
|
|
25226
|
+
* 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
|
|
25227
|
+
* (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
|
|
25228
|
+
* `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
|
|
25229
|
+
* 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
|
|
25230
|
+
*
|
|
25231
|
+
* **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(判据全在
|
|
25232
|
+
* `docs/design/2026-10-15-trash-detail-design.md` §6,本节只记结论;**现行界面规格住 `docs/design/
|
|
25233
|
+
* 2026-09-30-session-delete-design.md` §7**——那份是批次日志,§7 之后各批会改写它):
|
|
25234
|
+
* ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
|
|
25235
|
+
* 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
|
|
25236
|
+
* ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
|
|
25237
|
+
* window.resize 会漏**——宿主侧栏折叠/展开也改中央列宽,而且量的是面板自己那块的 **border-box 宽**。
|
|
25238
|
+
* 窄于此走下钻(详情铺满 + 返回),因为 2026-10-01 真机在 520px 窗量过中央列只有 405px,两栏在那
|
|
25239
|
+
* 儿两栏都读不了;900 这个数是按两栏各自要读得开算出来的(见 `SPLIT_MIN_WIDTH` 处);
|
|
25240
|
+
* ③ **动作留在清单行右侧**(2026-10-15 试过"退出行、住进详情",按真机反馈回到 2026-10-01 的形状):
|
|
25241
|
+
* 那一版左栏为了只放标题被压到 316px,用户第一眼就是"左侧太窄"。详情只负责看,失败原文也跟着
|
|
25242
|
+
* 动作留在它自己那一行。
|
|
25243
|
+
*
|
|
25244
|
+
* **卡片配色沿用 2026-10-01 那批**(宿主设置面卡片的底层 token,不是随包发布的 settings 别名):
|
|
25245
|
+
* `background:var(--dsw-alias-bg-layer-2)` + `border:.5px solid var(--dsw-alias-border-l4)` +
|
|
25246
|
+
* `border-radius:var(--dsw-radius-lg)`(20px 的 `xl` 会把 60px 上下的行吹成胶囊)。
|
|
25247
|
+
* 选中态换一枚 token(2026-10-15 从 asar 的 `dsh-client-ui-theme/lib/client.js` 逐字读出):
|
|
25248
|
+
* `--dsw-alias-button-ghost-active-fill`(浅 `#ebeef2` / 深 `#43454a`)配
|
|
25249
|
+
* `--dsw-alias-button-ghost-active-border`(`#979da6` / `#81858c`)——宿主自己侧栏选中项用的
|
|
25250
|
+
* `--dsw-specific-sidebar-nav-item-active` 与前者取值逐字相同,所以这是宿主"选中"那一档,不是我挑的。
|
|
25251
|
+
*
|
|
25252
|
+
* 时间分桶走 `relativeTime`(逐字抄自官方,见 `ui/format.ts`),项目名从宿主 `projectKey` 反推
|
|
25253
|
+
* (`unescapeProjectKey`,两半共用),组头 `title` 给完整原串。**行分隔**交给卡片自己的边框。
|
|
25254
|
+
*
|
|
25255
|
+
* 三条界面规则没有因为改版让路:
|
|
25256
|
+
* - **空态**:`回收站是空的`,且底部**不出现**「清空回收站…」(没有东西可清)。
|
|
25257
|
+
* - **失败写在出事的那一块上**:清单读不到 ⇒ 单独一块摆原文 + 重试(不是空态,"读不到"与"空的"是
|
|
25258
|
+
* 两件事);某条会话的正文读不到 ⇒ 只在详情那块说,清单照常可选;恢复/彻底删除失败 ⇒ 原因留在
|
|
25259
|
+
* **清单那一行**(按条目 id 存,切走再回来还在);无主子会话的检查/清理读不到 ⇒ 面板顶端那块摆原文 + 重试
|
|
25260
|
+
* (那是"没能回答有多少残留",与"没有残留"是两件事,不许折成空报告);清理发现"这一组已经不在了" ⇒
|
|
25261
|
+
* 单独一条中性提示,不计入失败。这些失败块都标 `role="status"`——但**插入即播报并不保证**
|
|
25262
|
+
* (`aria-live` 看的是"已在场的区域发生了变更"),要稳得常驻一个只给读屏看的播报台,本机没有 AT
|
|
25263
|
+
* 可验,所以留在这里当已知边界,不写成已经做到。
|
|
25264
|
+
* - **危险层级**:整页只有一块实心红(页脚「清空回收站…」,确认框里那块是它的后续);行与详情里的
|
|
25265
|
+
* 「彻底删除…」是**安静的红**——只写 `color:--dsw-alias-state-error-primary`、**不写 background**
|
|
25266
|
+
* (作者态内联 `background` 永远赢过类规则,而 `ui.css` 里 `.sd-button-ghost:hover` / `:active` 都是
|
|
25267
|
+
* 普通类 ⇒ 铺了底的动作在点错之前不给任何视觉回应)。批量与确认框那款实心红的写法(`variant="primary"`
|
|
25268
|
+
* + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
|
|
25269
|
+
*
|
|
25270
|
+
* 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
|
|
25271
|
+
* 结构与层级归设计稿 §7 与 2026-10-15 §2,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
|
|
25272
|
+
* 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
|
|
25273
|
+
*
|
|
25274
|
+
* **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
|
|
25275
|
+
* ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
|
|
25276
|
+
* (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`),量法见 2026-10-15 §4 第 5 条。
|
|
25277
|
+
*/
|
|
25278
|
+
/** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。
|
|
25279
|
+
* 字面量住 `shared/wire.ts`(`TRASH_DIR_LABEL`)——Node 半拼的就是同一个名字(ADR-0003)。 */
|
|
25280
|
+
const TRASH_DIR = TRASH_DIR_LABEL;
|
|
25281
|
+
/**
|
|
25282
|
+
* 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(设计稿 2026-10-15 §2.1,2026-10-15 真机后定值)。
|
|
25283
|
+
*
|
|
25284
|
+
* 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
|
|
25285
|
+
* 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
|
|
25286
|
+
* 详情至少要 399 ⇒ 地板 489,剩下的给清单。窄于此与其挤成两条读不了的柱子,不如下钻。
|
|
25287
|
+
* 900 这一档清单被夹到 411(比默认窄 9px),是 2026-10-15 按真 Chromium 量过之后定的:当时按"分隔 1px"
|
|
25288
|
+
* 算成 420 + 32 + 1 + 399 + 48,实际那格是 10px,于是清单让 9px 给详情,两边的可读下限都守住。
|
|
25289
|
+
*/
|
|
25290
|
+
const SPLIT_MIN_WIDTH = 900;
|
|
25291
|
+
/**
|
|
25292
|
+
* 左栏的**默认**宽(2026-10-15 起可拖,见下面 `LIST_COLUMN_MIN` / `DETAIL_RESERVE`)。
|
|
25293
|
+
* 这个数是**为"行内有动作"设计的**(2026-10-15 真机反馈后定):两颗按钮(「恢复」描边 +
|
|
25294
|
+
* 「彻底删除…」安静的红)连 gap 约 152px,卡片左右内边距 28px,剩下 ~240px 给标题与
|
|
25295
|
+
* 「删除于 … · 大小」那一行——316 是"只放标题"那一版的宽度,动作搬回来之后它就不成立了。
|
|
25296
|
+
*/
|
|
25297
|
+
const LIST_COLUMN_DEFAULT = 420;
|
|
25298
|
+
/**
|
|
25299
|
+
* 左栏下限。**量出来的,不是算出来的**(2026-10-15,真 Chromium 上按 10px 一档扫 240→420):
|
|
25300
|
+
* 两颗按钮从来不会被挤掉——它们 `flex:none`,再窄也是**文字列**缩,所以"放得下按钮"根本不是约束
|
|
25301
|
+
* (我第一版按这个算出 280,是错的)。真正的下沿是「删除于 … · 224KB」那一行什么时候不再被省略号
|
|
25302
|
+
* 截断:280 差 50px、320 差 10px、**330 归零**。定在 330。
|
|
25303
|
+
*/
|
|
25304
|
+
const LIST_COLUMN_MIN = 330;
|
|
25305
|
+
/**
|
|
25306
|
+
* 拖到最右时给详情那一侧留的地板:正文可读最小宽 399 + 那一格的 90(两道 gap 32、把手 10、页内边距 48)。
|
|
25307
|
+
* 于是 `900 − 489 = 411`——断点下沿那一档**清单抢不动宽度**(而且它比默认 420 还窄 9px),这是对的:
|
|
25308
|
+
* 那一档两栏刚好塞满,再让清单抢宽就是把详情挤回"读不开"的样子(上一轮"右侧没有渲染"就是这么来的)。
|
|
25309
|
+
*/
|
|
25310
|
+
const DETAIL_RESERVE = 489;
|
|
25311
|
+
/** 分隔线键盘一格的像素数(←→)。 */
|
|
25312
|
+
const HANDLE_STEP = 20;
|
|
25313
|
+
/** 详情正文的收口宽度:中央列再宽也不把文字拉成长线(原来那条"整列限宽 760"的关心得换成只限正文)。 */
|
|
25314
|
+
const DETAIL_TEXT_MAX_WIDTH = 860;
|
|
25315
|
+
/**
|
|
25316
|
+
* 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
|
|
25317
|
+
*
|
|
25318
|
+
* `boxSizing:'border-box'` **不是装饰**:宿主文档没有全局 border-box reset(220 处 `box-sizing`
|
|
25319
|
+
* 声明全是组件自己带的),默认是 content-box ⇒ `height:100%` 再加 20px 上边距会溢出 20px,
|
|
25320
|
+
* 把页脚整块推到中央列底边之外(真机浅色截图上页脚贴着窗口下缘就是这个)。
|
|
25321
|
+
*/
|
|
25322
|
+
const PAGE = {
|
|
25323
|
+
display: "flex",
|
|
25324
|
+
flexDirection: "column",
|
|
25325
|
+
boxSizing: "border-box",
|
|
25326
|
+
height: "100%",
|
|
25327
|
+
minWidth: 0,
|
|
25328
|
+
padding: "20px 24px 12px"
|
|
25329
|
+
};
|
|
25330
|
+
/** 两栏/下钻那一行(页脚之上,自收滚动)。 */
|
|
25331
|
+
const BODY = {
|
|
25332
|
+
display: "flex",
|
|
25333
|
+
flex: "1 1 auto",
|
|
25334
|
+
gap: 16,
|
|
25335
|
+
minHeight: 0,
|
|
25336
|
+
minWidth: 0
|
|
25337
|
+
};
|
|
25338
|
+
/**
|
|
25339
|
+
* 清单那一栏的**外壳**(宽与 `flex` 不在这里——由调用点在渲染时现给:两栏时是可拖的定宽,
|
|
25340
|
+
* 单列时 `flex:'1 1 auto'` 铺满)。两栏不成立时(窄档,或宽档但清单为空/读不到)同一个配方
|
|
25341
|
+
* 直接铺满:**不再限宽 760 居中**,那条关心得只适用于正文(见 `DETAIL_TEXT_MAX_WIDTH`)。
|
|
25342
|
+
*
|
|
25343
|
+
* **滚动条不用注入任何 CSS 就已经是宿主的**:宿主在 `body` 上给出一组量(`--dsh-scrollbar-width:5px`
|
|
25344
|
+
* 等),再用全局 `::-webkit-scrollbar*` 规则消费它们(asar @45064180 起)⇒ 这里一个 `overflow:auto`
|
|
25345
|
+
* 拿到的就是 5px 圆角 token 条。下面把 thumb 提到 `-l2` 那一档,与宿主自己的长滚动面同档。
|
|
25346
|
+
*/
|
|
25347
|
+
const LIST_COLUMN = {
|
|
25348
|
+
display: "flex",
|
|
25349
|
+
flexDirection: "column",
|
|
25350
|
+
boxSizing: "border-box",
|
|
25351
|
+
gap: 7,
|
|
25352
|
+
minWidth: 0,
|
|
25353
|
+
minHeight: 0,
|
|
25354
|
+
overflow: "auto",
|
|
25355
|
+
paddingRight: 10,
|
|
25356
|
+
paddingBottom: 8,
|
|
25357
|
+
"--dsh-scrollbar-thumb": "var(--dsw-alias-scrollbar-bg-l2)",
|
|
25358
|
+
"--dsh-scrollbar-thumb-hover": "var(--dsw-alias-scrollbar-hover-l2)"
|
|
25359
|
+
};
|
|
25360
|
+
/** 详情那一栏的外壳(滚动归它自己,正文限宽在内部)。 */
|
|
25361
|
+
const DETAIL_COLUMN = {
|
|
25362
|
+
display: "flex",
|
|
25363
|
+
flex: "1 1 auto",
|
|
25364
|
+
minWidth: 0,
|
|
25365
|
+
minHeight: 0,
|
|
25366
|
+
overflow: "auto",
|
|
25367
|
+
paddingRight: 10,
|
|
25368
|
+
paddingBottom: 8,
|
|
25369
|
+
"--dsh-scrollbar-thumb": "var(--dsw-alias-scrollbar-bg-l2)",
|
|
25370
|
+
"--dsh-scrollbar-thumb-hover": "var(--dsw-alias-scrollbar-hover-l2)"
|
|
25371
|
+
};
|
|
25372
|
+
const DETAIL_INNER = {
|
|
25373
|
+
minWidth: 0,
|
|
25374
|
+
maxWidth: DETAIL_TEXT_MAX_WIDTH,
|
|
25375
|
+
width: "100%"
|
|
25376
|
+
};
|
|
25377
|
+
/**
|
|
25378
|
+
* 那一排视图开关(全部折叠 / 全部展开、多选;「刷新」图标 2026-10-16 已删,见 `toolbar` 那处注释)。
|
|
25379
|
+
* 它**住在面板体顶端、不在清单列里**:原先在滚动容器内,长清单滚到底会把开关滚走(2026-10-15
|
|
25380
|
+
* 真机反馈后搬出来的理由,与已经删掉的「整条左栏收起」无关)。宽度上限由用它的地方现给——两栏时
|
|
25381
|
+
* 跟着可拖的清单列宽,单列时不设上限。
|
|
25382
|
+
*/
|
|
25383
|
+
const TOOLBAR = {
|
|
25384
|
+
display: "flex",
|
|
25385
|
+
alignItems: "center",
|
|
25386
|
+
gap: 6,
|
|
25387
|
+
flex: "none",
|
|
25388
|
+
minWidth: 0,
|
|
25389
|
+
width: "100%",
|
|
25390
|
+
paddingBottom: 8
|
|
25391
|
+
};
|
|
25392
|
+
/**
|
|
25393
|
+
* 行那句「删除于 3小时前」:整句模板住在字典里(两种语言各自钉语序),单位词与绝对串走 `copy.ts`
|
|
25394
|
+
* 那一处——详情那格要的是同一份,别抄成两本账。`now` 桶不套模板:"删除于 刚刚前"不是话。
|
|
25395
|
+
*/
|
|
25396
|
+
function deletedAgo(at, now, t) {
|
|
25397
|
+
const { unit, n } = relativeTime(at, now);
|
|
25398
|
+
return unit === "now" ? t("trash.row.deletedNow") : t("trash.row.deletedAgo", {
|
|
25399
|
+
n,
|
|
25400
|
+
unit: unitWord(unit, t)
|
|
25401
|
+
});
|
|
25402
|
+
}
|
|
25403
|
+
function TrashPanel({ deps, t = zhTranslate }) {
|
|
25404
|
+
/** 一枚全局闸:写动作在飞时按住这一排按钮(清单行那颗按 `busy` 置灰),并挡住"旧清单那一拍再点一次"。 */
|
|
25405
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
25406
|
+
const around = (0, react.useCallback)(async (task) => {
|
|
25407
|
+
setBusy(true);
|
|
25408
|
+
try {
|
|
25409
|
+
return await task();
|
|
25410
|
+
} finally {
|
|
25411
|
+
setBusy(false);
|
|
25412
|
+
}
|
|
25413
|
+
}, []);
|
|
25414
|
+
const query = useTrashQuery({
|
|
25415
|
+
deps,
|
|
25416
|
+
t,
|
|
25417
|
+
around
|
|
25418
|
+
});
|
|
25419
|
+
const { entries, listFailure, orphanReport, orphanFailure, orphanNotice, orphanConfirm, setOrphanConfirm, refresh } = query;
|
|
25420
|
+
/** 恢复/彻底删除 的失败原文,按条目 id 存:切走再回来还在,不是一次性的。 */
|
|
25421
|
+
const [actionFailure, setActionFailure] = (0, react.useState)({});
|
|
25422
|
+
const [pending, setPending] = (0, react.useState)(null);
|
|
25423
|
+
const [selectedId, setSelectedId] = (0, react.useState)(null);
|
|
25424
|
+
const [loadingId, setLoadingId] = (0, react.useState)(null);
|
|
25425
|
+
const [detailFailure, setDetailFailure] = (0, react.useState)(null);
|
|
25426
|
+
const [drilled, setDrilled] = (0, react.useState)(false);
|
|
25427
|
+
/** 面板存活期内每条只读一次(本机量级:最大一条 135ms,125 条全读 2.0s ⇒ 不预读、也不重复读)。 */
|
|
25428
|
+
const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
25429
|
+
const listRef = (0, react.useRef)(null);
|
|
25430
|
+
const rootRef = (0, react.useRef)(null);
|
|
25431
|
+
const width = usePanelWidth(rootRef);
|
|
25432
|
+
/**
|
|
25433
|
+
* 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
|
|
25434
|
+
* 可选项也没有动作,白占一半宽度(设计稿 2026-10-15 §2.3 第 1 条)。失败那块自己的「重试」
|
|
25435
|
+
* 负责把清单读回来,读回来之后右栏跟着回来。
|
|
25436
|
+
*/
|
|
25437
|
+
const split = width >= SPLIT_MIN_WIDTH && entries !== null && entries.length > 0;
|
|
25438
|
+
/** 两栏之间那根可拖的把手(窄档不渲染它,但 hook 照样得在——不能在条件里调)。 */
|
|
25439
|
+
const resize = useColumnResize({
|
|
25440
|
+
panelWidth: width,
|
|
25441
|
+
defaultValue: LIST_COLUMN_DEFAULT,
|
|
25442
|
+
min: LIST_COLUMN_MIN,
|
|
25443
|
+
reserve: DETAIL_RESERVE,
|
|
25444
|
+
step: HANDLE_STEP,
|
|
25445
|
+
label: t("trash.divider.label")
|
|
25446
|
+
});
|
|
25447
|
+
const revision = useTrashRevision();
|
|
25000
25448
|
(0, react.useEffect)(() => {
|
|
25001
|
-
|
|
25002
|
-
}, [
|
|
25449
|
+
refresh();
|
|
25450
|
+
}, [refresh, revision]);
|
|
25003
25451
|
/**
|
|
25004
25452
|
* 选一条:命中缓存就直接摆,没命中才去读那条会话自己的日志。
|
|
25005
25453
|
* `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
|
|
@@ -25023,58 +25471,10 @@ window.__ModuleLoader__.load({
|
|
|
25023
25471
|
});
|
|
25024
25472
|
}, [deps]);
|
|
25025
25473
|
const groups = (0, react.useMemo)(() => groupByProject(entries ?? [], t), [entries, t]);
|
|
25026
|
-
const isCollapsed
|
|
25027
|
-
|
|
25028
|
-
|
|
25029
|
-
|
|
25030
|
-
if (next.delete(projectDir)) return next;
|
|
25031
|
-
next.add(projectDir);
|
|
25032
|
-
return next;
|
|
25033
|
-
});
|
|
25034
|
-
};
|
|
25035
|
-
const allCollapsed = groups.length > 0 && groups.every((group) => isCollapsed(group.projectDir));
|
|
25036
|
-
const toggleAllGroups = () => {
|
|
25037
|
-
setCollapsedGroups(allCollapsed ? /* @__PURE__ */ new Set() : new Set(groups.map((group) => group.projectDir)));
|
|
25038
|
-
};
|
|
25039
|
-
/** 按**清单从上往下**排好的勾选 id(页脚两颗与批量执行都读这一份)。
|
|
25040
|
-
* 顺序必须走 `groups`(组间按各组最新一条、组内按删除时间),不能用扁平 `entries`:后者是
|
|
25041
|
-
* 全局 `deletedAt` 序,跨项目多选时与屏幕上的分组顺序不同。折叠组里的条目照样算在内。 */
|
|
25042
|
-
const checkedIds = groups.flatMap((group) => group.entries).filter((entry) => checked.has(entry.id)).map((entry) => entry.id);
|
|
25043
|
-
const toggleCheck = (entryId) => {
|
|
25044
|
-
setChecked((current) => {
|
|
25045
|
-
const next = new Set(current);
|
|
25046
|
-
if (next.delete(entryId)) return next;
|
|
25047
|
-
next.add(entryId);
|
|
25048
|
-
return next;
|
|
25049
|
-
});
|
|
25050
|
-
};
|
|
25051
|
-
const exitSelect = () => {
|
|
25052
|
-
setSelectMode(false);
|
|
25053
|
-
setChecked(/* @__PURE__ */ new Set());
|
|
25054
|
-
};
|
|
25055
|
-
/**
|
|
25056
|
-
* 组头那颗勾选框:说的是"**这个项目的全部会话**",所以判据走 `group.entries`——**折叠起来的条目
|
|
25057
|
-
* 也算在内**(折叠只是不收它们进 DOM 而已,不是它们不存在)。三态里"半选"用原生
|
|
25058
|
-
* `indeterminate`,勾满再点 = 整组取消,而不是取消到半选。
|
|
25059
|
-
*/
|
|
25060
|
-
const groupFullyChecked = (group) => group.entries.length > 0 && group.entries.every((entry) => checked.has(entry.id));
|
|
25061
|
-
const groupPartiallyChecked = (group) => !groupFullyChecked(group) && group.entries.some((entry) => checked.has(entry.id));
|
|
25062
|
-
const toggleGroupCheck = (group) => {
|
|
25063
|
-
const ids = group.entries.map((entry) => entry.id);
|
|
25064
|
-
const clear = groupFullyChecked(group);
|
|
25065
|
-
setChecked((current) => {
|
|
25066
|
-
const next = new Set(current);
|
|
25067
|
-
for (const id of ids) if (clear) next.delete(id);
|
|
25068
|
-
else next.add(id);
|
|
25069
|
-
return next;
|
|
25070
|
-
});
|
|
25071
|
-
};
|
|
25072
|
-
/**
|
|
25073
|
-
* 键盘 ↑↓ 走的是这个顺序(分组只是视觉上的分段,别让它把跨组的相邻关系改乱),
|
|
25074
|
-
* 且**只走展开的组**:折叠起来的行不在 DOM 里,让方向键停在一条看不见的行上,
|
|
25075
|
-
* 就是"按了有反应、屏幕上什么都没发生"。
|
|
25076
|
-
*/
|
|
25077
|
-
const ordered = (0, react.useMemo)(() => groups.filter((group) => !isCollapsed(group.projectDir)).flatMap((group) => group.entries), [groups, collapsedGroups]);
|
|
25474
|
+
const { selectMode, checked, checkedIds, ordered, allCollapsed, isCollapsed, enterSelect, exitSelect, toggleGroup, toggleAllGroups, toggleCheck, toggleGroupCheck, groupFullyChecked, groupPartiallyChecked } = useTrashSelection({
|
|
25475
|
+
entries,
|
|
25476
|
+
groups
|
|
25477
|
+
});
|
|
25078
25478
|
(0, react.useEffect)(() => {
|
|
25079
25479
|
if (entries === null || selectedId === null) return;
|
|
25080
25480
|
if (entries.some((entry) => entry.id === selectedId)) return;
|
|
@@ -25151,15 +25551,15 @@ window.__ModuleLoader__.load({
|
|
|
25151
25551
|
delete next[entry.id];
|
|
25152
25552
|
return next;
|
|
25153
25553
|
});
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25554
|
+
await around(async () => {
|
|
25555
|
+
const outcome = await callRemote(() => applyOne(deps, "restore", entry.id).then((item) => item.value));
|
|
25556
|
+
if (!outcome.ok) {
|
|
25557
|
+
failRow(entry.id, outcome.failure.message);
|
|
25558
|
+
return;
|
|
25559
|
+
}
|
|
25560
|
+
detailCache.current.delete(entry.id);
|
|
25561
|
+
await refresh();
|
|
25562
|
+
});
|
|
25163
25563
|
};
|
|
25164
25564
|
/** 一段批量 → 组件状态:成功的丢详情缓存,失败的原文落回它自己那一行。整批一次上屏、一次重读。 */
|
|
25165
25565
|
const settleOutcomes = (outcomes) => {
|
|
@@ -25180,17 +25580,13 @@ window.__ModuleLoader__.load({
|
|
|
25180
25580
|
* 逐条成败住在 `ApplyOutcome` 里 ⇒ 组件不再自己拼循环、也不再"每条一次全量重读"。
|
|
25181
25581
|
*/
|
|
25182
25582
|
const confirmApply = async (action, ids) => {
|
|
25183
|
-
|
|
25184
|
-
|
|
25185
|
-
|
|
25186
|
-
|
|
25187
|
-
|
|
25188
|
-
|
|
25189
|
-
|
|
25190
|
-
return;
|
|
25191
|
-
}
|
|
25192
|
-
settleOutcomes(outcome.value);
|
|
25193
|
-
await reload();
|
|
25583
|
+
await around(async () => {
|
|
25584
|
+
const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
|
|
25585
|
+
setPending(null);
|
|
25586
|
+
if (!outcome.ok) for (const id of ids) failRow(id, outcome.failure.message);
|
|
25587
|
+
else settleOutcomes(outcome.value);
|
|
25588
|
+
await refresh();
|
|
25589
|
+
});
|
|
25194
25590
|
};
|
|
25195
25591
|
const confirmPurge = async () => {
|
|
25196
25592
|
const target = pending;
|
|
@@ -25198,16 +25594,14 @@ window.__ModuleLoader__.load({
|
|
|
25198
25594
|
if (target.kind === "entries") return confirmApply("purge", target.ids);
|
|
25199
25595
|
if (target.kind === "restore") return confirmApply("restore", target.ids);
|
|
25200
25596
|
if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
|
|
25201
|
-
|
|
25202
|
-
const outcome = await callRemote(() => deps.emptyTrash());
|
|
25203
|
-
setBusy(false);
|
|
25597
|
+
const emptied = await query.emptyTrash();
|
|
25204
25598
|
setPending(null);
|
|
25205
|
-
if (
|
|
25206
|
-
|
|
25207
|
-
|
|
25208
|
-
|
|
25209
|
-
|
|
25210
|
-
await
|
|
25599
|
+
if (emptied) detailCache.current.clear();
|
|
25600
|
+
};
|
|
25601
|
+
/** 清理那批无主子会话:整块住在 `useTrashQuery.cleanOrphans`(扫描的报告、两种失败的分流、先刷新后落提示
|
|
25602
|
+
* 都在那一条里);这里只负责把确认框收起来。 */
|
|
25603
|
+
const confirmOrphans = async () => {
|
|
25604
|
+
await query.cleanOrphans();
|
|
25211
25605
|
};
|
|
25212
25606
|
/**
|
|
25213
25607
|
* 详细信息那块的选中集合:确认框要把"这一批有哪些、要去哪"摆出来,靠的全量条目是从勾选 id 反查
|
|
@@ -25216,7 +25610,7 @@ window.__ModuleLoader__.load({
|
|
|
25216
25610
|
*/
|
|
25217
25611
|
const byIdEntries = (0, react.useMemo)(() => new Map((entries ?? []).map((entry) => [entry.id, entry])), [entries]);
|
|
25218
25612
|
const restoreEntries = pending?.kind === "restore" ? pending.ids.map((id) => byIdEntries.get(id)).filter((entry) => entry !== void 0) : [];
|
|
25219
|
-
const totalBytes = (entries ?? []).reduce((sum, entry) => sum +
|
|
25613
|
+
const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.releaseBytes, 0);
|
|
25220
25614
|
const now = Date.now();
|
|
25221
25615
|
const hasEntries = entries !== null && entries.length > 0;
|
|
25222
25616
|
/**
|
|
@@ -25332,7 +25726,7 @@ window.__ModuleLoader__.load({
|
|
|
25332
25726
|
variant: "outline",
|
|
25333
25727
|
size: "sm",
|
|
25334
25728
|
style: { flex: "none" },
|
|
25335
|
-
onClick: () => void
|
|
25729
|
+
onClick: () => void refresh(),
|
|
25336
25730
|
children: t("common.retry")
|
|
25337
25731
|
})
|
|
25338
25732
|
]
|
|
@@ -25531,6 +25925,15 @@ window.__ModuleLoader__.load({
|
|
|
25531
25925
|
stranded > 0 ? t("trash.row.strandedSubagents", { n: stranded }) : ""
|
|
25532
25926
|
].filter(Boolean).join(" · ")
|
|
25533
25927
|
}),
|
|
25928
|
+
entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25929
|
+
"data-dsh-session-delete": "trash-row-workspace-gone",
|
|
25930
|
+
style: {
|
|
25931
|
+
fontSize: 12,
|
|
25932
|
+
lineHeight: "16px",
|
|
25933
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
25934
|
+
},
|
|
25935
|
+
children: t("trash.row.workspaceGone")
|
|
25936
|
+
}) : null,
|
|
25534
25937
|
actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25535
25938
|
role: "status",
|
|
25536
25939
|
style: {
|
|
@@ -25570,6 +25973,7 @@ window.__ModuleLoader__.load({
|
|
|
25570
25973
|
size: "sm",
|
|
25571
25974
|
style: QUIET_DANGER,
|
|
25572
25975
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
25976
|
+
disabled: busy,
|
|
25573
25977
|
onClick: () => setPending({
|
|
25574
25978
|
kind: "entry",
|
|
25575
25979
|
entry
|
|
@@ -25626,7 +26030,7 @@ window.__ModuleLoader__.load({
|
|
|
25626
26030
|
"aria-pressed": selectMode,
|
|
25627
26031
|
onClick: () => {
|
|
25628
26032
|
if (selectMode) exitSelect();
|
|
25629
|
-
else
|
|
26033
|
+
else enterSelect();
|
|
25630
26034
|
},
|
|
25631
26035
|
children: selectMode ? t("trash.toolbar.selectExit") : t("trash.toolbar.select")
|
|
25632
26036
|
})]
|
|
@@ -25681,6 +26085,135 @@ window.__ModuleLoader__.load({
|
|
|
25681
26085
|
}) : null]
|
|
25682
26086
|
})
|
|
25683
26087
|
}),
|
|
26088
|
+
orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26089
|
+
"data-dsh-session-delete": "orphan-banner",
|
|
26090
|
+
style: {
|
|
26091
|
+
...CARD,
|
|
26092
|
+
alignItems: "flex-start",
|
|
26093
|
+
gap: 12,
|
|
26094
|
+
marginBottom: 10
|
|
26095
|
+
},
|
|
26096
|
+
children: [
|
|
26097
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26098
|
+
style: {
|
|
26099
|
+
display: "inline-flex",
|
|
26100
|
+
flex: "none",
|
|
26101
|
+
paddingTop: 2,
|
|
26102
|
+
color: "var(--dsw-alias-state-warn-primary)"
|
|
26103
|
+
},
|
|
26104
|
+
"aria-hidden": "true",
|
|
26105
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
|
|
26106
|
+
}),
|
|
26107
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26108
|
+
style: {
|
|
26109
|
+
display: "flex",
|
|
26110
|
+
flexDirection: "column",
|
|
26111
|
+
gap: 2,
|
|
26112
|
+
flex: "1 1 auto",
|
|
26113
|
+
minWidth: 0
|
|
26114
|
+
},
|
|
26115
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26116
|
+
style: {
|
|
26117
|
+
fontSize: 13,
|
|
26118
|
+
lineHeight: "19px"
|
|
26119
|
+
},
|
|
26120
|
+
children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
|
|
26121
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26122
|
+
style: {
|
|
26123
|
+
fontSize: 12,
|
|
26124
|
+
lineHeight: "16px",
|
|
26125
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
26126
|
+
},
|
|
26127
|
+
children: t("trash.orphans.meta", {
|
|
26128
|
+
size: fileSizeText(orphanReport.totalBytes),
|
|
26129
|
+
parents: orphanReport.missingParents
|
|
26130
|
+
})
|
|
26131
|
+
})]
|
|
26132
|
+
}),
|
|
26133
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
26134
|
+
variant: "outline",
|
|
26135
|
+
size: "sm",
|
|
26136
|
+
style: { flex: "none" },
|
|
26137
|
+
disabled: busy,
|
|
26138
|
+
onClick: () => {
|
|
26139
|
+
if (busy) return;
|
|
26140
|
+
query.openOrphanPreview();
|
|
26141
|
+
},
|
|
26142
|
+
children: t("trash.orphans.open")
|
|
26143
|
+
})
|
|
26144
|
+
]
|
|
26145
|
+
}) : null,
|
|
26146
|
+
orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26147
|
+
role: "status",
|
|
26148
|
+
style: {
|
|
26149
|
+
...CARD,
|
|
26150
|
+
alignItems: "flex-start",
|
|
26151
|
+
gap: 10,
|
|
26152
|
+
marginBottom: 10,
|
|
26153
|
+
padding: "10px 12px"
|
|
26154
|
+
},
|
|
26155
|
+
children: [
|
|
26156
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26157
|
+
style: {
|
|
26158
|
+
display: "inline-flex",
|
|
26159
|
+
flex: "none",
|
|
26160
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
26161
|
+
},
|
|
26162
|
+
"aria-hidden": "true",
|
|
26163
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
|
|
26164
|
+
}),
|
|
26165
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26166
|
+
style: {
|
|
26167
|
+
flex: "1 1 auto",
|
|
26168
|
+
minWidth: 0,
|
|
26169
|
+
wordBreak: "break-word",
|
|
26170
|
+
fontSize: 12,
|
|
26171
|
+
lineHeight: "17px",
|
|
26172
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
26173
|
+
},
|
|
26174
|
+
children: orphanFailure
|
|
26175
|
+
}),
|
|
26176
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
26177
|
+
variant: "outline",
|
|
26178
|
+
size: "sm",
|
|
26179
|
+
style: { flex: "none" },
|
|
26180
|
+
disabled: busy,
|
|
26181
|
+
onClick: () => void refresh(),
|
|
26182
|
+
children: t("common.retry")
|
|
26183
|
+
})
|
|
26184
|
+
]
|
|
26185
|
+
}) : null,
|
|
26186
|
+
orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26187
|
+
"data-dsh-session-delete": "orphan-notice",
|
|
26188
|
+
role: "status",
|
|
26189
|
+
style: {
|
|
26190
|
+
...CARD,
|
|
26191
|
+
alignItems: "flex-start",
|
|
26192
|
+
gap: 10,
|
|
26193
|
+
marginBottom: 10,
|
|
26194
|
+
padding: "10px 12px"
|
|
26195
|
+
},
|
|
26196
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26197
|
+
style: {
|
|
26198
|
+
display: "inline-flex",
|
|
26199
|
+
flex: "none",
|
|
26200
|
+
paddingTop: 2,
|
|
26201
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
26202
|
+
},
|
|
26203
|
+
"aria-hidden": "true",
|
|
26204
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
|
|
26205
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26206
|
+
style: {
|
|
26207
|
+
flex: "1 1 auto",
|
|
26208
|
+
minWidth: 0,
|
|
26209
|
+
wordBreak: "break-word",
|
|
26210
|
+
fontSize: 12,
|
|
26211
|
+
lineHeight: "17px",
|
|
26212
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
26213
|
+
},
|
|
26214
|
+
children: orphanNotice
|
|
26215
|
+
})]
|
|
26216
|
+
}) : null,
|
|
25684
26217
|
toolbar,
|
|
25685
26218
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25686
26219
|
style: BODY,
|
|
@@ -25771,6 +26304,7 @@ window.__ModuleLoader__.load({
|
|
|
25771
26304
|
size: "sm",
|
|
25772
26305
|
style: BULK_DANGER,
|
|
25773
26306
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
|
|
26307
|
+
disabled: busy,
|
|
25774
26308
|
onClick: () => setPending({ kind: "all" }),
|
|
25775
26309
|
children: t("trash.emptyAll")
|
|
25776
26310
|
})
|
|
@@ -25784,124 +26318,15 @@ window.__ModuleLoader__.load({
|
|
|
25784
26318
|
onCancel: () => setPending(null),
|
|
25785
26319
|
onConfirmPurge: () => void confirmPurge(),
|
|
25786
26320
|
onConfirmRestore: (ids) => void confirmApply("restore", ids)
|
|
25787
|
-
})
|
|
25788
|
-
]
|
|
25789
|
-
});
|
|
25790
|
-
}
|
|
25791
|
-
/**
|
|
25792
|
-
* 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
|
|
25793
|
-
* 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
|
|
25794
|
-
* 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
|
|
25795
|
-
* 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
|
|
25796
|
-
*/
|
|
25797
|
-
function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
|
|
25798
|
-
if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
25799
|
-
open: true,
|
|
25800
|
-
onClose: onCancel,
|
|
25801
|
-
title: t("trash.confirm.restore", { count: pending.ids.length }),
|
|
25802
|
-
closeLabel: t("common.close"),
|
|
25803
|
-
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25804
|
-
variant: "outline",
|
|
25805
|
-
onClick: onCancel,
|
|
25806
|
-
children: t("common.cancel")
|
|
25807
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25808
|
-
variant: "primary",
|
|
25809
|
-
disabled: busy,
|
|
25810
|
-
onClick: () => onConfirmRestore(pending.ids),
|
|
25811
|
-
children: t("trash.confirm.restoreNow")
|
|
25812
|
-
})] }),
|
|
25813
|
-
children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25814
|
-
style: {
|
|
25815
|
-
display: "flex",
|
|
25816
|
-
flexDirection: "column",
|
|
25817
|
-
gap: 6,
|
|
25818
|
-
maxHeight: 280,
|
|
25819
|
-
overflow: "auto",
|
|
25820
|
-
marginBlockEnd: 10
|
|
25821
|
-
},
|
|
25822
|
-
children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25823
|
-
style: {
|
|
25824
|
-
...CARD,
|
|
25825
|
-
flexDirection: "column",
|
|
25826
|
-
alignItems: "stretch",
|
|
25827
|
-
gap: 2,
|
|
25828
|
-
padding: "9px 12px"
|
|
25829
|
-
},
|
|
25830
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25831
|
-
style: {
|
|
25832
|
-
...ELLIPSIS,
|
|
25833
|
-
fontSize: 13.5,
|
|
25834
|
-
lineHeight: "19px"
|
|
25835
|
-
},
|
|
25836
|
-
children: entry.title === "" ? t("common.untitled") : entry.title
|
|
25837
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25838
|
-
style: {
|
|
25839
|
-
...ELLIPSIS,
|
|
25840
|
-
fontSize: 12,
|
|
25841
|
-
lineHeight: "16px",
|
|
25842
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
25843
|
-
},
|
|
25844
|
-
children: projectLabel(entry.projectDir, t)
|
|
25845
|
-
})]
|
|
25846
|
-
}, entry.id))
|
|
25847
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
|
|
25848
|
-
});
|
|
25849
|
-
const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
|
|
25850
|
-
const strong = t("trash.confirm.body.strong");
|
|
25851
|
-
const body = splitOn(t("trash.confirm.body", { strong }), strong);
|
|
25852
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
|
|
25853
|
-
open: true,
|
|
25854
|
-
onClose: onCancel,
|
|
25855
|
-
title,
|
|
25856
|
-
closeLabel: t("common.close"),
|
|
25857
|
-
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25858
|
-
variant: "outline",
|
|
25859
|
-
onClick: onCancel,
|
|
25860
|
-
children: t("common.cancel")
|
|
25861
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
25862
|
-
variant: "primary",
|
|
25863
|
-
style: BULK_DANGER,
|
|
25864
|
-
disabled: busy,
|
|
25865
|
-
onClick: onConfirmPurge,
|
|
25866
|
-
children: t("trash.confirm.purge")
|
|
25867
|
-
})] }),
|
|
25868
|
-
children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
25869
|
-
style: {
|
|
25870
|
-
...CARD,
|
|
25871
|
-
flexDirection: "column",
|
|
25872
|
-
alignItems: "stretch",
|
|
25873
|
-
gap: 2,
|
|
25874
|
-
padding: "10px 12px",
|
|
25875
|
-
marginBlockEnd: 10
|
|
25876
|
-
},
|
|
25877
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25878
|
-
style: {
|
|
25879
|
-
...ELLIPSIS,
|
|
25880
|
-
fontSize: 13.5,
|
|
25881
|
-
lineHeight: "19px"
|
|
25882
|
-
},
|
|
25883
|
-
children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
|
|
25884
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
25885
|
-
style: {
|
|
25886
|
-
...ELLIPSIS,
|
|
25887
|
-
fontSize: 12,
|
|
25888
|
-
lineHeight: "16px",
|
|
25889
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
25890
|
-
},
|
|
25891
|
-
children: t("trash.confirm.one.meta", {
|
|
25892
|
-
project: projectLabel(pending.entry.projectDir, t),
|
|
25893
|
-
at: absoluteText(pending.entry.deletedAt),
|
|
25894
|
-
size: fileSizeText(releaseBytesOf(pending.entry))
|
|
25895
|
-
})
|
|
25896
|
-
})]
|
|
25897
|
-
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
|
|
25898
|
-
body[0],
|
|
25899
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
|
|
25900
|
-
style: { color: "var(--dsw-alias-state-error-primary)" },
|
|
25901
|
-
children: body[1]
|
|
25902
26321
|
}),
|
|
25903
|
-
|
|
25904
|
-
|
|
26322
|
+
orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
|
|
26323
|
+
report: orphanReport,
|
|
26324
|
+
busy,
|
|
26325
|
+
t,
|
|
26326
|
+
onCancel: () => setOrphanConfirm(false),
|
|
26327
|
+
onConfirm: () => void confirmOrphans()
|
|
26328
|
+
}) : null
|
|
26329
|
+
]
|
|
25905
26330
|
});
|
|
25906
26331
|
}
|
|
25907
26332
|
//#endregion
|