@xrn1997/dsh-session-delete 0.4.0 → 0.6.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 +39 -10
- package/cordis.patch.yml +4 -0
- package/icon.svg +18 -18
- package/lib/client.js +1897 -859
- package/lib/index.js +1803 -1058
- package/lib/types/api/attachment-orphans.d.ts +9 -3
- package/lib/types/api/attachment.d.ts +17 -7
- package/lib/types/api/connection-route.d.ts +23 -0
- package/lib/types/api/dispatch.d.ts +25 -14
- package/lib/types/api/route-table.d.ts +47 -11
- package/lib/types/api/trash-rows.d.ts +3 -3
- package/lib/types/client/batch-bar.d.ts +1 -1
- package/lib/types/client/batch-pick.d.ts +19 -0
- package/lib/types/client/batch-select.d.ts +7 -5
- package/lib/types/client/busy-gate.d.ts +6 -2
- package/lib/types/client/confirm-close.d.ts +4 -1
- package/lib/types/client/context.d.ts +3 -2
- package/lib/types/client/copy.d.ts +54 -4
- package/lib/types/client/delete-confirm.d.ts +1 -1
- package/lib/types/client/delete-menu-item.d.ts +1 -1
- package/lib/types/client/host-rows.d.ts +29 -30
- package/lib/types/client/main-view.d.ts +2 -1
- package/lib/types/client/markdown.d.ts +6 -7
- package/lib/types/client/orphan-maintenance.d.ts +10 -25
- package/lib/types/client/pending-open.d.ts +17 -0
- package/lib/types/client/receipt.d.ts +3 -1
- package/lib/types/client/remote.d.ts +21 -18
- package/lib/types/client/store.d.ts +15 -10
- package/lib/types/client/trash-detail.d.ts +48 -6
- package/lib/types/client/trash-entry.d.ts +1 -1
- package/lib/types/client/trash-nav.d.ts +12 -8
- package/lib/types/client/ui/Toast.d.ts +3 -1
- package/lib/types/client/ui/icons.d.ts +1 -1
- package/lib/types/client/undo-toast.d.ts +2 -1
- package/lib/types/client/use-maintenance-section.d.ts +52 -0
- package/lib/types/client/use-maintenance.d.ts +7 -35
- package/lib/types/client/use-trash-panel-controller.d.ts +35 -14
- package/lib/types/client/use-trash-query.d.ts +10 -8
- package/lib/types/client/use-trash-selection.d.ts +15 -12
- package/lib/types/client/write-seats.d.ts +32 -16
- package/lib/types/client/write-settlement.d.ts +1 -1
- package/lib/types/client/write.d.ts +13 -3
- package/lib/types/config.d.ts +45 -0
- package/lib/types/deps.d.ts +8 -3
- package/lib/types/domain/closure.d.ts +47 -0
- package/lib/types/index.d.ts +13 -12
- package/lib/types/log/zstd-frames.d.ts +2 -3
- package/lib/types/ports/attachments-port.d.ts +40 -0
- package/lib/types/ports/fs-port.d.ts +15 -12
- package/lib/types/ports/host-port.d.ts +4 -5
- package/lib/types/ports/presence-port.d.ts +9 -0
- package/lib/types/shared/wire.d.ts +137 -31
- package/lib/types/trash/attachments.d.ts +10 -1
- package/lib/types/trash/detail.d.ts +32 -22
- package/lib/types/trash/entry.d.ts +7 -1
- package/lib/types/trash/groups.d.ts +6 -1
- package/lib/types/trash/manifest.d.ts +4 -4
- package/lib/types/verbs/batch-carried.d.ts +1 -3
- package/lib/types/verbs/live-admission.d.ts +1 -1
- package/lib/types/verbs/member-presence.d.ts +3 -1
- package/lib/types/verbs/move-group.d.ts +2 -1
- package/lib/types/verbs/orphan-subagents.d.ts +12 -5
- package/lib/types/verbs/permanent-removal.d.ts +39 -10
- package/lib/types/verbs/trash-scope.d.ts +36 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +13 -3
- package/lib/types/api/request-trust.d.ts +0 -45
- package/lib/types/ports/connection-port.d.ts +0 -33
- package/lib/types/verbs/purge-trash.d.ts +0 -35
package/lib/client.js
CHANGED
|
@@ -196,7 +196,7 @@ window.__ModuleLoader__.load({
|
|
|
196
196
|
"detail.notSelected": "选中一条会话,看它讲了什么",
|
|
197
197
|
"detail.loading": "正在读这条会话…",
|
|
198
198
|
"detail.readFailed": "这条会话的正文读不出来",
|
|
199
|
-
"detail.noBody": "
|
|
199
|
+
"detail.noBody": "这条会话没有可显示的内容",
|
|
200
200
|
"detail.field.created": "创建",
|
|
201
201
|
"detail.field.lastActivity": "最后活动",
|
|
202
202
|
"detail.field.deletedAt": "删除于",
|
|
@@ -205,8 +205,9 @@ window.__ModuleLoader__.load({
|
|
|
205
205
|
"detail.field.size": "大小",
|
|
206
206
|
"detail.field.turns": "轮数",
|
|
207
207
|
"detail.field.userMessages": "提问",
|
|
208
|
-
"detail.field.assistantMessages": "
|
|
208
|
+
"detail.field.assistantMessages": "助手消息",
|
|
209
209
|
"detail.field.toolCalls": "工具调用",
|
|
210
|
+
"detail.field.injections": "注入上下文",
|
|
210
211
|
"detail.field.models": "模型",
|
|
211
212
|
"detail.field.project": "项目",
|
|
212
213
|
"detail.field.sessionId": "会话 id",
|
|
@@ -214,10 +215,36 @@ window.__ModuleLoader__.load({
|
|
|
214
215
|
"detail.field.wasArchived": "原本归档",
|
|
215
216
|
"detail.value.yes": "是",
|
|
216
217
|
"detail.value.no": "否",
|
|
218
|
+
"detail.value.spanLessThanHour": "不足 1 小时",
|
|
217
219
|
"detail.value.spanHours": "{n} 小时",
|
|
218
220
|
"detail.value.spanDays": "{n} 天",
|
|
219
|
-
"detail.
|
|
220
|
-
"detail.
|
|
221
|
+
"detail.block.reasoning": "思考",
|
|
222
|
+
"detail.block.tool": "工具调用",
|
|
223
|
+
"detail.block.injection": "上下文注入",
|
|
224
|
+
"detail.block.system": "系统消息",
|
|
225
|
+
"detail.block.compaction": "上下文已压缩",
|
|
226
|
+
"detail.block.args": "参数",
|
|
227
|
+
"detail.block.result": "结果",
|
|
228
|
+
"detail.block.noResult": "日志在调用结束前结束,没有结果",
|
|
229
|
+
"detail.block.status.ok": "成功",
|
|
230
|
+
"detail.block.status.error": "失败",
|
|
231
|
+
"detail.block.status.unfinished": "未完成",
|
|
232
|
+
"detail.block.truncated": "已截断",
|
|
233
|
+
"detail.loadOlder": "加载更早",
|
|
234
|
+
"detail.loadOlder.loading": "正在加载更早…",
|
|
235
|
+
"detail.expandAll": "全部展开",
|
|
236
|
+
"detail.collapseAll": "全部折叠",
|
|
237
|
+
"detail.injection.runtimeContext": "运行上下文",
|
|
238
|
+
"detail.injection.timeContext": "时间上下文",
|
|
239
|
+
"detail.injection.agentInstructions": "Agent 指令",
|
|
240
|
+
"detail.injection.skillCatalog": "技能目录",
|
|
241
|
+
"detail.injection.agentMessage": "子会话消息",
|
|
242
|
+
"detail.injection.subagentSettled": "子会话结束",
|
|
243
|
+
"detail.injection.toolJobs": "后台任务",
|
|
244
|
+
"detail.injection.skillInvocation": "技能调用",
|
|
245
|
+
"detail.injection.userApproval": "用户批准",
|
|
246
|
+
"detail.injection.goal": "目标",
|
|
247
|
+
"detail.injection.other": "其他上下文",
|
|
221
248
|
"detail.image": "[图片]",
|
|
222
249
|
"detail.image.alt": "[图片 · {alt}]",
|
|
223
250
|
"detail.attachment.file": "文件",
|
|
@@ -229,6 +256,9 @@ window.__ModuleLoader__.load({
|
|
|
229
256
|
"detail.attachment.saving": "保存中…",
|
|
230
257
|
"detail.attachment.saveFailed": "保存失败:{message}",
|
|
231
258
|
"detail.back": "← 返回清单",
|
|
259
|
+
"detail.subagents.header": "子会话({n}) · {size}",
|
|
260
|
+
"detail.subagents.truncated": "仅列出前 {shown} 条,共 {total} 条",
|
|
261
|
+
"detail.backToParent": "← 返回父会话",
|
|
232
262
|
"detail.list.label": "回收站清单",
|
|
233
263
|
"detail.role.user": "你",
|
|
234
264
|
"detail.role.assistant": "助手",
|
|
@@ -253,7 +283,8 @@ window.__ModuleLoader__.load({
|
|
|
253
283
|
"undo.movedBatch": "{n} 条会话已移入回收站",
|
|
254
284
|
"undo.moved": "{title} 已移入回收站",
|
|
255
285
|
"undo.session": "会话",
|
|
256
|
-
"undo.undo": "撤销"
|
|
286
|
+
"undo.undo": "撤销",
|
|
287
|
+
"undo.failedBatch": "{n} 条会话未能撤销:{reasons}"
|
|
257
288
|
};
|
|
258
289
|
/** en:与 `ZH` 逐键镜像(键集合由 `Record<CopyKey, string>` 盯住,漏一个就编译不过)。
|
|
259
290
|
* 单位词与宿主 en 侧 `relativeTime` 的写法对齐(宿主 en 的桶就是 `5min` / `3h` / `2d` / `4mo` / `1y`)。 */
|
|
@@ -348,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
348
379
|
"detail.notSelected": "Select a session to see what it was about",
|
|
349
380
|
"detail.loading": "Reading this session…",
|
|
350
381
|
"detail.readFailed": "Could not read this session’s log",
|
|
351
|
-
"detail.noBody": "This session has
|
|
382
|
+
"detail.noBody": "This session has nothing to show",
|
|
352
383
|
"detail.field.created": "Created",
|
|
353
384
|
"detail.field.lastActivity": "Last activity",
|
|
354
385
|
"detail.field.deletedAt": "Deleted",
|
|
@@ -357,8 +388,9 @@ window.__ModuleLoader__.load({
|
|
|
357
388
|
"detail.field.size": "Size",
|
|
358
389
|
"detail.field.turns": "Turns",
|
|
359
390
|
"detail.field.userMessages": "Prompts",
|
|
360
|
-
"detail.field.assistantMessages": "
|
|
391
|
+
"detail.field.assistantMessages": "Assistant messages",
|
|
361
392
|
"detail.field.toolCalls": "Tool calls",
|
|
393
|
+
"detail.field.injections": "Injected contexts",
|
|
362
394
|
"detail.field.models": "Model",
|
|
363
395
|
"detail.field.project": "Project",
|
|
364
396
|
"detail.field.sessionId": "Session id",
|
|
@@ -366,10 +398,36 @@ window.__ModuleLoader__.load({
|
|
|
366
398
|
"detail.field.wasArchived": "Was archived",
|
|
367
399
|
"detail.value.yes": "Yes",
|
|
368
400
|
"detail.value.no": "No",
|
|
401
|
+
"detail.value.spanLessThanHour": "< 1 h",
|
|
369
402
|
"detail.value.spanHours": "{n} h",
|
|
370
403
|
"detail.value.spanDays": "{n} d",
|
|
371
|
-
"detail.
|
|
372
|
-
"detail.
|
|
404
|
+
"detail.block.reasoning": "Thinking",
|
|
405
|
+
"detail.block.tool": "Tool call",
|
|
406
|
+
"detail.block.injection": "Context injection",
|
|
407
|
+
"detail.block.system": "System message",
|
|
408
|
+
"detail.block.compaction": "Context compacted",
|
|
409
|
+
"detail.block.args": "Arguments",
|
|
410
|
+
"detail.block.result": "Result",
|
|
411
|
+
"detail.block.noResult": "The log ended before this call returned a result",
|
|
412
|
+
"detail.block.status.ok": "OK",
|
|
413
|
+
"detail.block.status.error": "Failed",
|
|
414
|
+
"detail.block.status.unfinished": "Unfinished",
|
|
415
|
+
"detail.block.truncated": "Truncated",
|
|
416
|
+
"detail.loadOlder": "Load earlier",
|
|
417
|
+
"detail.loadOlder.loading": "Loading earlier…",
|
|
418
|
+
"detail.expandAll": "Expand all",
|
|
419
|
+
"detail.collapseAll": "Collapse all",
|
|
420
|
+
"detail.injection.runtimeContext": "Runtime context",
|
|
421
|
+
"detail.injection.timeContext": "Time context",
|
|
422
|
+
"detail.injection.agentInstructions": "Agent instructions",
|
|
423
|
+
"detail.injection.skillCatalog": "Skill catalog",
|
|
424
|
+
"detail.injection.agentMessage": "Subagent message",
|
|
425
|
+
"detail.injection.subagentSettled": "Subagent finished",
|
|
426
|
+
"detail.injection.toolJobs": "Background jobs",
|
|
427
|
+
"detail.injection.skillInvocation": "Skill invocation",
|
|
428
|
+
"detail.injection.userApproval": "User approval",
|
|
429
|
+
"detail.injection.goal": "Goal",
|
|
430
|
+
"detail.injection.other": "Other context",
|
|
373
431
|
"detail.image": "[Image]",
|
|
374
432
|
"detail.image.alt": "[Image · {alt}]",
|
|
375
433
|
"detail.attachment.file": "File",
|
|
@@ -381,6 +439,9 @@ window.__ModuleLoader__.load({
|
|
|
381
439
|
"detail.attachment.saving": "Saving…",
|
|
382
440
|
"detail.attachment.saveFailed": "Save failed: {message}",
|
|
383
441
|
"detail.back": "← Back to list",
|
|
442
|
+
"detail.subagents.header": "Subagent sessions ({n}) · {size}",
|
|
443
|
+
"detail.subagents.truncated": "Showing the first {shown} of {total} subagent sessions",
|
|
444
|
+
"detail.backToParent": "← Back to parent session",
|
|
384
445
|
"detail.list.label": "Trash list",
|
|
385
446
|
"detail.role.user": "You",
|
|
386
447
|
"detail.role.assistant": "Assistant",
|
|
@@ -405,7 +466,8 @@ window.__ModuleLoader__.load({
|
|
|
405
466
|
"undo.movedBatch": "{n} sessions were moved to the trash",
|
|
406
467
|
"undo.moved": "{title} was moved to the trash",
|
|
407
468
|
"undo.session": "the session",
|
|
408
|
-
"undo.undo": "Undo"
|
|
469
|
+
"undo.undo": "Undo",
|
|
470
|
+
"undo.failedBatch": "{n} sessions could not be undone: {reasons}"
|
|
409
471
|
};
|
|
410
472
|
/** 与宿主 `translate` 同一条替换规则(`{name}`,参数缺席时原样留下占位符)。 */
|
|
411
473
|
function interpolate(template, params) {
|
|
@@ -432,6 +494,27 @@ window.__ModuleLoader__.load({
|
|
|
432
494
|
return (key, params) => bound(key, params);
|
|
433
495
|
}
|
|
434
496
|
/**
|
|
497
|
+
* `MarkdownBody`(`markdown.tsx`)memo comparator 的**语言探针**:借一句固定键的译文,当
|
|
498
|
+
* 「当前是哪种语言」的读数。为什么借译文而不问 locale 服务:宿主 locale 服务的窄镜像不暴露
|
|
499
|
+
* 当前语言(受窄镜像形状约束,不做 `locale()` 探针),当前语言只能从 `t` 的输出里读出来。
|
|
500
|
+
*
|
|
501
|
+
* 为什么必须在**渲染时**现算、由调用方存进 props:宿主 `t` 读的是**当前** locale,若在
|
|
502
|
+
* comparator 里现调,prev/next 那一拍都取到新值、恒等,换语言就漏了——只有渲染那一刻算好的
|
|
503
|
+
* 那份才是换语言那一拍的读数。
|
|
504
|
+
*
|
|
505
|
+
* 为什么拿 `t` 的输出当读数、而不是拿 `t` 的身份:宿主 `t` 是 `bind` 出来的**稳定身份**
|
|
506
|
+
* (`createTranslate` 一次客户端激活只造一个),换语言后同一个 `t` 吐另一套词——身份不变、
|
|
507
|
+
* 词变了,所以判据必须是这个词(探针值)而不是身份本身。
|
|
508
|
+
*
|
|
509
|
+
* 「借哪句」的知识只住这里:调用方与测试一律传 `probe={localeProbe(t)}`,不各自记得借哪句。
|
|
510
|
+
*
|
|
511
|
+
* **失效模式**:借的那句若在两种语言下译成同一个字面,探针就分不出换语言——memo 恒等、旧语言滞留。
|
|
512
|
+
* 这条由 `tests/client/markdown.test.tsx` 的换语言用例兜住;换借句或改那句译文时先看它红不红。
|
|
513
|
+
*/
|
|
514
|
+
function localeProbe(t) {
|
|
515
|
+
return t("common.close");
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
435
518
|
* 把一句话里内嵌的那一段拆出来:`'…文件,之后没有任何副本可以恢复。'` 交给调用面用 `<b>` 包中间那段。
|
|
436
519
|
*
|
|
437
520
|
* 为什么不留成"前段 / 内嵌段 / 后段"三个键:那等于把英文和中文的语序各钉一次,两段翻译各自
|
|
@@ -489,16 +572,19 @@ window.__ModuleLoader__.load({
|
|
|
489
572
|
* (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-notices.ts`)、
|
|
490
573
|
* 批量选中集(`batch-selection-store.ts`)等。它们都挂在 `shell.overlay` / `main` / 菜单行这些
|
|
491
574
|
* 互不相邻的座位上,模块级是唯一的共同点。
|
|
492
|
-
* 在这之前每个文件各写一遍「`let` 现场 +
|
|
575
|
+
* 在这之前每个文件各写一遍「`let` 现场 + 监听集合 + `notify` + `useSyncExternalStore`」,
|
|
493
576
|
* 各自的 seq / revision 也在分头维护。现在订阅/通告只住这一处。
|
|
494
577
|
*
|
|
495
|
-
* 另有一个**懒读快照**变体(`createLazyStore
|
|
496
|
-
*
|
|
578
|
+
* 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(如宿主行槽位登记)现读,
|
|
579
|
+
* 脏了才重读;订阅脚手架仍住这里。
|
|
497
580
|
*
|
|
498
581
|
* 还有一个**最新票句柄**(`createLatestWins`):并发的异步读里"只有最新那次可以落地"原先在每个
|
|
499
582
|
* 调用方各写一遍自增序号,现在只住这一处。它只拥有 compare-and-set——防抖、轮询、焦点策略仍是
|
|
500
583
|
* 调用方的现场,不搬进来。
|
|
501
584
|
*
|
|
585
|
+
* 以及一枚**引用归一 hook**(`useLatest`):输入逐渲染换新对象时,回调经它只读最新那一份,
|
|
586
|
+
* 身份恒稳、可安全进 effect 依赖。这一形状也只住这一处,调用方不再各写一遍「ref + 每渲染写回」。
|
|
587
|
+
*
|
|
502
588
|
* 语义(四条,别在调用方另算一遍):
|
|
503
589
|
* - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
|
|
504
590
|
* - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
|
|
@@ -551,7 +637,7 @@ window.__ModuleLoader__.load({
|
|
|
551
637
|
}
|
|
552
638
|
};
|
|
553
639
|
}
|
|
554
|
-
function createLazyStore(initial, read
|
|
640
|
+
function createLazyStore(initial, read) {
|
|
555
641
|
let cached = initial;
|
|
556
642
|
let dirty = true;
|
|
557
643
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -571,17 +657,11 @@ window.__ModuleLoader__.load({
|
|
|
571
657
|
notify();
|
|
572
658
|
},
|
|
573
659
|
subscribe(listener) {
|
|
574
|
-
|
|
660
|
+
if (listeners.size === 0) dirty = true;
|
|
575
661
|
listeners.add(listener);
|
|
576
|
-
if (reattaching) {
|
|
577
|
-
dirty = true;
|
|
578
|
-
lifecycle.onFirstSubscribe?.();
|
|
579
|
-
}
|
|
580
662
|
return () => {
|
|
581
663
|
listeners.delete(listener);
|
|
582
|
-
if (listeners.size
|
|
583
|
-
lifecycle.onLastUnsubscribe?.();
|
|
584
|
-
dirty = true;
|
|
664
|
+
if (listeners.size === 0) dirty = true;
|
|
585
665
|
};
|
|
586
666
|
}
|
|
587
667
|
};
|
|
@@ -600,170 +680,155 @@ window.__ModuleLoader__.load({
|
|
|
600
680
|
}
|
|
601
681
|
};
|
|
602
682
|
}
|
|
603
|
-
//#endregion
|
|
604
|
-
//#region src/client/batch-selection-store.ts
|
|
605
683
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
* 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
|
|
611
|
-
* `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
|
|
612
|
-
* 的 `createStore` 里,本文件不另写一份。
|
|
613
|
-
*
|
|
614
|
-
* **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
|
|
615
|
-
* (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
|
|
616
|
-
*
|
|
617
|
-
* 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
|
|
618
|
-
* 「全选」追加的条目按 `host-rows.ts` 读到的 DOM 序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
|
|
684
|
+
* 引用归一:输入允许调用方逐渲染换新对象,回调只经这份 ref 读**最新**那一份,于是依赖它的回调
|
|
685
|
+
* 身份恒稳、调用方的 effect 可以放心把它们当依赖——「输入必须是稳定引用」的隐式契约就此不存在
|
|
686
|
+
* (issue #8 第 3 条)。写回在 effect 里(提交后),与 React 的渲染期只读约定一致。
|
|
687
|
+
* 这一形状的唯一实现:别在调用方再手写一份「useRef + 每渲染写回」。
|
|
619
688
|
*/
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const selectionStore = createStore(INITIAL);
|
|
625
|
-
function getSelection() {
|
|
626
|
-
return selectionStore.get();
|
|
627
|
-
}
|
|
628
|
-
function getPicks() {
|
|
629
|
-
return getSelection().picks;
|
|
630
|
-
}
|
|
631
|
-
/** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
|
|
632
|
-
function useSelection() {
|
|
633
|
-
return selectionStore.use();
|
|
634
|
-
}
|
|
635
|
-
/** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
|
|
636
|
-
function enterSelection() {
|
|
637
|
-
selectionStore.update((current) => current.selecting ? current : {
|
|
638
|
-
...current,
|
|
639
|
-
selecting: true
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
643
|
-
function exitSelection() {
|
|
644
|
-
selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
|
|
645
|
-
}
|
|
646
|
-
/** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
|
|
647
|
-
function togglePick(item) {
|
|
648
|
-
selectionStore.update((current) => {
|
|
649
|
-
const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
|
|
650
|
-
return {
|
|
651
|
-
...current,
|
|
652
|
-
picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
|
|
653
|
-
};
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
/** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
|
|
657
|
-
function clearPicks() {
|
|
658
|
-
selectionStore.update((current) => current.picks.length === 0 ? current : {
|
|
659
|
-
...current,
|
|
660
|
-
picks: []
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
/** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
|
|
664
|
-
function addPicks(items) {
|
|
665
|
-
selectionStore.update((current) => {
|
|
666
|
-
const chosen = new Set(current.picks.map((item) => item.sessionId));
|
|
667
|
-
const additions = [];
|
|
668
|
-
for (const item of items) {
|
|
669
|
-
if (chosen.has(item.sessionId)) continue;
|
|
670
|
-
chosen.add(item.sessionId);
|
|
671
|
-
additions.push(item);
|
|
672
|
-
}
|
|
673
|
-
return additions.length === 0 ? current : {
|
|
674
|
-
...current,
|
|
675
|
-
picks: [...current.picks, ...additions]
|
|
676
|
-
};
|
|
689
|
+
function useLatest(value) {
|
|
690
|
+
const ref = (0, react.useRef)(value);
|
|
691
|
+
(0, react.useEffect)(() => {
|
|
692
|
+
ref.current = value;
|
|
677
693
|
});
|
|
678
|
-
|
|
679
|
-
/** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
|
|
680
|
-
function shrinkPicksToFailures(failedSessionIds) {
|
|
681
|
-
const keep = new Set(failedSessionIds);
|
|
682
|
-
selectionStore.update((current) => ({
|
|
683
|
-
...current,
|
|
684
|
-
picks: current.picks.filter((item) => keep.has(item.sessionId))
|
|
685
|
-
}));
|
|
694
|
+
return ref;
|
|
686
695
|
}
|
|
687
696
|
//#endregion
|
|
688
697
|
//#region src/client/host-rows.ts
|
|
689
698
|
/**
|
|
690
|
-
*
|
|
699
|
+
* 宿主会话行的适配:**可见行由槽位组件登记**;选择动作与模式内整行点击守卫住 `batch-pick.ts`。
|
|
700
|
+
*
|
|
701
|
+
* 选中集(模式 + 名单)只住 `batch-selection-store.ts`;本文件只回答页面上的「当前有哪些可勾选的
|
|
702
|
+
* 会话行」:行槽位开关的挂载登记、渲染快照与按 id 的行查找。「这一行可不可勾」的 live 判据
|
|
703
|
+
* 也只住这里(`pickableRowOf` / `readVisibleSelectableRows`)。DOM 只用于守卫认行,且那处 DOM
|
|
704
|
+
* 读取随守卫住在 `batch-pick.ts`(ADR-0034 记下的受约束偏离)。
|
|
691
705
|
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
* 模式内整行点击在 document 捕获阶段拦截,避免触发宿主的会话切换。
|
|
706
|
+
* 为什么不再扫 DOM:宿主的行槽位只在行可见时挂载,行首/行尾两颗开关(`BatchPickCheckbox` /
|
|
707
|
+
* `BatchPickButton`)都会登记 `{title, live}`;挂载/卸载就是「这一行在不在可见集合」的契约,
|
|
708
|
+
* 不需要 MutationObserver。
|
|
696
709
|
*/
|
|
697
710
|
/**
|
|
698
|
-
* 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒
|
|
699
|
-
*
|
|
700
|
-
*
|
|
711
|
+
* 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行首/行尾开关
|
|
712
|
+
* 在 effect 里把这两件登记进这张表。生命周期跟着**行槽位组件的挂载**走:卸载即注销
|
|
713
|
+
* ({@link unregisterHostRowInfo}),表的大小被"当前渲染的行"钉住,而不是随用过的会话只增不减;
|
|
714
|
+
* 行重挂载时 effect 会重新登记。**这张表就是可见行的事实源**(ADR-0034),不再由 DOM 行列表反查。
|
|
715
|
+
*
|
|
716
|
+
* **按挂载实例计数**:同一个会话在 DOM 里可能有两行(重复行),两行各挂一组开关;
|
|
717
|
+
* 其中一颗卸载时不能把另一颗还在用的登记一起摘掉——那会让这一行从"全选"里消失、标题也变空。
|
|
701
718
|
*/
|
|
702
719
|
const rowInfo = /* @__PURE__ */ new Map();
|
|
703
720
|
/**
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
* 不算(标题只认 `rowInfo` 的登记值)。首个订阅者起开始观察,最后一个走了断开。
|
|
721
|
+
* 渲染侧快照:`rowInfo` 变了才标脏;订阅脚手架住 `store.ts` 的 `createLazyStore`。
|
|
722
|
+
* `readVisibleSelectableRows` 按登记顺序(宿主渲染顺序)返回,不再观察 DOM。
|
|
707
723
|
*/
|
|
708
724
|
const EMPTY_ROWS = [];
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
725
|
+
const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows);
|
|
726
|
+
const EMPTY_ROW_INFO = {
|
|
727
|
+
title: "",
|
|
728
|
+
live: false
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* 行槽位开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
|
|
732
|
+
* 每次登记算**多一个挂载实例**;不给 info 时只占位,随后由 {@link updateHostRowInfo} 填值。
|
|
733
|
+
*/
|
|
734
|
+
function registerHostRowInfo(sessionId, info) {
|
|
735
|
+
const current = rowInfo.get(sessionId);
|
|
736
|
+
if (current === void 0) {
|
|
737
|
+
rowInfo.set(sessionId, {
|
|
738
|
+
info: info ?? EMPTY_ROW_INFO,
|
|
739
|
+
owners: 1
|
|
740
|
+
});
|
|
741
|
+
visibleRows.invalidate();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
current.owners += 1;
|
|
745
|
+
if (info !== void 0 && (current.info.title !== info.title || current.info.live !== info.live)) {
|
|
746
|
+
current.info = info;
|
|
747
|
+
visibleRows.invalidate();
|
|
748
|
+
}
|
|
719
749
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
750
|
+
/**
|
|
751
|
+
* 标题/运行态变了就地更新:**不注销、不重登记**,否则这一行会被挪到登记序末尾(ADR-0034 决定 2)。
|
|
752
|
+
*/
|
|
753
|
+
function updateHostRowInfo(sessionId, info) {
|
|
754
|
+
const current = rowInfo.get(sessionId);
|
|
755
|
+
if (current === void 0) return;
|
|
756
|
+
const title = info.title === "" ? current.info.title : info.title;
|
|
757
|
+
if (current.info.title === title && current.info.live === info.live) return;
|
|
758
|
+
current.info = {
|
|
759
|
+
title,
|
|
760
|
+
live: info.live
|
|
761
|
+
};
|
|
762
|
+
visibleRows.invalidate();
|
|
723
763
|
}
|
|
724
|
-
const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows, {
|
|
725
|
-
onFirstSubscribe: startObserving,
|
|
726
|
-
onLastUnsubscribe: stopObserving
|
|
727
|
-
});
|
|
728
764
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
765
|
+
* 槽位组件共用的登记生命周期:挂载登记、卸载注销;标题/运行态变化只走更新口。
|
|
766
|
+
* 两次 effect 分工是顺序契约:先占住首次登记序,再填值。
|
|
731
767
|
*/
|
|
732
|
-
function
|
|
768
|
+
function useHostRowRegistration(sessionId, info) {
|
|
769
|
+
(0, react.useEffect)(() => {
|
|
770
|
+
registerHostRowInfo(sessionId);
|
|
771
|
+
return () => {
|
|
772
|
+
unregisterHostRowInfo(sessionId);
|
|
773
|
+
};
|
|
774
|
+
}, [sessionId]);
|
|
775
|
+
const { title, live } = info;
|
|
776
|
+
(0, react.useEffect)(() => {
|
|
777
|
+
updateHostRowInfo(sessionId, {
|
|
778
|
+
title,
|
|
779
|
+
live
|
|
780
|
+
});
|
|
781
|
+
}, [
|
|
782
|
+
sessionId,
|
|
783
|
+
title,
|
|
784
|
+
live
|
|
785
|
+
]);
|
|
786
|
+
}
|
|
787
|
+
/** 行槽位开关卸载时调用:那一行已经离开渲染树,登记跟着走。
|
|
788
|
+
* 还有别的挂载实例在用同一行(重复行)就只减计数,不摘登记。 */
|
|
789
|
+
function unregisterHostRowInfo(sessionId) {
|
|
733
790
|
const current = rowInfo.get(sessionId);
|
|
734
|
-
if (current
|
|
735
|
-
|
|
791
|
+
if (current === void 0) return;
|
|
792
|
+
if (current.owners > 1) {
|
|
793
|
+
current.owners -= 1;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
rowInfo.delete(sessionId);
|
|
736
797
|
visibleRows.invalidate();
|
|
737
798
|
}
|
|
799
|
+
/** `live` 行不可勾:与置灰的勾选框、守卫拦下的整行点击同一条判据。 */
|
|
800
|
+
function isPickable(info) {
|
|
801
|
+
return !info.live;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* 守卫与全选共用的一行读数:这一行此刻可勾选时交回登记(含标题);未登记或在跑回 `undefined`。
|
|
805
|
+
* 登记表是可见行的事实源,所以"没登记"就是"这一行此刻不在可见集合里"。
|
|
806
|
+
*/
|
|
807
|
+
function pickableRowOf(sessionId) {
|
|
808
|
+
const entry = rowInfo.get(sessionId);
|
|
809
|
+
return entry === void 0 || !isPickable(entry.info) ? void 0 : entry.info;
|
|
810
|
+
}
|
|
738
811
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
812
|
+
* 「当前可见」的可选行:**已登记**的会话行(槽位开关还挂在渲染树上)——折叠组、归档过滤外的行
|
|
813
|
+
* 没有挂载,因此没有登记;在跑的行跳过(与置灰勾选框同口径)。标题/运行态来自登记值;
|
|
814
|
+
* 顺序是登记顺序(宿主按列表顺序挂载行)。
|
|
742
815
|
*
|
|
743
816
|
* 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
|
|
744
817
|
*/
|
|
745
818
|
function readVisibleSelectableRows() {
|
|
746
819
|
const rows = [];
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
const key = row.getAttribute("data-row-key") ?? "";
|
|
750
|
-
if (!key.startsWith("session:")) continue;
|
|
751
|
-
const sessionId = key.slice(8);
|
|
752
|
-
if (sessionId === "" || seen.has(sessionId)) continue;
|
|
753
|
-
const info = rowInfo.get(sessionId);
|
|
754
|
-
if (info === void 0 || info.live) continue;
|
|
755
|
-
seen.add(sessionId);
|
|
820
|
+
for (const [sessionId, entry] of rowInfo) {
|
|
821
|
+
if (!isPickable(entry.info)) continue;
|
|
756
822
|
rows.push({
|
|
757
823
|
sessionId,
|
|
758
|
-
title: info.title
|
|
824
|
+
title: entry.info.title
|
|
759
825
|
});
|
|
760
826
|
}
|
|
761
827
|
return rows;
|
|
762
828
|
}
|
|
763
829
|
/**
|
|
764
|
-
* `enabled === false`
|
|
765
|
-
* 浮条是 `shell.overlay`
|
|
766
|
-
* 都会白叫醒它重渲染一次。
|
|
830
|
+
* `enabled === false` 时的稳定替身:同一个空表、不订阅、不读登记。
|
|
831
|
+
* 浮条是 `shell.overlay` 的常驻槽位,模式外若照常订阅,每次行登记都会白叫醒它重渲染一次。
|
|
767
832
|
*/
|
|
768
833
|
function getEmptyRowsSnapshot() {
|
|
769
834
|
return EMPTY_ROWS;
|
|
@@ -772,126 +837,97 @@ window.__ModuleLoader__.load({
|
|
|
772
837
|
return () => {};
|
|
773
838
|
}
|
|
774
839
|
/**
|
|
775
|
-
* 渲染侧只读快照 +
|
|
776
|
-
*
|
|
840
|
+
* 渲染侧只读快照 + 登记订阅;`enabled === false` 不订阅也不读登记——行列表只随多选态存在,
|
|
841
|
+
* 模式外不必付订阅的成本。
|
|
777
842
|
*/
|
|
778
843
|
function useVisibleSelectableRows(enabled) {
|
|
779
844
|
return (0, react.useSyncExternalStore)(enabled ? visibleRows.subscribe : noopSubscribe, enabled ? visibleRows.get : getEmptyRowsSnapshot, enabled ? visibleRows.get : getEmptyRowsSnapshot);
|
|
780
845
|
}
|
|
781
|
-
/** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
|
|
782
|
-
function selectAllVisible() {
|
|
783
|
-
addPicks(readVisibleSelectableRows());
|
|
784
|
-
}
|
|
785
|
-
/**
|
|
786
|
-
* 同一个切换口该显示「全选」还是「取消全选」:
|
|
787
|
-
* - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
|
|
788
|
-
* - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
|
|
789
|
-
* - 其余(含 0 条)⇒ 全选。
|
|
790
|
-
*/
|
|
791
|
-
function areAllVisibleRowsSelected(rows, picks) {
|
|
792
|
-
if (picks.length === 0) return false;
|
|
793
|
-
if (rows.length === 0) return true;
|
|
794
|
-
const chosen = new Set(picks.map((item) => item.sessionId));
|
|
795
|
-
return rows.every((row) => chosen.has(row.sessionId));
|
|
796
|
-
}
|
|
797
|
-
/** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
|
|
798
|
-
const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
|
|
799
|
-
/**
|
|
800
|
-
* 多选态内的整行点击守卫(document 捕获阶段)。
|
|
801
|
-
*
|
|
802
|
-
* 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
|
|
803
|
-
* 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
|
|
804
|
-
* 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
|
|
805
|
-
* 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
|
|
806
|
-
*/
|
|
807
|
-
function useSelectionRowClickGuard() {
|
|
808
|
-
const selecting = useSelection().selecting;
|
|
809
|
-
(0, react.useEffect)(() => {
|
|
810
|
-
if (!selecting) return;
|
|
811
|
-
const handle = (event) => {
|
|
812
|
-
if (!(event.target instanceof Element)) return;
|
|
813
|
-
const row = event.target.closest("[data-row-key^=\"session:\"]");
|
|
814
|
-
if (row === null) return;
|
|
815
|
-
if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
|
|
816
|
-
event.preventDefault();
|
|
817
|
-
event.stopPropagation();
|
|
818
|
-
if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
|
|
819
|
-
const key = row.getAttribute("data-row-key") ?? "";
|
|
820
|
-
if (!key.startsWith("session:")) return;
|
|
821
|
-
const sessionId = key.slice(8);
|
|
822
|
-
if (sessionId === "") return;
|
|
823
|
-
const info = rowInfo.get(sessionId);
|
|
824
|
-
if (info?.live === true) return;
|
|
825
|
-
togglePick({
|
|
826
|
-
sessionId,
|
|
827
|
-
title: info?.title ?? ""
|
|
828
|
-
});
|
|
829
|
-
};
|
|
830
|
-
document.addEventListener("click", handle, true);
|
|
831
|
-
document.addEventListener("dblclick", handle, true);
|
|
832
|
-
return () => {
|
|
833
|
-
document.removeEventListener("click", handle, true);
|
|
834
|
-
document.removeEventListener("dblclick", handle, true);
|
|
835
|
-
};
|
|
836
|
-
}, [selecting]);
|
|
837
|
-
}
|
|
838
|
-
//#endregion
|
|
839
|
-
//#region src/shared/wire.ts
|
|
840
846
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
|
|
845
|
-
* 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
|
|
846
|
-
*/
|
|
847
|
-
/**
|
|
848
|
-
* 插件专属前缀。
|
|
849
|
-
*
|
|
850
|
-
* 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
|
|
851
|
-
* 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
|
|
852
|
-
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
847
|
+
* 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
|
|
848
|
+
* 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
|
|
853
849
|
*/
|
|
854
|
-
const
|
|
855
|
-
/**
|
|
850
|
+
const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api/dsh-session-delete-api`;
|
|
851
|
+
/** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
|
|
852
|
+
* (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
|
|
853
|
+
* 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
|
|
854
|
+
* 两半都不逐段手写一份。
|
|
855
|
+
* 写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
|
|
856
856
|
* 单条就是长度为 1 的批量,仓里不留第二条执行路径。
|
|
857
|
-
* - `
|
|
858
|
-
* - `
|
|
859
|
-
* - `
|
|
860
|
-
* - `
|
|
861
|
-
* - `
|
|
862
|
-
* - `
|
|
857
|
+
* - `list`:对账并读清单(`session-trash/` 里的目录是准)。
|
|
858
|
+
* - `ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
|
|
859
|
+
* - `detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts` 的 detail 行)。
|
|
860
|
+
* - `apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
861
|
+
* - `empty`:清空回收站(聚合回执,不按条回)。
|
|
862
|
+
* - `orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
|
|
863
863
|
* 这一段只负责"有多少、在哪、多大"。
|
|
864
|
-
* - `
|
|
865
|
-
* - `
|
|
866
|
-
* - `
|
|
864
|
+
* - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
|
|
865
|
+
* - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
|
|
866
|
+
* - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
|
|
867
867
|
const ROUTES = {
|
|
868
|
-
list:
|
|
868
|
+
list: {
|
|
869
|
+
path: "/list",
|
|
870
|
+
method: "GET"
|
|
871
|
+
},
|
|
869
872
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
870
|
-
ping:
|
|
873
|
+
ping: {
|
|
874
|
+
path: "/ping",
|
|
875
|
+
method: "GET"
|
|
876
|
+
},
|
|
871
877
|
/** 单条详情(读回收站目录里那条会话自己的日志)。 */
|
|
872
|
-
detail:
|
|
878
|
+
detail: {
|
|
879
|
+
path: "/detail",
|
|
880
|
+
method: "POST"
|
|
881
|
+
},
|
|
873
882
|
/** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
|
|
874
|
-
apply:
|
|
883
|
+
apply: {
|
|
884
|
+
path: "/apply",
|
|
885
|
+
method: "POST"
|
|
886
|
+
},
|
|
875
887
|
/** 清空回收站。 */
|
|
876
|
-
empty:
|
|
888
|
+
empty: {
|
|
889
|
+
path: "/empty",
|
|
890
|
+
method: "POST"
|
|
891
|
+
},
|
|
877
892
|
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
878
|
-
orphans:
|
|
893
|
+
orphans: {
|
|
894
|
+
path: "/orphans",
|
|
895
|
+
method: "GET"
|
|
896
|
+
},
|
|
879
897
|
/** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
|
|
880
|
-
attachment:
|
|
898
|
+
attachment: {
|
|
899
|
+
path: "/attachment",
|
|
900
|
+
method: "GET"
|
|
901
|
+
},
|
|
881
902
|
/** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
|
|
882
|
-
attachmentOrphans:
|
|
903
|
+
attachmentOrphans: {
|
|
904
|
+
path: "/attachment-orphans",
|
|
905
|
+
method: "GET"
|
|
906
|
+
},
|
|
883
907
|
/** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
|
|
884
|
-
attachmentClean:
|
|
908
|
+
attachmentClean: {
|
|
909
|
+
path: "/attachment-clean",
|
|
910
|
+
method: "POST"
|
|
911
|
+
}
|
|
885
912
|
};
|
|
886
913
|
/** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
|
|
887
914
|
* 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
|
|
888
915
|
const PARAMS = {
|
|
889
916
|
entryId: "entryId",
|
|
917
|
+
/** `/detail` 的分页游标:只取这个块序号之前的块。缺省 = 从尾部取。 */
|
|
918
|
+
before: "before",
|
|
890
919
|
action: "action",
|
|
891
920
|
ids: "ids",
|
|
892
921
|
attachmentId: "id",
|
|
893
922
|
attachmentKind: "kind",
|
|
894
|
-
attachmentName: "name"
|
|
923
|
+
attachmentName: "name",
|
|
924
|
+
/** 图片引用的媒体类型 / 宽高:只给宿主 `attachments.readImage` 做引用校验,不参与路径拼接。 */
|
|
925
|
+
attachmentMediaType: "mediaType",
|
|
926
|
+
/** 字节数:图片支与文件支都带——它是宿主读面的引用一部分,并先过 `maxAttachmentBytes` 闸
|
|
927
|
+
* (别为一张已知超限的图让宿主整份读出来)。 */
|
|
928
|
+
attachmentBytes: "bytes",
|
|
929
|
+
attachmentWidth: "width",
|
|
930
|
+
attachmentHeight: "height"
|
|
895
931
|
};
|
|
896
932
|
/**
|
|
897
933
|
* 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
|
|
@@ -906,6 +942,21 @@ window.__ModuleLoader__.load({
|
|
|
906
942
|
const candidate = value;
|
|
907
943
|
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
908
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* 运行期判形「这段 JSON 是不是信封」。中间层(反代 / SPA 兜底)会回 `{}`、`{"error":"…"}` 这类
|
|
947
|
+
* 不是信封的 JSON;判形不过就该走「回的不是信封」诊断,而不是把垃圾喂给展开层炸出
|
|
948
|
+
* TypeError / "undefined" 上屏(issue #8 第 1 条)。判据就是两臂的形状:`ok` 必须是布尔,
|
|
949
|
+
* `ok:true` 须带 `value` 键(值可以是 null),`ok:false` 的 `error` 须是带 string `code`/`message` 的对象。
|
|
950
|
+
*/
|
|
951
|
+
function isRemoteResult(value) {
|
|
952
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
953
|
+
const body = value;
|
|
954
|
+
if (typeof body.ok !== "boolean") return false;
|
|
955
|
+
if (body.ok) return "value" in body;
|
|
956
|
+
if (typeof body.error !== "object" || body.error === null) return false;
|
|
957
|
+
const error = body.error;
|
|
958
|
+
return typeof error.code === "string" && typeof error.message === "string";
|
|
959
|
+
}
|
|
909
960
|
const SESSION_DELETE_CODES = {
|
|
910
961
|
live: "sessiondelete/live",
|
|
911
962
|
"not-found": "sessiondelete/not-found",
|
|
@@ -1104,6 +1155,7 @@ window.__ModuleLoader__.load({
|
|
|
1104
1155
|
};
|
|
1105
1156
|
}
|
|
1106
1157
|
const landedIds = receipt.action === "delete" ? landedEntryIds(receipt) : receipt.action === "restore" ? restoredSessionIds(receipt, sessionIdOfEntry) : [];
|
|
1158
|
+
const attachmentCleanup = summarizeAttachmentCleanup(receipt);
|
|
1107
1159
|
return {
|
|
1108
1160
|
summary: {
|
|
1109
1161
|
successIds,
|
|
@@ -1112,7 +1164,7 @@ window.__ModuleLoader__.load({
|
|
|
1112
1164
|
partial,
|
|
1113
1165
|
landedIds,
|
|
1114
1166
|
anySuccess: successIds.length > 0,
|
|
1115
|
-
attachmentCleanup:
|
|
1167
|
+
...attachmentCleanup !== void 0 ? { attachmentCleanup } : {}
|
|
1116
1168
|
},
|
|
1117
1169
|
failures
|
|
1118
1170
|
};
|
|
@@ -1153,11 +1205,15 @@ window.__ModuleLoader__.load({
|
|
|
1153
1205
|
//#endregion
|
|
1154
1206
|
//#region src/client/remote.ts
|
|
1155
1207
|
/**
|
|
1156
|
-
* 浏览器半的取数通道:同源 fetch
|
|
1208
|
+
* 浏览器半的取数通道:同源 fetch 宿主 `connection.fetch` 的 exact Fetch 路由(`/api` + 插件前缀)。
|
|
1209
|
+
* 信封与失败原文在这一层收口。
|
|
1157
1210
|
*
|
|
1158
|
-
* -
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1211
|
+
* - 九段注册在宿主 `connection.fetch.register` 上,鉴权归宿主 `/api` bridge(ADR-0033);
|
|
1212
|
+
* 没有旧前缀 fallback——支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`。
|
|
1213
|
+
* 官方 remote.<namespace> 仍不可用(第三方拿不到 Typert 描述符的动态发现挂载点)。
|
|
1214
|
+
* - 九段的**段名与方法**住 `src/shared/wire.ts` 的结构化段名表(`ROUTES` 每项 `{ path, method }`),
|
|
1215
|
+
* 本文件只投影路径(`prefix + ROUTES.x.path`)与方法(GET 段直接取 `ROUTES.x.method`,写段把它
|
|
1216
|
+
* 交给 `post()`)——方法字面量不在本文件出现第二份;
|
|
1161
1217
|
* 写动作统一走 apply;`attachment` 是唯一二进制段,由 `src/client/attachment-view.tsx` 直接拼地址,
|
|
1162
1218
|
* 不经过本文件的信封通道;`attachment-orphans`(纯读)与 `attachment-clean`(聚合写)是孤儿附件的两段。
|
|
1163
1219
|
* - 信封与官方 unary 面同形:{ok:true,value} / {ok:false,error}。HTTP 往返本身失败才 reject;
|
|
@@ -1225,63 +1281,74 @@ window.__ModuleLoader__.load({
|
|
|
1225
1281
|
};
|
|
1226
1282
|
}
|
|
1227
1283
|
}
|
|
1284
|
+
/** 宿主 `/api` 的 401/403 是纯文本,不是本插件信封:固定中文原文只住这里(浏览器半唯一消费者)。 */
|
|
1285
|
+
const UNTRUSTED_MESSAGE = "非受信来源";
|
|
1286
|
+
const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
|
|
1228
1287
|
/**
|
|
1229
1288
|
* 一次 HTTP 往返 → 信封。**`ok:false` 也是落地**(返回,不抛);只有往返本身失败才抛:
|
|
1230
|
-
* 打不通(宿主没起来 /
|
|
1231
|
-
*
|
|
1289
|
+
* 打不通(宿主没起来 / 路由没挂)或回的不是信封(中间层回 `{}` / `{"error":"…"}` 这类非信封 JSON,
|
|
1290
|
+
* 或 SPA 兜底的 index.html——判形住 `isRemoteResult`,几种都落同一条诊断)。两种抛都带原文,
|
|
1291
|
+
* 组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
|
|
1232
1292
|
*/
|
|
1233
1293
|
async function requestEnvelope(path, init) {
|
|
1234
1294
|
let response;
|
|
1235
1295
|
try {
|
|
1236
1296
|
response = await fetch(path, init);
|
|
1237
1297
|
} catch (reason) {
|
|
1238
|
-
throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}
|
|
1298
|
+
throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`, { cause: reason });
|
|
1239
1299
|
}
|
|
1300
|
+
let body;
|
|
1240
1301
|
try {
|
|
1241
|
-
|
|
1302
|
+
body = await response.json();
|
|
1242
1303
|
} catch {
|
|
1304
|
+
body = void 0;
|
|
1305
|
+
}
|
|
1306
|
+
if (!isRemoteResult(body)) {
|
|
1307
|
+
if (response.status === 401) throw new Error(UNAUTHENTICATED_MESSAGE);
|
|
1308
|
+
if (response.status === 403) throw new Error(UNTRUSTED_MESSAGE);
|
|
1243
1309
|
throw new Error(`取数通道回的不是信封(HTTP ${String(response.status)})`);
|
|
1244
1310
|
}
|
|
1311
|
+
return body;
|
|
1245
1312
|
}
|
|
1246
1313
|
/**
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
* `dsh web` 打印的 URL 打开过的页面成立(凭据是那行 URL 换来的 cookie,不是插件自己的秘密)。
|
|
1314
|
+
* exact Fetch 路由的 fetch 实现。**相对路径**:不拼 host、不认 origin——页面本身就是从宿主页面
|
|
1315
|
+
* 加载的,同源是这条通道的全部前提;鉴权归宿主 `/api` bridge(ADR-0033)。所以这条通道**只对**
|
|
1316
|
+
* 宿主页面打开过、带宿主身份的客户端成立(凭据是那次访问换来的 cookie,不是插件自己的秘密)。
|
|
1251
1317
|
*/
|
|
1252
|
-
function createHttpRemote(
|
|
1253
|
-
const post = (body) => ({
|
|
1254
|
-
method:
|
|
1318
|
+
function createHttpRemote() {
|
|
1319
|
+
const post = (route, body) => ({
|
|
1320
|
+
method: route.method,
|
|
1255
1321
|
headers: { "content-type": "application/json" },
|
|
1256
1322
|
body: JSON.stringify(body)
|
|
1257
1323
|
});
|
|
1258
1324
|
return {
|
|
1259
|
-
list: () => requestEnvelope(
|
|
1260
|
-
ping: () => requestEnvelope(
|
|
1261
|
-
orphans: () => requestEnvelope(
|
|
1262
|
-
attachmentOrphans: () => requestEnvelope(
|
|
1263
|
-
attachmentClean: () => requestEnvelope(
|
|
1264
|
-
detail: (entryId) => requestEnvelope(
|
|
1265
|
-
|
|
1325
|
+
list: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.list.path, { method: ROUTES.list.method }),
|
|
1326
|
+
ping: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.ping.path, { method: ROUTES.ping.method }),
|
|
1327
|
+
orphans: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.orphans.path, { method: ROUTES.orphans.method }),
|
|
1328
|
+
attachmentOrphans: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.attachmentOrphans.path, { method: ROUTES.attachmentOrphans.method }),
|
|
1329
|
+
attachmentClean: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.attachmentClean.path, post(ROUTES.attachmentClean, {})),
|
|
1330
|
+
detail: (entryId, before) => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.detail.path, post(ROUTES.detail, {
|
|
1331
|
+
[PARAMS.entryId]: entryId,
|
|
1332
|
+
...before === void 0 ? {} : { [PARAMS.before]: before }
|
|
1333
|
+
})),
|
|
1334
|
+
apply: (action, ids) => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.apply.path, post(ROUTES.apply, {
|
|
1266
1335
|
[PARAMS.action]: action,
|
|
1267
1336
|
[PARAMS.ids]: [...ids]
|
|
1268
1337
|
})),
|
|
1269
|
-
empty: () => requestEnvelope(
|
|
1338
|
+
empty: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.empty.path, post(ROUTES.empty, {}))
|
|
1270
1339
|
};
|
|
1271
1340
|
}
|
|
1272
1341
|
/**
|
|
1273
|
-
*
|
|
1342
|
+
* 探测唯一通道:一次 `GET /ping` 回成功信封(`pong`)才注册入口。
|
|
1274
1343
|
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
* 探测用一次零成本的 `GET /ping`,不读清单,避免每次页面加载都跑全表对账。
|
|
1344
|
+
* - 401/403 是宿主 `/api` 的纯文本身份/归属失败:入口不出现,不装禁用态。
|
|
1345
|
+
* - 失败信封 / 404 / 非信封同样按不可用处理:`/api` 网关对未知路径也可能回错误形状,
|
|
1346
|
+
* 不能据此把入口摆出来让每个动作都失败。
|
|
1280
1347
|
*/
|
|
1281
1348
|
async function probeChannel(raw) {
|
|
1282
1349
|
try {
|
|
1283
|
-
await raw.ping();
|
|
1284
|
-
return
|
|
1350
|
+
const result = await raw.ping();
|
|
1351
|
+
return result.ok && result.value === "pong";
|
|
1285
1352
|
} catch {
|
|
1286
1353
|
return false;
|
|
1287
1354
|
}
|
|
@@ -1325,12 +1392,96 @@ window.__ModuleLoader__.load({
|
|
|
1325
1392
|
scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
|
|
1326
1393
|
scanAttachmentOrphans: () => raw.attachmentOrphans().then((result) => unwrapRemoteResult(result)),
|
|
1327
1394
|
cleanAttachmentOrphans: () => raw.attachmentClean().then((result) => unwrapRemoteResult(result)),
|
|
1328
|
-
detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
|
|
1395
|
+
detail: (entryId, before) => raw.detail(entryId, before).then((result) => unwrapRemoteResult(result)),
|
|
1329
1396
|
applyOutcomes: applyOnce,
|
|
1330
1397
|
emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
|
|
1331
1398
|
};
|
|
1332
1399
|
}
|
|
1333
1400
|
//#endregion
|
|
1401
|
+
//#region src/client/batch-selection-store.ts
|
|
1402
|
+
/**
|
|
1403
|
+
* 宿主列表批量删除的**选中集现场**:多选模式 + 选中名单,一份合并快照。
|
|
1404
|
+
*
|
|
1405
|
+
* 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
|
|
1406
|
+
* 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
|
|
1407
|
+
* 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
|
|
1408
|
+
* `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
|
|
1409
|
+
* 的 `createStore` 里,本文件不另写一份。
|
|
1410
|
+
*
|
|
1411
|
+
* **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
|
|
1412
|
+
* (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
|
|
1413
|
+
*
|
|
1414
|
+
* 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
|
|
1415
|
+
* 「全选」追加的条目按 `host-rows.ts` 的槽位登记序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
|
|
1416
|
+
*/
|
|
1417
|
+
const INITIAL = {
|
|
1418
|
+
selecting: false,
|
|
1419
|
+
picks: []
|
|
1420
|
+
};
|
|
1421
|
+
const selectionStore = createStore(INITIAL);
|
|
1422
|
+
function getSelection() {
|
|
1423
|
+
return selectionStore.get();
|
|
1424
|
+
}
|
|
1425
|
+
function getPicks() {
|
|
1426
|
+
return getSelection().picks;
|
|
1427
|
+
}
|
|
1428
|
+
/** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
|
|
1429
|
+
function useSelection() {
|
|
1430
|
+
return selectionStore.use();
|
|
1431
|
+
}
|
|
1432
|
+
/** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
|
|
1433
|
+
function enterSelection() {
|
|
1434
|
+
selectionStore.update((current) => current.selecting ? current : {
|
|
1435
|
+
...current,
|
|
1436
|
+
selecting: true
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
|
|
1440
|
+
function exitSelection() {
|
|
1441
|
+
selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
|
|
1442
|
+
}
|
|
1443
|
+
/** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
|
|
1444
|
+
function togglePick(item) {
|
|
1445
|
+
selectionStore.update((current) => {
|
|
1446
|
+
const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
|
|
1447
|
+
return {
|
|
1448
|
+
...current,
|
|
1449
|
+
picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
|
|
1450
|
+
};
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
/** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
|
|
1454
|
+
function clearPicks() {
|
|
1455
|
+
selectionStore.update((current) => current.picks.length === 0 ? current : {
|
|
1456
|
+
...current,
|
|
1457
|
+
picks: []
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
/** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
|
|
1461
|
+
function addPicks(items) {
|
|
1462
|
+
selectionStore.update((current) => {
|
|
1463
|
+
const chosen = new Set(current.picks.map((item) => item.sessionId));
|
|
1464
|
+
const additions = [];
|
|
1465
|
+
for (const item of items) {
|
|
1466
|
+
if (chosen.has(item.sessionId)) continue;
|
|
1467
|
+
chosen.add(item.sessionId);
|
|
1468
|
+
additions.push(item);
|
|
1469
|
+
}
|
|
1470
|
+
return additions.length === 0 ? current : {
|
|
1471
|
+
...current,
|
|
1472
|
+
picks: [...current.picks, ...additions]
|
|
1473
|
+
};
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
/** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
|
|
1477
|
+
function shrinkPicksToFailures(failedSessionIds) {
|
|
1478
|
+
const keep = new Set(failedSessionIds);
|
|
1479
|
+
selectionStore.update((current) => ({
|
|
1480
|
+
...current,
|
|
1481
|
+
picks: current.picks.filter((item) => keep.has(item.sessionId))
|
|
1482
|
+
}));
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1334
1485
|
//#region src/client/ui/Checkbox.tsx
|
|
1335
1486
|
/**
|
|
1336
1487
|
* 勾选框,回收站多选态用。
|
|
@@ -1499,7 +1650,7 @@ window.__ModuleLoader__.load({
|
|
|
1499
1650
|
}
|
|
1500
1651
|
//#endregion
|
|
1501
1652
|
//#region src/client/ui/ui.css?dsh-css-text
|
|
1502
|
-
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 * ⑥ `.sd-batch-pick-leading`(同日)——宿主行首那格只给 16×20,勾选框要一层 inline-flex 包装才压得回\r\n * 同样大小,官方没有这层包装。\r\n * 整张表里就这六条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的整套 UA reset。宿主 `DisclosureRow` 那份是\r\n * `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表会把背景、边框、内距、字体、居中与\r\n * flex 基线一起渗进来(真机上就是项目名整条居中,2026-10-15 截图)。它写在\r\n * `src/client/trash-panel.tsx` 组头那一行的 inline 样式上,**具体 reset 了哪几项由\r\n * `tests/skeleton.test.ts` 逐条钉住**(不在这里再抄一份名单)——所以「与官方逐条比对」时本表仍然只数\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 实测)——不自己描一条,键盘走\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 改版,**只在\r\n 多选模式内渲染**)。格本身由宿主居中(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";
|
|
1653
|
+
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=\"row\"` 且要吃 ↑↓,而这一代 `--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 * ⑥ `.sd-batch-pick-leading`(同日)——宿主行首那格只给 16×20,勾选框要一层 inline-flex 包装才压得回\r\n * 同样大小,官方没有这层包装;⑦ `.sd-toast-action:disabled`(2026-11-01)——撤销横幅在飞时那颗动作要\r\n * 看着就按不动,官方 Toast 的 action 没有禁用档,而我们这份自带 `color`、浏览器默认置灰不生效,\r\n * 档位与③的禁用档同源(`opacity` + `not-allowed`)。\r\n * 整张表里就这七条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的整套 UA reset。宿主 `DisclosureRow` 那份是\r\n * `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表会把背景、边框、内距、字体、居中与\r\n * flex 基线一起渗进来(真机上就是项目名整条居中,2026-10-15 截图)。它写在\r\n * `src/client/trash-panel.tsx` 组头那一行的 inline 样式上,**具体 reset 了哪几项由\r\n * `tests/skeleton.test.ts` 逐条钉住**(不在这里再抄一份名单)——所以「与官方逐条比对」时本表仍然只数\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/* 本插件自造的第七条(不是抄本,见文件头点名):撤销横幅在飞时那颗动作要**看着就按不动**。\r\n 官方 Toast 的 action 只有常态与 hover,没有禁用档;而 `.sd-toast-action` 自带 `color`,\r\n 浏览器默认的置灰根本不生效(真机上只会看见一个光标照常是手、点下去没反应的「撤销」)。\r\n 禁用档与行尾勾选开关同源(`opacity` + `not-allowed`),不新造颜色。 */\r\n.sd-toast-action:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\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=\"row\"` 的可聚焦 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 改版,**只在\r\n 多选模式内渲染**)。格本身由宿主居中(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";
|
|
1503
1654
|
//#endregion
|
|
1504
1655
|
//#region src/client/ui/markdown.css?dsh-css-text
|
|
1505
1656
|
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 * (图片渲成本地化占位,见 `copy.ts` 的 `detail.image` / `detail.image.alt`)/ `.katex-display` /\r\n * `:global(.md-code-block)` / `.shiki`(高亮由宿主的 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";
|
|
@@ -1689,21 +1840,6 @@ window.__ModuleLoader__.load({
|
|
|
1689
1840
|
//#endregion
|
|
1690
1841
|
//#region src/client/batch-select.tsx
|
|
1691
1842
|
/**
|
|
1692
|
-
* 宿主列表批量删除的**控件面**:行首勾选框、行尾兜底开关,
|
|
1693
|
-
* 以及确认框/浮条读的那一份计数问句。
|
|
1694
|
-
*
|
|
1695
|
-
* 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
|
|
1696
|
-
* 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
|
|
1697
|
-
* 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
|
|
1698
|
-
* store 能让他们看见同一份名单;本文件不再持有任何现场。
|
|
1699
|
-
*
|
|
1700
|
-
* **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
|
|
1701
|
-
* 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
|
|
1702
|
-
* 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
|
|
1703
|
-
* 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
|
|
1704
|
-
* 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
|
|
1705
|
-
*/
|
|
1706
|
-
/**
|
|
1707
1843
|
* 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
|
|
1708
1844
|
*
|
|
1709
1845
|
* **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
|
|
@@ -1724,8 +1860,12 @@ window.__ModuleLoader__.load({
|
|
|
1724
1860
|
* (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
|
|
1725
1861
|
* 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
|
|
1726
1862
|
*/
|
|
1727
|
-
function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
|
|
1863
|
+
function BatchPickCheckbox({ sessionId, title, live = false, t = zhTranslate }) {
|
|
1728
1864
|
const { selecting, picks } = useSelection();
|
|
1865
|
+
useHostRowRegistration(sessionId, {
|
|
1866
|
+
title,
|
|
1867
|
+
live
|
|
1868
|
+
});
|
|
1729
1869
|
const on = picks.some((candidate) => candidate.sessionId === sessionId);
|
|
1730
1870
|
if (!selecting) return null;
|
|
1731
1871
|
const name = title === "" ? t("common.untitled") : title;
|
|
@@ -1754,16 +1894,10 @@ window.__ModuleLoader__.load({
|
|
|
1754
1894
|
function BatchPickButton({ sessionId, title, live = false, t = zhTranslate }) {
|
|
1755
1895
|
const { selecting, picks } = useSelection();
|
|
1756
1896
|
const on = picks.some((candidate) => candidate.sessionId === sessionId);
|
|
1757
|
-
(
|
|
1758
|
-
registerHostRowInfo(sessionId, {
|
|
1759
|
-
title,
|
|
1760
|
-
live
|
|
1761
|
-
});
|
|
1762
|
-
}, [
|
|
1763
|
-
sessionId,
|
|
1897
|
+
useHostRowRegistration(sessionId, {
|
|
1764
1898
|
title,
|
|
1765
1899
|
live
|
|
1766
|
-
|
|
1900
|
+
});
|
|
1767
1901
|
if (!selecting) return null;
|
|
1768
1902
|
const name = title === "" ? t("common.untitled") : title;
|
|
1769
1903
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
|
|
@@ -1794,21 +1928,26 @@ window.__ModuleLoader__.load({
|
|
|
1794
1928
|
//#endregion
|
|
1795
1929
|
//#region src/client/busy-gate.ts
|
|
1796
1930
|
/**
|
|
1797
|
-
*
|
|
1931
|
+
* 「这一趟还算忙」的一枚闸——面板、两个确认框与撤销横幅共用的一份实现(各面各持一枚实例)。
|
|
1798
1932
|
*
|
|
1799
|
-
*
|
|
1800
|
-
* 任务落地或抛错都把 busy 放回 false
|
|
1933
|
+
* 为什么收回一处:各面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有两条:
|
|
1934
|
+
* 任务落地或抛错都把 busy 放回 false;同一实例同一时刻只跑一趟——在飞时再包一趟会被**同步**
|
|
1935
|
+
* 挡回(`busy` 是 state,要等下一拍才上屏,同一 tick 的连点拦不住),不做引用计数,多一层计数
|
|
1801
1936
|
* 只会把「这一趟什么时候算完」变成第二个判据。
|
|
1802
1937
|
*
|
|
1803
|
-
* 边界:闸只拥有 busy
|
|
1938
|
+
* 边界:闸只拥有 busy 的生命周期与同实例防重,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
|
|
1804
1939
|
*/
|
|
1805
1940
|
function useBusyGate() {
|
|
1806
1941
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
1942
|
+
const flying = (0, react.useRef)(false);
|
|
1807
1943
|
const around = (0, react.useCallback)(async (task) => {
|
|
1944
|
+
if (flying.current) return void 0;
|
|
1945
|
+
flying.current = true;
|
|
1808
1946
|
setBusy(true);
|
|
1809
1947
|
try {
|
|
1810
1948
|
return await task();
|
|
1811
1949
|
} finally {
|
|
1950
|
+
flying.current = false;
|
|
1812
1951
|
setBusy(false);
|
|
1813
1952
|
}
|
|
1814
1953
|
}, []);
|
|
@@ -2018,7 +2157,7 @@ window.__ModuleLoader__.load({
|
|
|
2018
2157
|
/**
|
|
2019
2158
|
* 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
|
|
2020
2159
|
*
|
|
2021
|
-
* 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(
|
|
2160
|
+
* 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
|
|
2022
2161
|
* **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
|
|
2023
2162
|
* 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-notices.ts`)
|
|
2024
2163
|
* 同理。
|
|
@@ -2105,23 +2244,117 @@ window.__ModuleLoader__.load({
|
|
|
2105
2244
|
}));
|
|
2106
2245
|
}
|
|
2107
2246
|
//#endregion
|
|
2247
|
+
//#region src/client/pending-open.ts
|
|
2248
|
+
/**
|
|
2249
|
+
* 重开等待的唯一生命周期:一张票、一条订阅、一个放弃 timer、一次 settle/cleanup。
|
|
2250
|
+
*
|
|
2251
|
+
* ADR-0036 的语义不变:**订阅优先、出现即开、最多等一个放弃边界、不轮询**。这里把这段状态机
|
|
2252
|
+
* 收成一个可取消句柄:调用方 `schedule` 一次、`cancel` 一次;票、订阅、timer 与 settle 标志
|
|
2253
|
+
* 都关在 implementation 里,不再由 main-view 在四个调用点成对手工清理。
|
|
2254
|
+
*
|
|
2255
|
+
* 依赖(store 的 subscribe、是否已出现、是否还能抢焦点、怎么打开)由调用方交进来——本模块不认宿主。
|
|
2256
|
+
*/
|
|
2257
|
+
/** 默认放弃边界:给宿主 `api-session/added` 到客户端摘要落地留一拍(ADR-0036 的产品决定)。 */
|
|
2258
|
+
const DEFAULT_GIVE_UP_MS = 2e3;
|
|
2259
|
+
function createPendingOpen(giveUpMs = DEFAULT_GIVE_UP_MS) {
|
|
2260
|
+
const latest = createLatestWins();
|
|
2261
|
+
let cleanup;
|
|
2262
|
+
const cancel = () => {
|
|
2263
|
+
latest.invalidate();
|
|
2264
|
+
const stop = cleanup;
|
|
2265
|
+
cleanup = void 0;
|
|
2266
|
+
stop?.();
|
|
2267
|
+
};
|
|
2268
|
+
return {
|
|
2269
|
+
cancel,
|
|
2270
|
+
schedule(input) {
|
|
2271
|
+
cancel();
|
|
2272
|
+
const ticket = latest.issue();
|
|
2273
|
+
let settled = false;
|
|
2274
|
+
let unsubscribe;
|
|
2275
|
+
let timer;
|
|
2276
|
+
const unsubscribeNow = () => {
|
|
2277
|
+
if (unsubscribe === void 0) return;
|
|
2278
|
+
const stop = unsubscribe;
|
|
2279
|
+
unsubscribe = void 0;
|
|
2280
|
+
try {
|
|
2281
|
+
stop();
|
|
2282
|
+
} catch {}
|
|
2283
|
+
};
|
|
2284
|
+
const finish = () => {
|
|
2285
|
+
if (settled) return;
|
|
2286
|
+
settled = true;
|
|
2287
|
+
if (timer !== void 0) {
|
|
2288
|
+
clearTimeout(timer);
|
|
2289
|
+
timer = void 0;
|
|
2290
|
+
}
|
|
2291
|
+
unsubscribeNow();
|
|
2292
|
+
if (cleanup === finish) cleanup = void 0;
|
|
2293
|
+
};
|
|
2294
|
+
const open = () => {
|
|
2295
|
+
if (!ticket.current || !input.canOpen()) {
|
|
2296
|
+
finish();
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
try {
|
|
2300
|
+
input.open();
|
|
2301
|
+
} finally {
|
|
2302
|
+
finish();
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
if (input.isReady()) {
|
|
2306
|
+
open();
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
const subscribe = input.subscribe;
|
|
2310
|
+
if (subscribe === void 0) return;
|
|
2311
|
+
cleanup = finish;
|
|
2312
|
+
let stop;
|
|
2313
|
+
try {
|
|
2314
|
+
stop = subscribe(() => {
|
|
2315
|
+
if (input.isReady()) open();
|
|
2316
|
+
});
|
|
2317
|
+
} catch {
|
|
2318
|
+
finish();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
unsubscribe = stop;
|
|
2322
|
+
if (settled) {
|
|
2323
|
+
unsubscribeNow();
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
timer = setTimeout(finish, giveUpMs);
|
|
2327
|
+
if (input.isReady()) open();
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
//#endregion
|
|
2108
2332
|
//#region src/client/main-view.ts
|
|
2109
2333
|
let client;
|
|
2110
2334
|
/** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开;面板恢复用 `forgetMainView` 清掉。 */
|
|
2111
2335
|
let closedByUs = /* @__PURE__ */ new Set();
|
|
2112
|
-
/**
|
|
2113
|
-
const
|
|
2336
|
+
/** 在等的重开:票、订阅、放弃 timer 与 cleanup 都归这个句柄(`src/client/pending-open.ts`)。 */
|
|
2337
|
+
const pendingOpen = createPendingOpen();
|
|
2114
2338
|
/** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
|
|
2115
2339
|
function bindMainView(ctx) {
|
|
2116
2340
|
client = ctx;
|
|
2341
|
+
if (typeof ctx.effect === "function") ctx.effect(() => () => {
|
|
2342
|
+
pendingOpen.cancel();
|
|
2343
|
+
closedByUs = /* @__PURE__ */ new Set();
|
|
2344
|
+
}, "session-delete: main-view");
|
|
2345
|
+
}
|
|
2346
|
+
/** 宿主服务是否可用:把检查写成类型谓词,调用点拿到的就是 `ReadyUiWorkspace`。
|
|
2347
|
+
* `ctx.get` 的窄镜像声明成可能回 null(宿主可回 null),所以空值判据必须同时认 undefined / null。 */
|
|
2348
|
+
function isReadyUiWorkspace(candidate) {
|
|
2349
|
+
if (candidate === void 0 || candidate === null) return false;
|
|
2350
|
+
if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return false;
|
|
2351
|
+
if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return false;
|
|
2352
|
+
return true;
|
|
2117
2353
|
}
|
|
2118
2354
|
function uiWorkspaceOf() {
|
|
2119
2355
|
try {
|
|
2120
2356
|
const candidate = client?.get?.("uiWorkspace");
|
|
2121
|
-
|
|
2122
|
-
if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return void 0;
|
|
2123
|
-
if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return void 0;
|
|
2124
|
-
return candidate;
|
|
2357
|
+
return isReadyUiWorkspace(candidate) ? candidate : void 0;
|
|
2125
2358
|
} catch {
|
|
2126
2359
|
return;
|
|
2127
2360
|
}
|
|
@@ -2139,7 +2372,7 @@ window.__ModuleLoader__.load({
|
|
|
2139
2372
|
*/
|
|
2140
2373
|
function afterDelete(sessionIds) {
|
|
2141
2374
|
if (sessionIds.length === 0) return;
|
|
2142
|
-
|
|
2375
|
+
pendingOpen.cancel();
|
|
2143
2376
|
const ui = uiWorkspaceOf();
|
|
2144
2377
|
if (ui === void 0) return;
|
|
2145
2378
|
const current = currentSessionOf(ui);
|
|
@@ -2158,11 +2391,13 @@ window.__ModuleLoader__.load({
|
|
|
2158
2391
|
*/
|
|
2159
2392
|
function afterRestore(sessionIds) {
|
|
2160
2393
|
if (sessionIds.length === 0) return;
|
|
2161
|
-
|
|
2394
|
+
pendingOpen.cancel();
|
|
2162
2395
|
const targets = sessionIds.filter((id) => closedByUs.has(id));
|
|
2163
2396
|
if (targets.length === 0) return;
|
|
2164
2397
|
for (const id of targets) closedByUs.delete(id);
|
|
2165
|
-
|
|
2398
|
+
const last = targets[targets.length - 1];
|
|
2399
|
+
if (last === void 0) return;
|
|
2400
|
+
openWhenListed(last);
|
|
2166
2401
|
}
|
|
2167
2402
|
/**
|
|
2168
2403
|
* 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
|
|
@@ -2173,19 +2408,23 @@ window.__ModuleLoader__.load({
|
|
|
2173
2408
|
}
|
|
2174
2409
|
/**
|
|
2175
2410
|
* 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
|
|
2176
|
-
* 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session
|
|
2177
|
-
*
|
|
2411
|
+
* 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——先查一次快照,没有就
|
|
2412
|
+
* **订阅**宿主快照 store;出现且票仍是当前、焦点没被抢走时打开。最多等 2 秒,一个放弃 timer,不轮询。
|
|
2413
|
+
* 期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
|
|
2178
2414
|
*
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2415
|
+
* 这段状态机住 `src/client/pending-open.ts` 的 `createPendingOpen`:这里只把「查什么、能不能开、
|
|
2416
|
+
* 怎么开」交进去;取消也只剩 `pendingOpen.cancel()` 一次。没有 `subscribe` 的旧 store 直接放弃
|
|
2417
|
+
* 等待,不退回轮询(ADR-0036)。
|
|
2181
2418
|
*/
|
|
2182
2419
|
function openWhenListed(sessionId) {
|
|
2183
2420
|
const ui = uiWorkspaceOf();
|
|
2184
2421
|
if (ui === void 0) return;
|
|
2185
|
-
const
|
|
2422
|
+
const store = ui.sessions?.list;
|
|
2423
|
+
if (store === void 0) return;
|
|
2424
|
+
const subscribe = typeof store.subscribe === "function" ? store.subscribe.bind(store) : void 0;
|
|
2186
2425
|
const listReady = () => {
|
|
2187
2426
|
try {
|
|
2188
|
-
return
|
|
2427
|
+
return store.getSnapshot()?.byId?.[sessionId] !== void 0;
|
|
2189
2428
|
} catch {
|
|
2190
2429
|
return false;
|
|
2191
2430
|
}
|
|
@@ -2194,28 +2433,16 @@ window.__ModuleLoader__.load({
|
|
|
2194
2433
|
const current = currentSessionOf(ui);
|
|
2195
2434
|
return current === void 0 || current === sessionId;
|
|
2196
2435
|
};
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
open();
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
2208
|
-
let attempts = 0;
|
|
2209
|
-
const tick = () => {
|
|
2210
|
-
if (!ticket.current) return;
|
|
2211
|
-
attempts += 1;
|
|
2212
|
-
if (listReady()) {
|
|
2213
|
-
open();
|
|
2214
|
-
return;
|
|
2436
|
+
pendingOpen.schedule({
|
|
2437
|
+
subscribe,
|
|
2438
|
+
isReady: listReady,
|
|
2439
|
+
canOpen: canTakeFocus,
|
|
2440
|
+
open: () => {
|
|
2441
|
+
try {
|
|
2442
|
+
ui.openSession(sessionId);
|
|
2443
|
+
} catch {}
|
|
2215
2444
|
}
|
|
2216
|
-
|
|
2217
|
-
};
|
|
2218
|
-
setTimeout(tick, 100);
|
|
2445
|
+
});
|
|
2219
2446
|
}
|
|
2220
2447
|
//#endregion
|
|
2221
2448
|
//#region src/client/write-settlement.ts
|
|
@@ -2307,9 +2534,12 @@ window.__ModuleLoader__.load({
|
|
|
2307
2534
|
}
|
|
2308
2535
|
//#endregion
|
|
2309
2536
|
//#region src/client/write-seats.ts
|
|
2310
|
-
/** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。
|
|
2537
|
+
/** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。
|
|
2538
|
+
* 三种形状各有意思,不许互相冒充:字符串 = 这一条失败了(原文);`null` = 跑了、这一条没有失败要说;
|
|
2539
|
+
* `undefined` = 闸把这一趟挡回来了、什么都没跑(框不收,等在飞那一趟的结论——先收就会把它的失败原文
|
|
2540
|
+
* 落在已经卸载的确认框上)。 */
|
|
2311
2541
|
async function deleteSessionFromMenu(deps, input, gate) {
|
|
2312
|
-
|
|
2542
|
+
const outcome = await runWrite({
|
|
2313
2543
|
deps,
|
|
2314
2544
|
action: "delete",
|
|
2315
2545
|
ids: [input.sessionId],
|
|
@@ -2322,10 +2552,13 @@ window.__ModuleLoader__.load({
|
|
|
2322
2552
|
})
|
|
2323
2553
|
},
|
|
2324
2554
|
gate
|
|
2325
|
-
})
|
|
2555
|
+
});
|
|
2556
|
+
if (outcome === void 0) return void 0;
|
|
2557
|
+
return outcome.failures.get(input.sessionId) ?? null;
|
|
2326
2558
|
}
|
|
2327
2559
|
/** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
|
|
2328
|
-
* 只收 id
|
|
2560
|
+
* 只收 id:标题是浮条的呈现,不参与这一次写入。**被闸挡回时交回 `undefined`**——这一趟没跑,
|
|
2561
|
+
* 浮条与确认框都不许照着它收缩名单或关框(那会吞掉在飞那一趟的结果)。 */
|
|
2329
2562
|
async function deleteSessionsFromBatch(deps, ids, gate) {
|
|
2330
2563
|
return (await runWrite({
|
|
2331
2564
|
deps,
|
|
@@ -2339,19 +2572,42 @@ window.__ModuleLoader__.load({
|
|
|
2339
2572
|
})
|
|
2340
2573
|
},
|
|
2341
2574
|
gate
|
|
2342
|
-
}))
|
|
2343
|
-
}
|
|
2344
|
-
/**
|
|
2345
|
-
|
|
2346
|
-
|
|
2575
|
+
}))?.failures;
|
|
2576
|
+
}
|
|
2577
|
+
/** 外部恢复座位:拥有整趟——ids 组装、runWrite、三种回执形状各改写一次横幅、成功收自己。
|
|
2578
|
+
* 闸在调用方(横幅组件每条通知一枚);座位不传闸(双套会让外层 busy 提前落)。
|
|
2579
|
+
* 单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
|
|
2580
|
+
* null 是协议错);失败就把原因换到同一条横幅上,不静默。多条逐条失败时横幅要说清**条数与各条
|
|
2581
|
+
* 原因**(原因去重)——只显第一条会把条数与其余原因折没(issue #8 第 5 条);单条失败仍原文上屏。
|
|
2582
|
+
* settlement 负责重开删除时被关掉的会话(只认 main-view 自己记过的)并让清单重读;
|
|
2583
|
+
* 座位**不传 `dismissUndo`**——部分失败的回滚会让 settlement 先熄灭横幅,随后 `failNotice` 因
|
|
2584
|
+
* `ownsNotice` 落空而吞掉失败原文。 */
|
|
2585
|
+
async function restoreFromUndo(deps, notice, t) {
|
|
2586
|
+
const ids = notice.kind === "one" ? [notice.entryId] : notice.entryIds;
|
|
2587
|
+
if (notice.kind === "one" && notice.entryId === "") return;
|
|
2588
|
+
const outcome = await runWrite({
|
|
2347
2589
|
deps,
|
|
2348
2590
|
action: "restore",
|
|
2349
|
-
ids
|
|
2350
|
-
single:
|
|
2591
|
+
ids,
|
|
2592
|
+
single: notice.kind === "one",
|
|
2351
2593
|
presentation: { invalidate: invalidateTrash }
|
|
2352
2594
|
});
|
|
2595
|
+
if (!outcome.ok) {
|
|
2596
|
+
failNotice(notice, outcome.failure.message);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
const failure = outcome.failures.values().next().value;
|
|
2600
|
+
if (failure !== void 0) {
|
|
2601
|
+
failNotice(notice, outcome.failures.size > 1 ? t("undo.failedBatch", {
|
|
2602
|
+
n: outcome.failures.size,
|
|
2603
|
+
reasons: [...new Set(outcome.failures.values())].join(" · ")
|
|
2604
|
+
}) : failure);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
if (ownsNotice(notice)) clearUndoNotice();
|
|
2353
2608
|
}
|
|
2354
|
-
/** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。
|
|
2609
|
+
/** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。
|
|
2610
|
+
* 闸被面板那一趟占着时交回 `undefined`(连点不发第二条),面板照它什么都不改。 */
|
|
2355
2611
|
function restoreFromPanel(input) {
|
|
2356
2612
|
return runWrite({
|
|
2357
2613
|
deps: input.deps,
|
|
@@ -2368,7 +2624,8 @@ window.__ModuleLoader__.load({
|
|
|
2368
2624
|
refresh: input.refreshAfterWrite
|
|
2369
2625
|
});
|
|
2370
2626
|
}
|
|
2371
|
-
/** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。
|
|
2627
|
+
/** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。
|
|
2628
|
+
* 与恢复座位同一条:闸被占着时交回 `undefined`,这一趟不改任何界面后果。 */
|
|
2372
2629
|
function purgeFromPanel(input) {
|
|
2373
2630
|
return runWrite({
|
|
2374
2631
|
deps: input.deps,
|
|
@@ -2466,6 +2723,7 @@ window.__ModuleLoader__.load({
|
|
|
2466
2723
|
sessionId: current.sessionId,
|
|
2467
2724
|
title: current.title
|
|
2468
2725
|
}, gate);
|
|
2726
|
+
if (failure === void 0) return;
|
|
2469
2727
|
if (failure !== null) {
|
|
2470
2728
|
setFailure(failure);
|
|
2471
2729
|
return;
|
|
@@ -2603,7 +2861,7 @@ window.__ModuleLoader__.load({
|
|
|
2603
2861
|
const [counts, setCounts] = (0, react.useState)(null);
|
|
2604
2862
|
const ids = current.items.map((item) => item.sessionId);
|
|
2605
2863
|
const failedIds = [...failures.keys()];
|
|
2606
|
-
const totalKids = counts === null ? 0 :
|
|
2864
|
+
const totalKids = counts === null ? 0 : current.items.reduce((sum, item) => sum + (counts.get(item.sessionId) ?? 0), 0);
|
|
2607
2865
|
(0, react.useEffect)(() => {
|
|
2608
2866
|
let alive = true;
|
|
2609
2867
|
fetchCounts(deps, current.items.map((item) => item.sessionId)).then((result) => {
|
|
@@ -2616,6 +2874,7 @@ window.__ModuleLoader__.load({
|
|
|
2616
2874
|
const run = async () => {
|
|
2617
2875
|
setFailures(/* @__PURE__ */ new Map());
|
|
2618
2876
|
const failed = await deleteSessionsFromBatch(deps, current.items.map((item) => item.sessionId), gate);
|
|
2877
|
+
if (failed === void 0) return;
|
|
2619
2878
|
shrinkPicksToFailures([...failed.keys()]);
|
|
2620
2879
|
if (failed.size === 0) {
|
|
2621
2880
|
clearDeleteBatchRequest();
|
|
@@ -2671,12 +2930,81 @@ window.__ModuleLoader__.load({
|
|
|
2671
2930
|
});
|
|
2672
2931
|
}
|
|
2673
2932
|
//#endregion
|
|
2933
|
+
//#region src/client/batch-pick.ts
|
|
2934
|
+
/**
|
|
2935
|
+
* 批量选择的**动作面**:全选、全选判定与模式内整行点击守卫。
|
|
2936
|
+
*
|
|
2937
|
+
* 可见行的事实源仍住 `host-rows.ts`(槽位登记 + 快照);选中集住 `batch-selection-store.ts`。
|
|
2938
|
+
* 本文件只把两者接起来,于是依赖方向单向:`batch-pick` → `host-rows` / `batch-selection-store`;
|
|
2939
|
+
* 浮条与行开关 → `batch-pick` / `host-rows`。`host-rows` 不再 import 任何选择动作。
|
|
2940
|
+
*
|
|
2941
|
+
* DOM 只在这里读一次 `data-row-key`(ADR-0034 记下的受约束偏离):守卫把指针下的行映射成
|
|
2942
|
+
* sessionId,再查登记;它不枚举行,也不维护行列表。
|
|
2943
|
+
*/
|
|
2944
|
+
/** 全选:把当前可见可选行按登记序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
|
|
2945
|
+
function selectAllVisible() {
|
|
2946
|
+
addPicks(readVisibleSelectableRows());
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* 同一个切换口该显示「全选」还是「取消全选」:
|
|
2950
|
+
* - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
|
|
2951
|
+
* - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
|
|
2952
|
+
* - 其余(含 0 条)⇒ 全选。
|
|
2953
|
+
*/
|
|
2954
|
+
function areAllVisibleRowsSelected(rows, picks) {
|
|
2955
|
+
if (picks.length === 0) return false;
|
|
2956
|
+
if (rows.length === 0) return true;
|
|
2957
|
+
const chosen = new Set(picks.map((item) => item.sessionId));
|
|
2958
|
+
return rows.every((row) => chosen.has(row.sessionId));
|
|
2959
|
+
}
|
|
2960
|
+
/** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
|
|
2961
|
+
const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
|
|
2962
|
+
/**
|
|
2963
|
+
* 多选态内的整行点击守卫(document 捕获阶段)。
|
|
2964
|
+
*
|
|
2965
|
+
* 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
|
|
2966
|
+
* 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
|
|
2967
|
+
* 认行靠 `data-row-key="session:…"`——**这是本文件唯一一处 DOM 读**(ADR-0034):宿主将来改掉这个
|
|
2968
|
+
* 属性,最差是模式内点行不生效,勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
|
|
2969
|
+
*/
|
|
2970
|
+
function useSelectionRowClickGuard() {
|
|
2971
|
+
const selecting = useSelection().selecting;
|
|
2972
|
+
(0, react.useEffect)(() => {
|
|
2973
|
+
if (!selecting) return;
|
|
2974
|
+
const handle = (event) => {
|
|
2975
|
+
if (!(event.target instanceof Element)) return;
|
|
2976
|
+
const row = event.target.closest("[data-row-key^=\"session:\"]");
|
|
2977
|
+
if (row === null) return;
|
|
2978
|
+
if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
|
|
2979
|
+
event.preventDefault();
|
|
2980
|
+
event.stopPropagation();
|
|
2981
|
+
if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
|
|
2982
|
+
const key = row.getAttribute("data-row-key") ?? "";
|
|
2983
|
+
if (!key.startsWith("session:")) return;
|
|
2984
|
+
const sessionId = key.slice(8);
|
|
2985
|
+
if (sessionId === "") return;
|
|
2986
|
+
const info = pickableRowOf(sessionId);
|
|
2987
|
+
if (info === void 0) return;
|
|
2988
|
+
togglePick({
|
|
2989
|
+
sessionId,
|
|
2990
|
+
title: info.title
|
|
2991
|
+
});
|
|
2992
|
+
};
|
|
2993
|
+
document.addEventListener("click", handle, true);
|
|
2994
|
+
document.addEventListener("dblclick", handle, true);
|
|
2995
|
+
return () => {
|
|
2996
|
+
document.removeEventListener("click", handle, true);
|
|
2997
|
+
document.removeEventListener("dblclick", handle, true);
|
|
2998
|
+
};
|
|
2999
|
+
}, [selecting]);
|
|
3000
|
+
}
|
|
3001
|
+
//#endregion
|
|
2674
3002
|
//#region src/client/batch-bar.tsx
|
|
2675
3003
|
/**
|
|
2676
3004
|
* 批量删除的浮条:**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
|
|
2677
3005
|
* 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
|
|
2678
3006
|
* (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
|
|
2679
|
-
* 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `
|
|
3007
|
+
* 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `batch-pick.ts` 的 `useSelectionRowClickGuard`)。
|
|
2680
3008
|
*
|
|
2681
3009
|
* 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
|
|
2682
3010
|
* (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
|
|
@@ -2923,12 +3251,16 @@ window.__ModuleLoader__.load({
|
|
|
2923
3251
|
if (list === void 0) byProject.set(entry.projectDir, [entry]);
|
|
2924
3252
|
else list.push(entry);
|
|
2925
3253
|
}
|
|
2926
|
-
return [...byProject].
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
3254
|
+
return [...byProject].flatMap(([projectDir, list]) => {
|
|
3255
|
+
const first = list[0];
|
|
3256
|
+
if (first === void 0) return [];
|
|
3257
|
+
return [{
|
|
3258
|
+
projectDir,
|
|
3259
|
+
label: projectLabel(projectDir, t),
|
|
3260
|
+
latest: first.deletedAt,
|
|
3261
|
+
entries: list
|
|
3262
|
+
}];
|
|
3263
|
+
}).sort((a, b) => b.latest - a.latest);
|
|
2932
3264
|
}
|
|
2933
3265
|
//#endregion
|
|
2934
3266
|
//#region src/client/attachment-view.tsx
|
|
@@ -2936,8 +3268,10 @@ window.__ModuleLoader__.load({
|
|
|
2936
3268
|
* 回收站详情与设置页维护卡预览里的一条附件:图片给缩略图 + 放大框,普通文件给下载 chip。
|
|
2937
3269
|
*
|
|
2938
3270
|
* 三条口径:
|
|
2939
|
-
* - **地址只由 `attachmentId` + `kind`
|
|
2940
|
-
*
|
|
3271
|
+
* - **地址只由 `attachmentId` + `kind` 寻址**:`/attachment` 段以这两个参数找对象;媒体元数据
|
|
3272
|
+
* (`mediaType` / `bytes` / `width` / `height`)只给宿主 `attachments` 服务做引用校验,`name` 只做
|
|
3273
|
+
* 下载名建议,均不参与路径推导(见 `src/api/attachment.ts`)。空串与缺席同义——空串不是一个名字,
|
|
3274
|
+
* 不写进查询串。
|
|
2941
3275
|
* - **图裂了就换占位**:缩略图与放大框共用同一份 `failed` 集合,`onError` 之后这一格改成「附件不可用」,
|
|
2942
3276
|
* 不把破图留在屏上(放大框里裂了也换)。普通文件走 `<a download>`,没有页面内失败面:服务端失败回
|
|
2943
3277
|
* 200 + JSON 信封,浏览器可能把这份错误响应存成文件。
|
|
@@ -2954,10 +3288,16 @@ window.__ModuleLoader__.load({
|
|
|
2954
3288
|
function attachmentUrl(a) {
|
|
2955
3289
|
const query = new URLSearchParams({
|
|
2956
3290
|
[PARAMS.attachmentId]: a.attachmentId,
|
|
2957
|
-
[PARAMS.attachmentKind]: a.kind
|
|
3291
|
+
[PARAMS.attachmentKind]: a.kind,
|
|
3292
|
+
[PARAMS.attachmentBytes]: String(a.bytes)
|
|
2958
3293
|
});
|
|
3294
|
+
if (a.kind === "image") {
|
|
3295
|
+
if (a.mediaType !== void 0) query.set(PARAMS.attachmentMediaType, a.mediaType);
|
|
3296
|
+
if (a.width !== void 0) query.set(PARAMS.attachmentWidth, String(a.width));
|
|
3297
|
+
if (a.height !== void 0) query.set(PARAMS.attachmentHeight, String(a.height));
|
|
3298
|
+
}
|
|
2959
3299
|
if (a.kind === "file" && a.name !== void 0 && a.name !== "") query.set(PARAMS.attachmentName, a.name);
|
|
2960
|
-
return `${
|
|
3300
|
+
return `${OFFICIAL_SESSION_DELETE_API_PREFIX}${ROUTES.attachment.path}?${query.toString()}`;
|
|
2961
3301
|
}
|
|
2962
3302
|
/** 空串与缺席同义:宿主侧的 `name` 是可选建议名,空串不是名字(兜底文案与"不带 name 查询"都看它)。 */
|
|
2963
3303
|
function nameOf(a) {
|
|
@@ -3047,7 +3387,8 @@ window.__ModuleLoader__.load({
|
|
|
3047
3387
|
setSaveError(null);
|
|
3048
3388
|
try {
|
|
3049
3389
|
const response = await fetch(attachmentUrl(attachment));
|
|
3050
|
-
const
|
|
3390
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0];
|
|
3391
|
+
const contentType = mediaType === void 0 ? "" : mediaType.trim();
|
|
3051
3392
|
if (!response.ok || !contentType.startsWith("image/")) {
|
|
3052
3393
|
let message = `HTTP ${String(response.status)}`;
|
|
3053
3394
|
if (contentType === "application/json") try {
|
|
@@ -3495,21 +3836,21 @@ window.__ModuleLoader__.load({
|
|
|
3495
3836
|
}
|
|
3496
3837
|
//#endregion
|
|
3497
3838
|
//#region src/client/orphan-maintenance.tsx
|
|
3498
|
-
function OrphanMaintenance({
|
|
3499
|
-
const {
|
|
3839
|
+
function OrphanMaintenance({ section, t }) {
|
|
3840
|
+
const { report, failure, notice, confirm, busy } = section;
|
|
3500
3841
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
3501
|
-
|
|
3842
|
+
report !== null && report.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(MaintenanceBanner, {
|
|
3502
3843
|
slot: "orphan-banner",
|
|
3503
3844
|
busy,
|
|
3504
3845
|
t,
|
|
3505
3846
|
gapBelow: true,
|
|
3506
|
-
onOpen: () =>
|
|
3847
|
+
onOpen: () => section.openPreview(),
|
|
3507
3848
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3508
3849
|
style: {
|
|
3509
3850
|
fontSize: 13,
|
|
3510
3851
|
lineHeight: "19px"
|
|
3511
3852
|
},
|
|
3512
|
-
children: t("trash.orphans.banner", { count:
|
|
3853
|
+
children: t("trash.orphans.banner", { count: report.totalSessions })
|
|
3513
3854
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3514
3855
|
style: {
|
|
3515
3856
|
fontSize: 12,
|
|
@@ -3517,19 +3858,19 @@ window.__ModuleLoader__.load({
|
|
|
3517
3858
|
color: "var(--dsw-alias-label-tertiary)"
|
|
3518
3859
|
},
|
|
3519
3860
|
children: t("trash.orphans.meta", {
|
|
3520
|
-
size: fileSizeText(
|
|
3521
|
-
parents:
|
|
3861
|
+
size: fileSizeText(report.totalBytes),
|
|
3862
|
+
parents: report.missingParents
|
|
3522
3863
|
})
|
|
3523
3864
|
})]
|
|
3524
3865
|
}) : null,
|
|
3525
|
-
|
|
3526
|
-
message:
|
|
3866
|
+
failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
|
|
3867
|
+
message: failure,
|
|
3527
3868
|
busy,
|
|
3528
3869
|
t,
|
|
3529
3870
|
gapBelow: true,
|
|
3530
|
-
onRetry: () => void
|
|
3871
|
+
onRetry: () => void section.scan()
|
|
3531
3872
|
}) : null,
|
|
3532
|
-
|
|
3873
|
+
notice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3533
3874
|
"data-dsh-session-delete": "orphan-notice",
|
|
3534
3875
|
role: "status",
|
|
3535
3876
|
style: {
|
|
@@ -3557,29 +3898,127 @@ window.__ModuleLoader__.load({
|
|
|
3557
3898
|
lineHeight: "17px",
|
|
3558
3899
|
color: "var(--dsw-alias-label-secondary)"
|
|
3559
3900
|
},
|
|
3560
|
-
children:
|
|
3901
|
+
children: notice
|
|
3561
3902
|
})]
|
|
3562
3903
|
}) : null,
|
|
3563
|
-
|
|
3564
|
-
report
|
|
3904
|
+
confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
|
|
3905
|
+
report,
|
|
3565
3906
|
busy,
|
|
3566
3907
|
t,
|
|
3567
|
-
onCancel: () =>
|
|
3568
|
-
onConfirm: () => void
|
|
3908
|
+
onCancel: () => section.cancel(),
|
|
3909
|
+
onConfirm: () => void section.clean()
|
|
3569
3910
|
}) : null
|
|
3570
3911
|
] });
|
|
3571
3912
|
}
|
|
3572
3913
|
//#endregion
|
|
3914
|
+
//#region src/client/use-maintenance-section.ts
|
|
3915
|
+
/**
|
|
3916
|
+
* 维护卡一段维护的**泛型状态机**:报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查。
|
|
3917
|
+
* 孤儿会话与孤儿附件曾是这台机器按前缀各抄一份的两段——这里收成一台,两段各自只带自己的
|
|
3918
|
+
* 远程调用、判据与拼句(`use-maintenance.ts` 两次实例化)。
|
|
3919
|
+
*
|
|
3920
|
+
* 三条承重规矩(ADR-0021)在这一层保证,两段都不可绕过:
|
|
3921
|
+
* - **没有自动扫描**:本 module 不挂 effect、没有定时器;挂载只交初始态(报告 `null` = 尚未检查)。
|
|
3922
|
+
* - **读不到就说读不到**:检查失败回 `null` + 原文(`scanFailureText` 由各段拼),不折成"没有发现"。
|
|
3923
|
+
* - **清理后必重查**:清完立刻重读;回执句子由 `settle` 拼好后**等重查赢了才落地**——顺序反了,
|
|
3924
|
+
* 重查成功会清掉旧提示,把这一趟的后果吞掉。
|
|
3925
|
+
*
|
|
3926
|
+
* **不变量由构造保证**:开确认框只有 `openPreview()` 一条路(先清旧失败与旧提示再开),收框只有
|
|
3927
|
+
* `cancel()`(只收框,不碰提示 / 失败);界面上写不出"裸把 confirm 摆 true"这种开门,于是
|
|
3928
|
+
* "确认框开着、上一趟的失败句还挂着"的组合从类型上就不存在。`busy` 同理由段自己给
|
|
3929
|
+
* (在飞即忙),不再作为 prop 逐层传。
|
|
3930
|
+
*
|
|
3931
|
+
* 动作是每渲染重建的普通闭包、不包 `useCallback`:config 每渲染本来就要重建,包了也稳定不下来;
|
|
3932
|
+
* 这些动作只被点击触发,没有按身份依赖它们的 effect(`useIdleClose` 只在调用时读它们)。
|
|
3933
|
+
*/
|
|
3934
|
+
function useMaintenanceSection(config) {
|
|
3935
|
+
const [report, setReport] = (0, react.useState)(null);
|
|
3936
|
+
const [failure, setFailure] = (0, react.useState)(null);
|
|
3937
|
+
const [notice, setNotice] = (0, react.useState)(null);
|
|
3938
|
+
const [confirm, setConfirm] = (0, react.useState)(false);
|
|
3939
|
+
const [phase, setPhase] = (0, react.useState)(null);
|
|
3940
|
+
const [latest] = (0, react.useState)(createLatestWins);
|
|
3941
|
+
/**
|
|
3942
|
+
* 一次检查(不碰 phase)。交回这张票是否仍是赢家:清理后的重查被后发的检查顶掉时,
|
|
3943
|
+
* 由赢家负责把读数与 busy 一起收尾,输家什么都不落地。**输家不许替赢家清 `phase`**——phase 归
|
|
3944
|
+
* 赢下最新票的那一趟收尾,输家提前清会在赢家还在飞时把界面摆成空闲(忙碌收尾与
|
|
3945
|
+
* `use-trash-query.ts` 的空闲判定是两套写法,判据不同源,别互相抄)。
|
|
3946
|
+
*/
|
|
3947
|
+
async function rescan() {
|
|
3948
|
+
const ticket = latest.issue();
|
|
3949
|
+
const outcome = await config.scan();
|
|
3950
|
+
if (!ticket.current) return false;
|
|
3951
|
+
config.onScanWon?.();
|
|
3952
|
+
if (outcome.ok) {
|
|
3953
|
+
setReport(outcome.value);
|
|
3954
|
+
setFailure(null);
|
|
3955
|
+
setNotice(null);
|
|
3956
|
+
return true;
|
|
3957
|
+
}
|
|
3958
|
+
setReport(null);
|
|
3959
|
+
setNotice(null);
|
|
3960
|
+
setConfirm(false);
|
|
3961
|
+
setFailure(config.scanFailureText(outcome.failure.message));
|
|
3962
|
+
return true;
|
|
3963
|
+
}
|
|
3964
|
+
async function scan() {
|
|
3965
|
+
setPhase("scanning");
|
|
3966
|
+
if (await rescan()) setPhase(null);
|
|
3967
|
+
}
|
|
3968
|
+
async function clean() {
|
|
3969
|
+
const before = report;
|
|
3970
|
+
if (before === null) {
|
|
3971
|
+
setConfirm(false);
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
const pending = config.clean(before);
|
|
3975
|
+
if (pending === null) {
|
|
3976
|
+
setConfirm(false);
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3979
|
+
setPhase("cleaning");
|
|
3980
|
+
setFailure(null);
|
|
3981
|
+
setNotice(null);
|
|
3982
|
+
const outcome = await pending;
|
|
3983
|
+
setConfirm(false);
|
|
3984
|
+
const preset = config.beforeRescan?.(outcome);
|
|
3985
|
+
if (preset !== null && preset !== void 0) setReport(preset);
|
|
3986
|
+
if (!await rescan()) return;
|
|
3987
|
+
const plan = config.settle(outcome, before);
|
|
3988
|
+
if (plan.notice !== void 0) setNotice(plan.notice);
|
|
3989
|
+
if (plan.failure !== void 0) setFailure(plan.failure);
|
|
3990
|
+
setPhase(null);
|
|
3991
|
+
}
|
|
3992
|
+
function openPreview() {
|
|
3993
|
+
setFailure(null);
|
|
3994
|
+
setNotice(null);
|
|
3995
|
+
setConfirm(true);
|
|
3996
|
+
}
|
|
3997
|
+
function cancel() {
|
|
3998
|
+
setConfirm(false);
|
|
3999
|
+
}
|
|
4000
|
+
return {
|
|
4001
|
+
report,
|
|
4002
|
+
failure,
|
|
4003
|
+
notice,
|
|
4004
|
+
confirm,
|
|
4005
|
+
phase,
|
|
4006
|
+
busy: phase !== null,
|
|
4007
|
+
scan,
|
|
4008
|
+
openPreview,
|
|
4009
|
+
clean,
|
|
4010
|
+
cancel
|
|
4011
|
+
};
|
|
4012
|
+
}
|
|
4013
|
+
//#endregion
|
|
3573
4014
|
//#region src/client/use-maintenance.ts
|
|
3574
4015
|
/**
|
|
3575
|
-
* 插件设置页「维护」卡的手动编排:孤儿会话 +
|
|
4016
|
+
* 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件两段。
|
|
3576
4017
|
*
|
|
3577
|
-
*
|
|
3578
|
-
* -
|
|
3579
|
-
*
|
|
3580
|
-
*
|
|
3581
|
-
* - **清理后必重查**:报告说的是"报告那一刻"的盘;清完立刻重读。回执里的中性 / 失败 / 部分计数
|
|
3582
|
-
* 先读出来、重查之后再上屏——重查成功会清掉旧提示,顺序反了会把这一趟的后果吞掉。
|
|
4018
|
+
* 状态机本身(报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查)收在
|
|
4019
|
+
* `src/client/use-maintenance-section.ts` 的泛型段 module 里,两段各实例化一次;这一层只剩薄 glue:
|
|
4020
|
+
* 各段自己的远程调用、"没活可干"的判据与回执拼句。三条承重规矩(没有自动扫描 / 挂载只交初始态 /
|
|
4021
|
+
* 没有定时器)由段 module 保证,这一层同样不挂 effect。
|
|
3583
4022
|
*
|
|
3584
4023
|
* 写动作走既有的两段:孤儿会话是 `/apply` 的 `orphan-clean`(回执判读复用 `receipt.ts` 的读法),
|
|
3585
4024
|
* 孤儿附件是聚合的 `POST /attachment-clean`(回执就是 `AttachmentCleanupWire` 本身)。
|
|
@@ -3603,178 +4042,63 @@ window.__ModuleLoader__.load({
|
|
|
3603
4042
|
size: fileSizeText(a.freedBytes)
|
|
3604
4043
|
}) : null;
|
|
3605
4044
|
}
|
|
4045
|
+
/** orphan-clean 回执里的"没清成"句:服务端原文 + 部分计数("删了一半才炸"的那部分释放量)。
|
|
4046
|
+
* 首条失败由调用方在判过非空之后作为参数交进来——签名因此写明前提,函数体不必再赌 `failed[0]` 在。 */
|
|
4047
|
+
function orphanCleanFailureSentence(summary, first, t) {
|
|
4048
|
+
const message = first.error.message;
|
|
4049
|
+
return `${t("trash.orphans.cleanFailed", {
|
|
4050
|
+
count: summary.failed.length,
|
|
4051
|
+
message
|
|
4052
|
+
})}${summary.partial.removed > 0 || summary.partial.freedBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
|
|
4053
|
+
removed: summary.partial.removed,
|
|
4054
|
+
size: fileSizeText(summary.partial.freedBytes)
|
|
4055
|
+
})}` : ""}`;
|
|
4056
|
+
}
|
|
3606
4057
|
function useMaintenance({ deps, t }) {
|
|
3607
|
-
const [orphanReport, setOrphanReport] = (0, react.useState)(null);
|
|
3608
|
-
const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
|
|
3609
|
-
const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
|
|
3610
4058
|
const [orphanAttachmentNotice, setOrphanAttachmentNotice] = (0, react.useState)(null);
|
|
3611
|
-
const
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
setOrphanReport(outcome.value);
|
|
3632
|
-
setOrphanFailure(null);
|
|
3633
|
-
setOrphanNotice(null);
|
|
3634
|
-
return true;
|
|
3635
|
-
}, [
|
|
3636
|
-
deps,
|
|
3637
|
-
orphanLatest,
|
|
3638
|
-
t
|
|
3639
|
-
]);
|
|
3640
|
-
const scanOrphans = (0, react.useCallback)(async () => {
|
|
3641
|
-
setOrphanPhase("scanning");
|
|
3642
|
-
if (await rescanOrphans()) setOrphanPhase(null);
|
|
3643
|
-
}, [rescanOrphans]);
|
|
3644
|
-
const cleanOrphans = (0, react.useCallback)(async () => {
|
|
3645
|
-
const report = orphanReport;
|
|
3646
|
-
if (report === null || report.roots.length === 0) {
|
|
3647
|
-
setOrphanConfirm(false);
|
|
3648
|
-
return;
|
|
3649
|
-
}
|
|
3650
|
-
const roots = report.roots;
|
|
3651
|
-
setOrphanPhase("cleaning");
|
|
3652
|
-
setOrphanFailure(null);
|
|
3653
|
-
setOrphanNotice(null);
|
|
3654
|
-
setOrphanAttachmentNotice(null);
|
|
3655
|
-
const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", roots));
|
|
3656
|
-
setOrphanConfirm(false);
|
|
3657
|
-
if (!outcome.ok) {
|
|
3658
|
-
if (!await rescanOrphans()) return;
|
|
3659
|
-
setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
|
|
3660
|
-
setOrphanPhase(null);
|
|
3661
|
-
return;
|
|
3662
|
-
}
|
|
3663
|
-
const { summary } = summarizeReceipt(readReceipt("orphan-clean", roots, outcome.value));
|
|
3664
|
-
if (!await rescanOrphans()) return;
|
|
3665
|
-
if (summary.neutralIds.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: summary.neutralIds.length }));
|
|
3666
|
-
if (summary.failed.length > 0) {
|
|
3667
|
-
const message = summary.failed[0].error.message;
|
|
3668
|
-
const base = t("trash.orphans.cleanFailed", {
|
|
3669
|
-
count: summary.failed.length,
|
|
3670
|
-
message
|
|
3671
|
-
});
|
|
3672
|
-
const partial = summary.partial.removed > 0 || summary.partial.freedBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
|
|
3673
|
-
removed: summary.partial.removed,
|
|
3674
|
-
size: fileSizeText(summary.partial.freedBytes)
|
|
3675
|
-
})}` : "";
|
|
3676
|
-
setOrphanFailure(`${base}${partial}`);
|
|
3677
|
-
}
|
|
3678
|
-
if (summary.attachmentCleanup !== void 0) setOrphanAttachmentNotice(attachmentCleanupNotice(summary.attachmentCleanup, t));
|
|
3679
|
-
setOrphanPhase(null);
|
|
3680
|
-
}, [
|
|
3681
|
-
deps,
|
|
3682
|
-
orphanReport,
|
|
3683
|
-
rescanOrphans,
|
|
3684
|
-
t
|
|
3685
|
-
]);
|
|
3686
|
-
const openOrphanPreview = (0, react.useCallback)(() => {
|
|
3687
|
-
setOrphanFailure(null);
|
|
3688
|
-
setOrphanNotice(null);
|
|
3689
|
-
setOrphanConfirm(true);
|
|
3690
|
-
}, []);
|
|
3691
|
-
const [attachmentReport, setAttachmentReport] = (0, react.useState)(null);
|
|
3692
|
-
const [attachmentFailure, setAttachmentFailure] = (0, react.useState)(null);
|
|
3693
|
-
const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
|
|
3694
|
-
const [attachmentConfirm, setAttachmentConfirm] = (0, react.useState)(false);
|
|
3695
|
-
const [attachmentPhase, setAttachmentPhase] = (0, react.useState)(null);
|
|
3696
|
-
const attachmentBusy = attachmentPhase !== null;
|
|
3697
|
-
const [attachmentLatest] = (0, react.useState)(createLatestWins);
|
|
3698
|
-
const rescanAttachments = (0, react.useCallback)(async () => {
|
|
3699
|
-
const ticket = attachmentLatest.issue();
|
|
3700
|
-
const outcome = await callRemote(() => deps.scanAttachmentOrphans());
|
|
3701
|
-
if (!ticket.current) return false;
|
|
3702
|
-
if (!outcome.ok) {
|
|
3703
|
-
setAttachmentReport(null);
|
|
3704
|
-
setAttachmentNotice(null);
|
|
3705
|
-
setAttachmentConfirm(false);
|
|
3706
|
-
setAttachmentFailure(t("maintenance.attachments.scanFailed", { message: outcome.failure.message }));
|
|
3707
|
-
return true;
|
|
3708
|
-
}
|
|
3709
|
-
setAttachmentReport(outcome.value);
|
|
3710
|
-
setAttachmentFailure(null);
|
|
3711
|
-
setAttachmentNotice(null);
|
|
3712
|
-
return true;
|
|
3713
|
-
}, [
|
|
3714
|
-
attachmentLatest,
|
|
3715
|
-
deps,
|
|
3716
|
-
t
|
|
3717
|
-
]);
|
|
3718
|
-
const scanAttachmentOrphans = (0, react.useCallback)(async () => {
|
|
3719
|
-
setAttachmentPhase("scanning");
|
|
3720
|
-
if (await rescanAttachments()) setAttachmentPhase(null);
|
|
3721
|
-
}, [rescanAttachments]);
|
|
3722
|
-
const cleanAttachmentOrphans = (0, react.useCallback)(async () => {
|
|
3723
|
-
const report = attachmentReport;
|
|
3724
|
-
if (report === null || report.count === 0) {
|
|
3725
|
-
setAttachmentConfirm(false);
|
|
3726
|
-
return;
|
|
3727
|
-
}
|
|
3728
|
-
setAttachmentPhase("cleaning");
|
|
3729
|
-
setAttachmentFailure(null);
|
|
3730
|
-
setAttachmentNotice(null);
|
|
3731
|
-
const outcome = await callRemote(() => deps.cleanAttachmentOrphans());
|
|
3732
|
-
setAttachmentConfirm(false);
|
|
3733
|
-
if (outcome.ok && outcome.value.failed === 0 && outcome.value.skipped === void 0) setAttachmentReport({
|
|
3734
|
-
count: 0,
|
|
3735
|
-
totalBytes: 0,
|
|
3736
|
-
items: []
|
|
3737
|
-
});
|
|
3738
|
-
if (!await rescanAttachments()) return;
|
|
3739
|
-
if (!outcome.ok) {
|
|
3740
|
-
setAttachmentFailure(t("maintenance.attachments.cleanError", { message: outcome.failure.message }));
|
|
3741
|
-
setAttachmentPhase(null);
|
|
3742
|
-
return;
|
|
4059
|
+
const orphans = useMaintenanceSection({
|
|
4060
|
+
scan: () => callRemote(() => deps.scanOrphans()),
|
|
4061
|
+
onScanWon: () => {
|
|
4062
|
+
setOrphanAttachmentNotice(null);
|
|
4063
|
+
},
|
|
4064
|
+
clean: (report) => {
|
|
4065
|
+
if (report.roots.length === 0) return null;
|
|
4066
|
+
setOrphanAttachmentNotice(null);
|
|
4067
|
+
return callRemote(() => deps.applyOutcomes("orphan-clean", report.roots));
|
|
4068
|
+
},
|
|
4069
|
+
scanFailureText: (message) => t("trash.orphans.failure", { message }),
|
|
4070
|
+
settle: (outcome, report) => {
|
|
4071
|
+
if (!outcome.ok) return { failure: t("trash.orphans.failure", { message: outcome.failure.message }) };
|
|
4072
|
+
const { summary } = summarizeReceipt(readReceipt("orphan-clean", report.roots, outcome.value));
|
|
4073
|
+
if (summary.attachmentCleanup !== void 0) setOrphanAttachmentNotice(attachmentCleanupNotice(summary.attachmentCleanup, t));
|
|
4074
|
+
const plan = {};
|
|
4075
|
+
if (summary.neutralIds.length > 0) plan.notice = t("trash.orphans.cleanGone", { count: summary.neutralIds.length });
|
|
4076
|
+
const firstFailed = summary.failed[0];
|
|
4077
|
+
if (firstFailed !== void 0) plan.failure = orphanCleanFailureSentence(summary, firstFailed, t);
|
|
4078
|
+
return plan;
|
|
3743
4079
|
}
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
4080
|
+
});
|
|
4081
|
+
const attachments = useMaintenanceSection({
|
|
4082
|
+
scan: () => callRemote(() => deps.scanAttachmentOrphans()),
|
|
4083
|
+
clean: (report) => report.count === 0 ? null : callRemote(() => deps.cleanAttachmentOrphans()),
|
|
4084
|
+
beforeRescan: (outcome) => {
|
|
4085
|
+
if (!outcome.ok) return null;
|
|
4086
|
+
const receipt = outcome.value;
|
|
4087
|
+
return receipt.failed === 0 && receipt.skipped === void 0 ? {
|
|
4088
|
+
count: 0,
|
|
4089
|
+
totalBytes: 0,
|
|
4090
|
+
items: []
|
|
4091
|
+
} : null;
|
|
4092
|
+
},
|
|
4093
|
+
scanFailureText: (message) => t("maintenance.attachments.scanFailed", { message }),
|
|
4094
|
+
settle: (outcome) => outcome.ok ? { notice: attachmentCleanupNotice(outcome.value, t) } : { failure: t("maintenance.attachments.cleanError", { message: outcome.failure.message }) }
|
|
4095
|
+
});
|
|
3752
4096
|
return {
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
orphanBusy,
|
|
3759
|
-
orphanPhase,
|
|
3760
|
-
scanOrphans,
|
|
3761
|
-
openOrphanPreview,
|
|
3762
|
-
cleanOrphans,
|
|
3763
|
-
setOrphanConfirm,
|
|
3764
|
-
attachmentReport,
|
|
3765
|
-
attachmentFailure,
|
|
3766
|
-
attachmentNotice,
|
|
3767
|
-
attachmentConfirm,
|
|
3768
|
-
attachmentBusy,
|
|
3769
|
-
attachmentPhase,
|
|
3770
|
-
scanAttachmentOrphans,
|
|
3771
|
-
openAttachmentPreview: (0, react.useCallback)(() => {
|
|
3772
|
-
setAttachmentFailure(null);
|
|
3773
|
-
setAttachmentNotice(null);
|
|
3774
|
-
setAttachmentConfirm(true);
|
|
3775
|
-
}, []),
|
|
3776
|
-
cleanAttachmentOrphans,
|
|
3777
|
-
setAttachmentConfirm
|
|
4097
|
+
orphans: {
|
|
4098
|
+
...orphans,
|
|
4099
|
+
attachmentNotice: orphanAttachmentNotice
|
|
4100
|
+
},
|
|
4101
|
+
attachments
|
|
3778
4102
|
};
|
|
3779
4103
|
}
|
|
3780
4104
|
//#endregion
|
|
@@ -3790,7 +4114,14 @@ window.__ModuleLoader__.load({
|
|
|
3790
4114
|
padding: "14px 16px"
|
|
3791
4115
|
};
|
|
3792
4116
|
/** 一段的标题行:标题 + 说明 + 状态 + 检查按钮。状态按优先级:清理中 / 检查中 / 检查失败 / 尚未检查 / 没有发现 / N。 */
|
|
3793
|
-
function MaintenanceSection({ slot, title, hint,
|
|
4117
|
+
function MaintenanceSection({ slot, title, hint, count, countKey, section, onScan, t, children }) {
|
|
4118
|
+
const status = statusText({
|
|
4119
|
+
phase: section.phase,
|
|
4120
|
+
failure: section.failure,
|
|
4121
|
+
count,
|
|
4122
|
+
countKey,
|
|
4123
|
+
t
|
|
4124
|
+
});
|
|
3794
4125
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
3795
4126
|
"data-dsh-session-delete": slot,
|
|
3796
4127
|
style: {
|
|
@@ -3847,7 +4178,7 @@ window.__ModuleLoader__.load({
|
|
|
3847
4178
|
variant: "outline",
|
|
3848
4179
|
size: "sm",
|
|
3849
4180
|
style: { flex: "none" },
|
|
3850
|
-
disabled: busy,
|
|
4181
|
+
disabled: section.busy,
|
|
3851
4182
|
onClick: onScan,
|
|
3852
4183
|
children: t("maintenance.scan")
|
|
3853
4184
|
})
|
|
@@ -3871,46 +4202,46 @@ window.__ModuleLoader__.load({
|
|
|
3871
4202
|
* 孤儿附件段:报告 >0 时出横幅(数量 + 体积 + 查看并清理);检查/清理失败在原位摆原文 + 重试;
|
|
3872
4203
|
* 上一趟清理的回执单独一行(已清 / 跳过 / 逐文件失败)。确认框把三条边界说完再动手。
|
|
3873
4204
|
*/
|
|
3874
|
-
function AttachmentMaintenance({
|
|
3875
|
-
const {
|
|
4205
|
+
function AttachmentMaintenance({ section, t }) {
|
|
4206
|
+
const { report, failure, notice, confirm, busy } = section;
|
|
3876
4207
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
3877
|
-
!busy &&
|
|
4208
|
+
!busy && report !== null && report.count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceBanner, {
|
|
3878
4209
|
slot: "attachment-banner",
|
|
3879
4210
|
busy,
|
|
3880
4211
|
t,
|
|
3881
|
-
onOpen: () =>
|
|
4212
|
+
onOpen: () => section.openPreview(),
|
|
3882
4213
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3883
4214
|
style: {
|
|
3884
4215
|
fontSize: 13,
|
|
3885
4216
|
lineHeight: "19px"
|
|
3886
4217
|
},
|
|
3887
4218
|
children: [
|
|
3888
|
-
t("maintenance.attachments.count", { count:
|
|
4219
|
+
t("maintenance.attachments.count", { count: report.count }),
|
|
3889
4220
|
" · ",
|
|
3890
|
-
fileSizeText(
|
|
4221
|
+
fileSizeText(report.totalBytes)
|
|
3891
4222
|
]
|
|
3892
4223
|
})
|
|
3893
4224
|
}) : null,
|
|
3894
|
-
|
|
4225
|
+
failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
|
|
3895
4226
|
slot: "attachment-failure",
|
|
3896
|
-
message:
|
|
4227
|
+
message: failure,
|
|
3897
4228
|
busy,
|
|
3898
4229
|
t,
|
|
3899
|
-
onRetry: () => void
|
|
4230
|
+
onRetry: () => void section.scan()
|
|
3900
4231
|
}) : null,
|
|
3901
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message:
|
|
3902
|
-
|
|
3903
|
-
count:
|
|
3904
|
-
items:
|
|
4232
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: notice }),
|
|
4233
|
+
confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentConfirm, {
|
|
4234
|
+
count: report.count,
|
|
4235
|
+
items: report.items,
|
|
3905
4236
|
busy,
|
|
3906
4237
|
t,
|
|
3907
|
-
onCancel: () =>
|
|
3908
|
-
onConfirm: () => void
|
|
4238
|
+
onCancel: () => section.cancel(),
|
|
4239
|
+
onConfirm: () => void section.clean()
|
|
3909
4240
|
}) : null
|
|
3910
4241
|
] });
|
|
3911
4242
|
}
|
|
3912
4243
|
/** 预览 + 确认:先摆出这些图片(点开可放大),再决定清不清;只清 `.dsh` 内无引用的图片副本。
|
|
3913
|
-
* `items`
|
|
4244
|
+
* `items` 只是预览清单(服务端按配置给条数,默认最多 200 条),`count` 才是这次要清理的总数。 */
|
|
3914
4245
|
function AttachmentConfirm({ count, items, busy, t, onCancel, onConfirm }) {
|
|
3915
4246
|
const { close, closeDisabled } = useIdleClose(busy, onCancel);
|
|
3916
4247
|
const attachments = items.map((item) => ({
|
|
@@ -3993,39 +4324,27 @@ window.__ModuleLoader__.load({
|
|
|
3993
4324
|
slot: "maintenance-orphans",
|
|
3994
4325
|
title: t("maintenance.orphans.title"),
|
|
3995
4326
|
hint: t("maintenance.orphans.hint"),
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
countKey: "maintenance.orphans.count",
|
|
4001
|
-
t
|
|
4002
|
-
}),
|
|
4003
|
-
busy: maintenance.orphanBusy,
|
|
4004
|
-
onScan: () => void maintenance.scanOrphans(),
|
|
4327
|
+
count: maintenance.orphans.report?.totalSessions ?? null,
|
|
4328
|
+
countKey: "maintenance.orphans.count",
|
|
4329
|
+
section: maintenance.orphans,
|
|
4330
|
+
onScan: () => void maintenance.orphans.scan(),
|
|
4005
4331
|
t,
|
|
4006
4332
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
|
|
4007
|
-
|
|
4008
|
-
busy: maintenance.orphanBusy,
|
|
4333
|
+
section: maintenance.orphans,
|
|
4009
4334
|
t
|
|
4010
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.
|
|
4335
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphans.attachmentNotice })]
|
|
4011
4336
|
}),
|
|
4012
4337
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceSection, {
|
|
4013
4338
|
slot: "maintenance-attachments",
|
|
4014
4339
|
title: t("maintenance.attachments.title"),
|
|
4015
4340
|
hint: t("maintenance.attachments.hint"),
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
countKey: "maintenance.attachments.count",
|
|
4021
|
-
t
|
|
4022
|
-
}),
|
|
4023
|
-
busy: maintenance.attachmentBusy,
|
|
4024
|
-
onScan: () => void maintenance.scanAttachmentOrphans(),
|
|
4341
|
+
count: maintenance.attachments.report?.count ?? null,
|
|
4342
|
+
countKey: "maintenance.attachments.count",
|
|
4343
|
+
section: maintenance.attachments,
|
|
4344
|
+
onScan: () => void maintenance.attachments.scan(),
|
|
4025
4345
|
t,
|
|
4026
4346
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentMaintenance, {
|
|
4027
|
-
|
|
4028
|
-
busy: maintenance.attachmentBusy,
|
|
4347
|
+
section: maintenance.attachments,
|
|
4029
4348
|
t
|
|
4030
4349
|
})
|
|
4031
4350
|
})
|
|
@@ -25997,14 +26316,13 @@ window.__ModuleLoader__.load({
|
|
|
25997
26316
|
}
|
|
25998
26317
|
/**
|
|
25999
26318
|
* `memo` 是必需的而不是优化:`react-markdown@10.1.0` 里**一处 memoization 都没有**(读实
|
|
26000
|
-
* `node_modules/react-markdown/lib/index.js
|
|
26319
|
+
* `node_modules/react-markdown/lib/index.js`——`Markdown()` 每次调用都 `createProcessor(options)`
|
|
26001
26320
|
* 再 `runSync(parse(file))`,`MarkdownHooks` 那一支也一样),而面板任何一次重渲(悬停、↑↓、
|
|
26002
26321
|
* 「刚刚删除」那句要读 `Date.now()`)都会走到这里 ⇒ 不按住就是每条助手回复每拍重解析一遍。
|
|
26003
|
-
* 按住的条件是 `text` 与**探针 `
|
|
26004
|
-
*
|
|
26005
|
-
*
|
|
26006
|
-
*
|
|
26007
|
-
* prev/next 那一拍都取到新值、恒等;只有渲染时算好存进 props 的那份才是换语言那一刻的读数。
|
|
26322
|
+
* 按住的条件是 `text` 与**探针 `probe`**:一句按当前 locale 现算的译文,专给 comparator 当
|
|
26323
|
+
* 「换语言了没」的读数。为什么必须渲染时现算存进 props、为什么宿主 `t` 只能当稳定身份而不是
|
|
26324
|
+
* 语言读数、「借哪句」,这些都住 `copy.ts` 的 `localeProbe`——调用方与测试一律
|
|
26325
|
+
* `probe={localeProbe(t)}`,「借哪句」的知识不散到各调用方。
|
|
26008
26326
|
* 探针没变 = 同语言同 `t` ⇒ 跳过重解析;变了(换语言或换 `t`)⇒ 重解析。
|
|
26009
26327
|
*/
|
|
26010
26328
|
const MarkdownBody = (0, react.memo)(function MarkdownBody({ text, t }) {
|
|
@@ -26017,7 +26335,7 @@ window.__ModuleLoader__.load({
|
|
|
26017
26335
|
children: text
|
|
26018
26336
|
})
|
|
26019
26337
|
});
|
|
26020
|
-
}, (prev, next) => prev.text === next.text && prev.
|
|
26338
|
+
}, (prev, next) => prev.text === next.text && prev.probe === next.probe);
|
|
26021
26339
|
//#endregion
|
|
26022
26340
|
//#region src/client/trash-detail.tsx
|
|
26023
26341
|
/**
|
|
@@ -26026,10 +26344,12 @@ window.__ModuleLoader__.load({
|
|
|
26026
26344
|
* 三条界面约束:
|
|
26027
26345
|
* - 动作(恢复/彻底删除)留在清单行右侧;详情只负责看。
|
|
26028
26346
|
* - 标题优先用清册标题,空串才回落到日志的 session/title。
|
|
26029
|
-
* -
|
|
26347
|
+
* - 正文是**块**:用户/助手文字常显;思考、工具、注入、系统消息、压缩摘要默认折叠,折叠头给
|
|
26348
|
+
* 标签 + 首行摘要(工具另给状态)。注入也铺进正文——这是对宿主 Chat 的有意偏离(它隐藏普通注入,
|
|
26349
|
+
* 而这里的目标是「删之前看全」);条数另进元信息表。
|
|
26030
26350
|
*
|
|
26031
|
-
*
|
|
26032
|
-
*
|
|
26351
|
+
* 助手正文与思考走 Markdown,用户提问保持原话;工具参数/结果走等宽纯文本(限高可滚)。
|
|
26352
|
+
* 附件随块位置渲染:用户气泡之上、助手正文之后、工具展开体末尾。
|
|
26033
26353
|
*/
|
|
26034
26354
|
const SHELL = {
|
|
26035
26355
|
display: "flex",
|
|
@@ -26039,11 +26359,24 @@ window.__ModuleLoader__.load({
|
|
|
26039
26359
|
height: "100%",
|
|
26040
26360
|
minWidth: 0
|
|
26041
26361
|
};
|
|
26362
|
+
/** 返回键排在标题之后:排在之前会把整条标题的起点往右挤(docs/ui-spec.md 状态⑥)。
|
|
26363
|
+
* 标题排按紧凑按钮(`ui.css` 的 `.sd-button-sm`)预留高度:宽档父详情没有返回键、孩子视图有,
|
|
26364
|
+
* 不预留时下方元信息会在父子切换时跳 6px。 */
|
|
26042
26365
|
const HEAD = {
|
|
26043
26366
|
display: "flex",
|
|
26044
26367
|
alignItems: "flex-start",
|
|
26045
|
-
gap: 10
|
|
26368
|
+
gap: 10,
|
|
26369
|
+
minHeight: 28
|
|
26046
26370
|
};
|
|
26371
|
+
const TITLE_LINE = {
|
|
26372
|
+
...ELLIPSIS,
|
|
26373
|
+
flex: "1 1 auto",
|
|
26374
|
+
minWidth: 0,
|
|
26375
|
+
fontSize: 15,
|
|
26376
|
+
fontWeight: 600,
|
|
26377
|
+
lineHeight: "22px"
|
|
26378
|
+
};
|
|
26379
|
+
const BACK = { flex: "none" };
|
|
26047
26380
|
/** 元信息那张表:label 一列、值一列,窄档也不横向溢出。 */
|
|
26048
26381
|
const META = {
|
|
26049
26382
|
display: "grid",
|
|
@@ -26123,6 +26456,107 @@ window.__ModuleLoader__.load({
|
|
|
26123
26456
|
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
26124
26457
|
borderRadius: "var(--dsw-radius-lg)"
|
|
26125
26458
|
};
|
|
26459
|
+
const BLOCK = {
|
|
26460
|
+
display: "flex",
|
|
26461
|
+
flexDirection: "column",
|
|
26462
|
+
minWidth: 0
|
|
26463
|
+
};
|
|
26464
|
+
/** 折叠头:与清单组头共用 `sd-disclosure-row` 的布局/悬停/无障碍;这里把 button 的 UA 样式压掉。 */
|
|
26465
|
+
const BLOCK_ROW = {
|
|
26466
|
+
background: "none",
|
|
26467
|
+
border: "none",
|
|
26468
|
+
padding: 0,
|
|
26469
|
+
font: "inherit",
|
|
26470
|
+
textAlign: "start",
|
|
26471
|
+
width: "100%",
|
|
26472
|
+
minWidth: 0,
|
|
26473
|
+
gap: 6
|
|
26474
|
+
};
|
|
26475
|
+
/** 标签不参与收缩:摘要是 `1 1 auto`,标签若也可缩,长结果会把「工具调用 / 来源」挤成几个字。 */
|
|
26476
|
+
const BLOCK_TITLE = {
|
|
26477
|
+
...ELLIPSIS,
|
|
26478
|
+
flex: "none",
|
|
26479
|
+
maxWidth: "50%",
|
|
26480
|
+
fontWeight: 500,
|
|
26481
|
+
fontSize: "var(--dsh-content-font-size-secondary, 13px)"
|
|
26482
|
+
};
|
|
26483
|
+
const BLOCK_SUMMARY = {
|
|
26484
|
+
...ELLIPSIS,
|
|
26485
|
+
flex: "1 1 auto",
|
|
26486
|
+
minWidth: 0,
|
|
26487
|
+
fontSize: 12,
|
|
26488
|
+
lineHeight: "18px",
|
|
26489
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
26490
|
+
};
|
|
26491
|
+
const BLOCK_META = {
|
|
26492
|
+
flex: "none",
|
|
26493
|
+
fontSize: 11.5,
|
|
26494
|
+
lineHeight: "18px",
|
|
26495
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
26496
|
+
whiteSpace: "nowrap"
|
|
26497
|
+
};
|
|
26498
|
+
const BLOCK_BODY = {
|
|
26499
|
+
display: "flex",
|
|
26500
|
+
flexDirection: "column",
|
|
26501
|
+
gap: 8,
|
|
26502
|
+
padding: "8px 0 4px calc(22px + var(--dsh-content-font-delta, 0px))",
|
|
26503
|
+
minWidth: 0
|
|
26504
|
+
};
|
|
26505
|
+
const PLAIN_BODY = {
|
|
26506
|
+
whiteSpace: "pre-wrap",
|
|
26507
|
+
wordBreak: "break-word",
|
|
26508
|
+
fontSize: "var(--dsh-content-font-size-secondary, 13px)",
|
|
26509
|
+
lineHeight: "20px",
|
|
26510
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
26511
|
+
};
|
|
26512
|
+
/** 工具参数/结果:等宽、保留换行、限高自滚——一段工具输出可能比整页还长。 */
|
|
26513
|
+
const CODE_BLOCK = {
|
|
26514
|
+
margin: 0,
|
|
26515
|
+
padding: "8px 10px",
|
|
26516
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
26517
|
+
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
26518
|
+
borderRadius: "var(--dsw-radius-md)",
|
|
26519
|
+
fontFamily: "var(--ds-font-family-code)",
|
|
26520
|
+
fontSize: 12,
|
|
26521
|
+
lineHeight: "18px",
|
|
26522
|
+
whiteSpace: "pre-wrap",
|
|
26523
|
+
wordBreak: "break-word",
|
|
26524
|
+
maxHeight: 320,
|
|
26525
|
+
overflow: "auto"
|
|
26526
|
+
};
|
|
26527
|
+
const SECTION_LABEL = {
|
|
26528
|
+
fontSize: 11.5,
|
|
26529
|
+
lineHeight: "16px",
|
|
26530
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
26531
|
+
};
|
|
26532
|
+
/**
|
|
26533
|
+
* 子会话行:折叠头同族的可点行,卡片底 + 小圆角;悬停走 sd-disclosure-row 的宿主配方。
|
|
26534
|
+
* 两处内联覆盖各有理由:宿主类给折叠头钉死行高并随之裁溢出,导航行带内边距,须放开
|
|
26535
|
+
* 高度按内容撑开,否则裁字;指针光标宿主只发给带展开语义的行,导航行不借那套语义,
|
|
26536
|
+
* 光标就内联自给。
|
|
26537
|
+
*/
|
|
26538
|
+
const SUBAGENT_ROW = {
|
|
26539
|
+
...BLOCK_ROW,
|
|
26540
|
+
padding: "6px 10px",
|
|
26541
|
+
height: "auto",
|
|
26542
|
+
cursor: "pointer",
|
|
26543
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
26544
|
+
border: "0.5px solid var(--dsw-alias-border-l4)",
|
|
26545
|
+
borderRadius: "var(--dsw-radius-md)"
|
|
26546
|
+
};
|
|
26547
|
+
const TOOLBAR$1 = {
|
|
26548
|
+
display: "flex",
|
|
26549
|
+
alignItems: "center",
|
|
26550
|
+
gap: 8,
|
|
26551
|
+
flexWrap: "wrap",
|
|
26552
|
+
minWidth: 0
|
|
26553
|
+
};
|
|
26554
|
+
const TRUNCATED = {
|
|
26555
|
+
fontSize: 11.5,
|
|
26556
|
+
lineHeight: "16px",
|
|
26557
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
26558
|
+
};
|
|
26559
|
+
const ERROR_TEXT = { color: "var(--dsw-alias-state-error-primary)" };
|
|
26126
26560
|
const HOUR = 36e5;
|
|
26127
26561
|
const DAY = 864e5;
|
|
26128
26562
|
/** 日志里那两个时间戳缺省时的降级(`src/trash/detail.ts` 把初值写成 0,读不到就是 0)。 */
|
|
@@ -26130,11 +26564,13 @@ window.__ModuleLoader__.load({
|
|
|
26130
26564
|
return ms === 0 ? "—" : absoluteText(ms);
|
|
26131
26565
|
}
|
|
26132
26566
|
/** 活跃跨度:不足一天给小时,超过给天——都是"这条会话活了多久"这一个问题的答案。
|
|
26133
|
-
*
|
|
26567
|
+
* 两端任一缺省就**不答**(不把"不知道"报成任何量级);不足一小时说"不足 1 小时",
|
|
26568
|
+
* 不把 10 分钟舍入成 1 小时冒充整点。 */
|
|
26134
26569
|
function spanText(from, to, t) {
|
|
26135
26570
|
if (from === 0 || to === 0) return void 0;
|
|
26136
26571
|
const ms = Math.max(0, to - from);
|
|
26137
|
-
|
|
26572
|
+
if (ms < HOUR) return t("detail.value.spanLessThanHour");
|
|
26573
|
+
return ms < DAY ? t("detail.value.spanHours", { n: Math.round(ms / HOUR) }) : t("detail.value.spanDays", { n: Math.round(ms / DAY) });
|
|
26138
26574
|
}
|
|
26139
26575
|
/** 详情头部显示的那句标题:清册优先,空串才回落到日志里的 `session/title`。 */
|
|
26140
26576
|
function titleOf(entry, detail, t) {
|
|
@@ -26142,7 +26578,246 @@ window.__ModuleLoader__.load({
|
|
|
26142
26578
|
if (detail !== void 0 && detail.logTitle !== "") return detail.logTitle;
|
|
26143
26579
|
return t("common.untitled");
|
|
26144
26580
|
}
|
|
26145
|
-
|
|
26581
|
+
/** 折叠头的摘要:第一行非空文字。 */
|
|
26582
|
+
function firstLine(text) {
|
|
26583
|
+
return (text.split("\n").find((candidate) => candidate.trim() !== "") ?? "").trim();
|
|
26584
|
+
}
|
|
26585
|
+
/** Markdown 块的摘要去掉粗体星号(与宿主 ReasoningRow 同一手法);纯文本块照原样。 */
|
|
26586
|
+
function markdownSummary(text) {
|
|
26587
|
+
return firstLine(text).replaceAll("**", "");
|
|
26588
|
+
}
|
|
26589
|
+
/** 宿主当前发过的 `source.kind` 全在这里;日志词表随宿主代次增长,新值先落到「其他上下文」。 */
|
|
26590
|
+
const INJECTION_LABEL_KEYS = {
|
|
26591
|
+
"runtime-context": "detail.injection.runtimeContext",
|
|
26592
|
+
"time-context": "detail.injection.timeContext",
|
|
26593
|
+
"agent-instructions": "detail.injection.agentInstructions",
|
|
26594
|
+
"skill-catalog": "detail.injection.skillCatalog",
|
|
26595
|
+
"agent-message": "detail.injection.agentMessage",
|
|
26596
|
+
"subagent-settled": "detail.injection.subagentSettled",
|
|
26597
|
+
"tool-jobs": "detail.injection.toolJobs",
|
|
26598
|
+
"skill-invocation": "detail.injection.skillInvocation",
|
|
26599
|
+
"user-approval": "detail.injection.userApproval",
|
|
26600
|
+
"goal": "detail.injection.goal"
|
|
26601
|
+
};
|
|
26602
|
+
/** 来源标签 + 悬停原串:认不出的 kind 收进「其他上下文」,原串留在 title。 */
|
|
26603
|
+
function injectionLabel(kind, t) {
|
|
26604
|
+
const key = INJECTION_LABEL_KEYS[kind];
|
|
26605
|
+
if (key !== void 0) {
|
|
26606
|
+
const text = t(key);
|
|
26607
|
+
return {
|
|
26608
|
+
text,
|
|
26609
|
+
title: text
|
|
26610
|
+
};
|
|
26611
|
+
}
|
|
26612
|
+
return {
|
|
26613
|
+
text: t("detail.injection.other"),
|
|
26614
|
+
title: kind === "" ? t("detail.injection.other") : kind
|
|
26615
|
+
};
|
|
26616
|
+
}
|
|
26617
|
+
function toolStatus(status, t) {
|
|
26618
|
+
if (status === "ok") return t("detail.block.status.ok");
|
|
26619
|
+
if (status === "error") return t("detail.block.status.error");
|
|
26620
|
+
return t("detail.block.status.unfinished");
|
|
26621
|
+
}
|
|
26622
|
+
function isDisclosureBlock(block) {
|
|
26623
|
+
return block.kind !== "user" && block.kind !== "assistant";
|
|
26624
|
+
}
|
|
26625
|
+
/** 一个可折叠块:折叠头一行(chevron + 标签 + 摘要 + 可选状态 + 时间),展开体由调用方给。 */
|
|
26626
|
+
function Disclosure({ block, label, title, summary, status, time, open, onToggle, children }) {
|
|
26627
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26628
|
+
"data-sd-block": block.kind,
|
|
26629
|
+
style: BLOCK,
|
|
26630
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
26631
|
+
type: "button",
|
|
26632
|
+
className: "sd-disclosure-row",
|
|
26633
|
+
"data-expandable": "",
|
|
26634
|
+
"aria-expanded": open,
|
|
26635
|
+
onClick: () => onToggle(block.id),
|
|
26636
|
+
style: BLOCK_ROW,
|
|
26637
|
+
children: [
|
|
26638
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26639
|
+
className: "sd-disclosure-leading",
|
|
26640
|
+
style: { marginRight: 0 },
|
|
26641
|
+
"aria-hidden": "true",
|
|
26642
|
+
children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 })
|
|
26643
|
+
}),
|
|
26644
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26645
|
+
style: BLOCK_TITLE,
|
|
26646
|
+
title: title ?? label,
|
|
26647
|
+
children: label
|
|
26648
|
+
}),
|
|
26649
|
+
summary === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26650
|
+
style: BLOCK_SUMMARY,
|
|
26651
|
+
title: summary,
|
|
26652
|
+
children: summary
|
|
26653
|
+
}),
|
|
26654
|
+
status === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26655
|
+
style: status.error ? {
|
|
26656
|
+
...BLOCK_META,
|
|
26657
|
+
...ERROR_TEXT
|
|
26658
|
+
} : BLOCK_META,
|
|
26659
|
+
children: status.text
|
|
26660
|
+
}),
|
|
26661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26662
|
+
style: BLOCK_META,
|
|
26663
|
+
children: time
|
|
26664
|
+
})
|
|
26665
|
+
]
|
|
26666
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26667
|
+
"data-sd-block-body": true,
|
|
26668
|
+
style: BLOCK_BODY,
|
|
26669
|
+
children
|
|
26670
|
+
}) : null]
|
|
26671
|
+
});
|
|
26672
|
+
}
|
|
26673
|
+
/** 单块载荷闸截断后的提示:各块共用同一句,别有的画省略号、有的只画字。 */
|
|
26674
|
+
function TruncatedNote({ t }) {
|
|
26675
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26676
|
+
style: TRUNCATED,
|
|
26677
|
+
children: t("detail.block.truncated")
|
|
26678
|
+
});
|
|
26679
|
+
}
|
|
26680
|
+
/** 展开体走 Markdown 的折叠块(思考、压缩摘要)。 */
|
|
26681
|
+
function markdownFold(block, label, context) {
|
|
26682
|
+
const { open, toggle, probe, t } = context;
|
|
26683
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
|
|
26684
|
+
block,
|
|
26685
|
+
label,
|
|
26686
|
+
summary: markdownSummary(block.text),
|
|
26687
|
+
time: timeText(block.time),
|
|
26688
|
+
open: open.has(block.id),
|
|
26689
|
+
onToggle: toggle,
|
|
26690
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
|
|
26691
|
+
text: block.text,
|
|
26692
|
+
t,
|
|
26693
|
+
probe
|
|
26694
|
+
}), block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null]
|
|
26695
|
+
}, block.id);
|
|
26696
|
+
}
|
|
26697
|
+
/** 展开体走等宽原文的折叠块(注入、系统消息):这两类带附件。 */
|
|
26698
|
+
function plainFold(block, label, context, title) {
|
|
26699
|
+
const { open, toggle, t } = context;
|
|
26700
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
|
|
26701
|
+
block,
|
|
26702
|
+
label,
|
|
26703
|
+
title,
|
|
26704
|
+
summary: firstLine(block.text),
|
|
26705
|
+
time: timeText(block.time),
|
|
26706
|
+
open: open.has(block.id),
|
|
26707
|
+
onToggle: toggle,
|
|
26708
|
+
children: [
|
|
26709
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26710
|
+
style: PLAIN_BODY,
|
|
26711
|
+
children: block.text
|
|
26712
|
+
}),
|
|
26713
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26714
|
+
attachments: block.attachments,
|
|
26715
|
+
t,
|
|
26716
|
+
align: "start"
|
|
26717
|
+
}),
|
|
26718
|
+
block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
|
|
26719
|
+
]
|
|
26720
|
+
}, block.id);
|
|
26721
|
+
}
|
|
26722
|
+
/** 块联合新增成员而 `renderBlock` 没跟上时这里编译失败;运行时只可能是前后半版本错配,画空不炸整栏。 */
|
|
26723
|
+
function unhandledBlock(block) {
|
|
26724
|
+
return null;
|
|
26725
|
+
}
|
|
26726
|
+
function renderBlock(block, context) {
|
|
26727
|
+
const { open, toggle, probe, t } = context;
|
|
26728
|
+
switch (block.kind) {
|
|
26729
|
+
case "user": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26730
|
+
"data-sd-msg": "user",
|
|
26731
|
+
style: USER_ROW,
|
|
26732
|
+
children: [
|
|
26733
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26734
|
+
style: META_LINE,
|
|
26735
|
+
children: `${t("detail.role.user")} · ${timeText(block.time)}`
|
|
26736
|
+
}),
|
|
26737
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26738
|
+
attachments: block.attachments,
|
|
26739
|
+
t,
|
|
26740
|
+
align: "end"
|
|
26741
|
+
}),
|
|
26742
|
+
block.text === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26743
|
+
"data-sd-bubble": true,
|
|
26744
|
+
style: BUBBLE,
|
|
26745
|
+
children: block.text
|
|
26746
|
+
}),
|
|
26747
|
+
block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
|
|
26748
|
+
]
|
|
26749
|
+
}, block.id);
|
|
26750
|
+
case "assistant": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26751
|
+
"data-sd-msg": "assistant",
|
|
26752
|
+
style: ASSISTANT_ROW,
|
|
26753
|
+
children: [
|
|
26754
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26755
|
+
style: META_LINE,
|
|
26756
|
+
children: `${t("detail.role.assistant")} · ${timeText(block.time)}`
|
|
26757
|
+
}),
|
|
26758
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
|
|
26759
|
+
text: block.text,
|
|
26760
|
+
t,
|
|
26761
|
+
probe
|
|
26762
|
+
}),
|
|
26763
|
+
block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null,
|
|
26764
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26765
|
+
attachments: block.attachments,
|
|
26766
|
+
t,
|
|
26767
|
+
align: "start"
|
|
26768
|
+
})
|
|
26769
|
+
]
|
|
26770
|
+
}, block.id);
|
|
26771
|
+
case "reasoning": return markdownFold(block, t("detail.block.reasoning"), context);
|
|
26772
|
+
case "tool": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
|
|
26773
|
+
block,
|
|
26774
|
+
label: block.name === "" ? t("detail.block.tool") : block.name,
|
|
26775
|
+
summary: firstLine(block.result) || firstLine(block.args),
|
|
26776
|
+
status: {
|
|
26777
|
+
text: toolStatus(block.status, t),
|
|
26778
|
+
error: block.status === "error"
|
|
26779
|
+
},
|
|
26780
|
+
time: timeText(block.time),
|
|
26781
|
+
open: open.has(block.id),
|
|
26782
|
+
onToggle: toggle,
|
|
26783
|
+
children: [
|
|
26784
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26785
|
+
style: SECTION_LABEL,
|
|
26786
|
+
children: t("detail.block.args")
|
|
26787
|
+
}),
|
|
26788
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
26789
|
+
style: CODE_BLOCK,
|
|
26790
|
+
children: block.args === "" ? "—" : block.args
|
|
26791
|
+
}),
|
|
26792
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26793
|
+
style: SECTION_LABEL,
|
|
26794
|
+
children: t("detail.block.result")
|
|
26795
|
+
}),
|
|
26796
|
+
block.status === "unfinished" && block.result === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26797
|
+
style: META_LINE,
|
|
26798
|
+
children: t("detail.block.noResult")
|
|
26799
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
26800
|
+
style: CODE_BLOCK,
|
|
26801
|
+
children: block.result === "" ? "—" : block.result
|
|
26802
|
+
}),
|
|
26803
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26804
|
+
attachments: block.attachments,
|
|
26805
|
+
t,
|
|
26806
|
+
align: "start"
|
|
26807
|
+
}),
|
|
26808
|
+
block.argsTruncated || block.resultTruncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
|
|
26809
|
+
]
|
|
26810
|
+
}, block.id);
|
|
26811
|
+
case "injection": {
|
|
26812
|
+
const label = injectionLabel(block.sourceKind, t);
|
|
26813
|
+
return plainFold(block, `${t("detail.block.injection")} · ${label.text}`, context, `${t("detail.block.injection")} · ${label.title}`);
|
|
26814
|
+
}
|
|
26815
|
+
case "system": return plainFold(block, t("detail.block.system"), context);
|
|
26816
|
+
case "compaction": return markdownFold(block, t("detail.block.compaction"), context);
|
|
26817
|
+
default: return unhandledBlock(block);
|
|
26818
|
+
}
|
|
26819
|
+
}
|
|
26820
|
+
function TrashDetail({ state, busy, wide, onRetry, onBack, detailNav, pager, t }) {
|
|
26146
26821
|
if (state.kind === "idle") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26147
26822
|
style: {
|
|
26148
26823
|
display: "flex",
|
|
@@ -26159,29 +26834,32 @@ window.__ModuleLoader__.load({
|
|
|
26159
26834
|
});
|
|
26160
26835
|
const entry = state.entry;
|
|
26161
26836
|
const detail = state.kind === "ready" ? state.detail : void 0;
|
|
26837
|
+
const title = titleOf(entry, detail, t);
|
|
26162
26838
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26163
26839
|
style: SHELL,
|
|
26164
26840
|
children: [
|
|
26165
26841
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26842
|
+
"data-dsh-session-delete": "trash-detail-head",
|
|
26166
26843
|
style: HEAD,
|
|
26167
|
-
children: [
|
|
26844
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26845
|
+
style: TITLE_LINE,
|
|
26846
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
26847
|
+
title,
|
|
26848
|
+
children: title
|
|
26849
|
+
})
|
|
26850
|
+
}), detailNav.parentContext !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
26168
26851
|
variant: "ghost",
|
|
26169
26852
|
size: "sm",
|
|
26170
|
-
style:
|
|
26853
|
+
style: BACK,
|
|
26854
|
+
onClick: detailNav.parentContext.onBack,
|
|
26855
|
+
title: detailNav.parentContext.title === "" ? t("common.untitled") : detailNav.parentContext.title,
|
|
26856
|
+
children: t("detail.backToParent")
|
|
26857
|
+
}) : wide ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
26858
|
+
variant: "ghost",
|
|
26859
|
+
size: "sm",
|
|
26860
|
+
style: BACK,
|
|
26171
26861
|
onClick: onBack,
|
|
26172
26862
|
children: t("detail.back")
|
|
26173
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26174
|
-
style: { minWidth: 0 },
|
|
26175
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26176
|
-
style: {
|
|
26177
|
-
...ELLIPSIS,
|
|
26178
|
-
fontSize: 15,
|
|
26179
|
-
fontWeight: 600,
|
|
26180
|
-
lineHeight: "22px"
|
|
26181
|
-
},
|
|
26182
|
-
title: titleOf(entry, detail, t),
|
|
26183
|
-
children: titleOf(entry, detail, t)
|
|
26184
|
-
})
|
|
26185
26863
|
})]
|
|
26186
26864
|
}),
|
|
26187
26865
|
state.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoadingBody, { t }) : null,
|
|
@@ -26236,8 +26914,10 @@ window.__ModuleLoader__.load({
|
|
|
26236
26914
|
detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReadyBody, {
|
|
26237
26915
|
entry,
|
|
26238
26916
|
detail,
|
|
26917
|
+
detailNav,
|
|
26918
|
+
pager,
|
|
26239
26919
|
t
|
|
26240
|
-
})
|
|
26920
|
+
}, entry.id)
|
|
26241
26921
|
]
|
|
26242
26922
|
});
|
|
26243
26923
|
}
|
|
@@ -26281,17 +26961,60 @@ window.__ModuleLoader__.load({
|
|
|
26281
26961
|
})] });
|
|
26282
26962
|
}
|
|
26283
26963
|
/**
|
|
26284
|
-
* 有内容:元信息(时间 → 体量 → 位置三组的顺序)+
|
|
26964
|
+
* 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 子会话段 + 折叠块。
|
|
26285
26965
|
*
|
|
26286
|
-
*
|
|
26287
|
-
*
|
|
26288
|
-
*
|
|
26966
|
+
* 页面上**两本账分开读**:元信息里的计数是整条日志的(注入条数也在表里);
|
|
26967
|
+
* 正文是分页取回来的块。`olderCursor` 非空时顶部出「加载更早」。
|
|
26968
|
+
* 折叠状态只活在这个组件里;切条目用 `key={entry.id}` 重挂载,不会把 A 的展开状态带到 B。
|
|
26289
26969
|
*/
|
|
26290
|
-
function ReadyBody({ entry, detail, t }) {
|
|
26291
|
-
const
|
|
26970
|
+
function ReadyBody({ entry, detail, detailNav, pager, t }) {
|
|
26971
|
+
const { loadingOlder, olderFailure, onLoadOlder, scrollRef } = pager;
|
|
26972
|
+
const [open, setOpen] = (0, react.useState)(() => /* @__PURE__ */ new Set());
|
|
26973
|
+
/** 更早一页要 prepend 时的块数;换页回来(组件重挂载)这份意图会丢,那是可接受的边界。 */
|
|
26974
|
+
const pendingScroll = (0, react.useRef)(null);
|
|
26975
|
+
/** 上一次布局后的滚动高度:合并更早一页时用它算**真正插进来的高度**,不把请求在飞期间的
|
|
26976
|
+
* 滚动或折叠变化也算进去。 */
|
|
26977
|
+
const lastHeight = (0, react.useRef)(null);
|
|
26978
|
+
const probe = localeProbe(t);
|
|
26292
26979
|
const project = detail.cwd === "" ? t("trash.project.unknown") : detail.cwd;
|
|
26293
|
-
const
|
|
26294
|
-
const
|
|
26980
|
+
const disclosureBlocks = detail.blocks.filter(isDisclosureBlock);
|
|
26981
|
+
const allOpen = disclosureBlocks.length > 0 && disclosureBlocks.every((block) => open.has(block.id));
|
|
26982
|
+
const openChild = detailNav.onOpenChild;
|
|
26983
|
+
const toggle = (id) => {
|
|
26984
|
+
setOpen((current) => {
|
|
26985
|
+
const next = new Set(current);
|
|
26986
|
+
if (next.has(id)) next.delete(id);
|
|
26987
|
+
else next.add(id);
|
|
26988
|
+
return next;
|
|
26989
|
+
});
|
|
26990
|
+
};
|
|
26991
|
+
const toggleAll = () => {
|
|
26992
|
+
setOpen(allOpen ? /* @__PURE__ */ new Set() : new Set(disclosureBlocks.map((block) => block.id)));
|
|
26993
|
+
};
|
|
26994
|
+
const loadOlder = () => {
|
|
26995
|
+
const element = scrollRef?.current;
|
|
26996
|
+
if (element !== null && element !== void 0) pendingScroll.current = { blocks: detail.blocks.length };
|
|
26997
|
+
onLoadOlder();
|
|
26998
|
+
};
|
|
26999
|
+
(0, react.useLayoutEffect)(() => {
|
|
27000
|
+
const element = scrollRef?.current;
|
|
27001
|
+
const pending = pendingScroll.current;
|
|
27002
|
+
if (element === null || element === void 0 || pending === null) return;
|
|
27003
|
+
if (detail.blocks.length !== pending.blocks) {
|
|
27004
|
+
pendingScroll.current = null;
|
|
27005
|
+
if (lastHeight.current !== null) element.scrollTop += element.scrollHeight - lastHeight.current;
|
|
27006
|
+
return;
|
|
27007
|
+
}
|
|
27008
|
+
if (!loadingOlder) pendingScroll.current = null;
|
|
27009
|
+
}, [
|
|
27010
|
+
detail.blocks.length,
|
|
27011
|
+
loadingOlder,
|
|
27012
|
+
scrollRef
|
|
27013
|
+
]);
|
|
27014
|
+
(0, react.useLayoutEffect)(() => {
|
|
27015
|
+
const element = scrollRef?.current;
|
|
27016
|
+
if (element !== null && element !== void 0) lastHeight.current = element.scrollHeight;
|
|
27017
|
+
});
|
|
26295
27018
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
26296
27019
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", {
|
|
26297
27020
|
style: META,
|
|
@@ -26335,6 +27058,10 @@ window.__ModuleLoader__.load({
|
|
|
26335
27058
|
label: t("detail.field.toolCalls"),
|
|
26336
27059
|
children: String(detail.toolCallCount)
|
|
26337
27060
|
}),
|
|
27061
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
|
|
27062
|
+
label: t("detail.field.injections"),
|
|
27063
|
+
children: String(detail.injectedCount)
|
|
27064
|
+
}),
|
|
26338
27065
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
|
|
26339
27066
|
label: t("detail.field.models"),
|
|
26340
27067
|
children: detail.models.join(", ") || "—"
|
|
@@ -26351,8 +27078,8 @@ window.__ModuleLoader__.load({
|
|
|
26351
27078
|
}),
|
|
26352
27079
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
|
|
26353
27080
|
label: t("detail.field.trashDir"),
|
|
26354
|
-
title:
|
|
26355
|
-
children:
|
|
27081
|
+
title: entry.id,
|
|
27082
|
+
children: entry.id
|
|
26356
27083
|
}),
|
|
26357
27084
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
|
|
26358
27085
|
label: t("detail.field.wasArchived"),
|
|
@@ -26360,46 +27087,98 @@ window.__ModuleLoader__.load({
|
|
|
26360
27087
|
})
|
|
26361
27088
|
]
|
|
26362
27089
|
}),
|
|
27090
|
+
detail.subagents === void 0 || detail.subagents.length === 0 || openChild === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
27091
|
+
"data-dsh-session-delete": "trash-subagents",
|
|
27092
|
+
style: {
|
|
27093
|
+
display: "flex",
|
|
27094
|
+
flexDirection: "column",
|
|
27095
|
+
gap: 6
|
|
27096
|
+
},
|
|
27097
|
+
children: [
|
|
27098
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
27099
|
+
style: SECTION_LABEL,
|
|
27100
|
+
children: t("detail.subagents.header", {
|
|
27101
|
+
n: detail.subagents.length,
|
|
27102
|
+
size: fileSizeText(detail.subagents.reduce((sum, child) => sum + child.sizeBytes, 0))
|
|
27103
|
+
})
|
|
27104
|
+
}),
|
|
27105
|
+
detail.subagents.map((child) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
27106
|
+
type: "button",
|
|
27107
|
+
className: "sd-disclosure-row",
|
|
27108
|
+
"data-dsh-session-delete": "trash-subagent-row",
|
|
27109
|
+
"data-entry-id": child.id,
|
|
27110
|
+
style: SUBAGENT_ROW,
|
|
27111
|
+
onClick: () => openChild(child),
|
|
27112
|
+
children: [
|
|
27113
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27114
|
+
style: {
|
|
27115
|
+
...ELLIPSIS,
|
|
27116
|
+
flex: "1 1 auto",
|
|
27117
|
+
minWidth: 0
|
|
27118
|
+
},
|
|
27119
|
+
children: child.title === "" ? t("common.untitled") : child.title
|
|
27120
|
+
}),
|
|
27121
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27122
|
+
style: BLOCK_META,
|
|
27123
|
+
children: fileSizeText(child.sizeBytes)
|
|
27124
|
+
}),
|
|
27125
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27126
|
+
style: BLOCK_META,
|
|
27127
|
+
children: agoText(child.deletedAt, Date.now(), t)
|
|
27128
|
+
})
|
|
27129
|
+
]
|
|
27130
|
+
}, child.id)),
|
|
27131
|
+
detail.subagentsTotal === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
27132
|
+
style: SECTION_LABEL,
|
|
27133
|
+
"data-dsh-session-delete": "trash-subagents-truncated",
|
|
27134
|
+
children: t("detail.subagents.truncated", {
|
|
27135
|
+
shown: detail.subagents.length,
|
|
27136
|
+
total: detail.subagentsTotal
|
|
27137
|
+
})
|
|
27138
|
+
})
|
|
27139
|
+
]
|
|
27140
|
+
}),
|
|
26363
27141
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26364
|
-
style:
|
|
26365
|
-
children: [
|
|
27142
|
+
style: TOOLBAR$1,
|
|
27143
|
+
children: [
|
|
27144
|
+
detail.olderCursor === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
27145
|
+
variant: "outline",
|
|
27146
|
+
size: "sm",
|
|
27147
|
+
disabled: loadingOlder,
|
|
27148
|
+
onClick: loadOlder,
|
|
27149
|
+
children: loadingOlder ? t("detail.loadOlder.loading") : t("detail.loadOlder")
|
|
27150
|
+
}),
|
|
27151
|
+
olderFailure === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27152
|
+
role: "status",
|
|
27153
|
+
style: {
|
|
27154
|
+
...META_LINE,
|
|
27155
|
+
minWidth: 0,
|
|
27156
|
+
wordBreak: "break-word"
|
|
27157
|
+
},
|
|
27158
|
+
children: olderFailure
|
|
27159
|
+
}),
|
|
27160
|
+
disclosureBlocks.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27161
|
+
style: { marginLeft: "auto" },
|
|
27162
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
27163
|
+
variant: "ghost",
|
|
27164
|
+
size: "sm",
|
|
27165
|
+
onClick: toggleAll,
|
|
27166
|
+
children: allOpen ? t("detail.collapseAll") : t("detail.expandAll")
|
|
27167
|
+
})
|
|
27168
|
+
})
|
|
27169
|
+
]
|
|
26366
27170
|
}),
|
|
26367
|
-
|
|
27171
|
+
detail.blocks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26368
27172
|
style: META_LINE,
|
|
26369
27173
|
children: t("detail.noBody")
|
|
26370
27174
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26371
27175
|
style: THREAD,
|
|
26372
|
-
children:
|
|
26373
|
-
|
|
26374
|
-
|
|
26375
|
-
|
|
26376
|
-
|
|
26377
|
-
|
|
26378
|
-
}), message.role === "user" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26379
|
-
attachments: message.attachments,
|
|
26380
|
-
t,
|
|
26381
|
-
align: "end"
|
|
26382
|
-
}), message.text === "" && !message.truncated ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
26383
|
-
"data-sd-bubble": true,
|
|
26384
|
-
style: BUBBLE,
|
|
26385
|
-
children: [message.text, message.truncated ? "…" : ""]
|
|
26386
|
-
})] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
26387
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
|
|
26388
|
-
text: message.text,
|
|
26389
|
-
t,
|
|
26390
|
-
localeKey
|
|
26391
|
-
}),
|
|
26392
|
-
message.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
26393
|
-
style: META_LINE,
|
|
26394
|
-
children: "…"
|
|
26395
|
-
}) : null,
|
|
26396
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
|
|
26397
|
-
attachments: message.attachments,
|
|
26398
|
-
t,
|
|
26399
|
-
align: "start"
|
|
26400
|
-
})
|
|
26401
|
-
] })]
|
|
26402
|
-
}, `${message.time}-${index}`))
|
|
27176
|
+
children: detail.blocks.map((block) => renderBlock(block, {
|
|
27177
|
+
open,
|
|
27178
|
+
toggle,
|
|
27179
|
+
probe,
|
|
27180
|
+
t
|
|
27181
|
+
}))
|
|
26403
27182
|
})
|
|
26404
27183
|
] });
|
|
26405
27184
|
}
|
|
@@ -26496,7 +27275,7 @@ window.__ModuleLoader__.load({
|
|
|
26496
27275
|
*/
|
|
26497
27276
|
function usePanelWidth(ref) {
|
|
26498
27277
|
const [width, setWidth] = (0, react.useState)(0);
|
|
26499
|
-
(0, react.
|
|
27278
|
+
(0, react.useLayoutEffect)(() => {
|
|
26500
27279
|
const el = ref.current;
|
|
26501
27280
|
if (el === null) return;
|
|
26502
27281
|
setWidth(el.getBoundingClientRect().width);
|
|
@@ -26515,7 +27294,7 @@ window.__ModuleLoader__.load({
|
|
|
26515
27294
|
//#region src/client/trash-nav.ts
|
|
26516
27295
|
/**
|
|
26517
27296
|
* 清单的键盘导航面:方向键走环、Enter/Space 的双重语义、roving tabindex 的落点,
|
|
26518
|
-
* 以及窄档下钻里按 Esc
|
|
27297
|
+
* 以及窄档下钻里按 Esc 回退——孩子视图开着时先回父详情,再按一次才退出下钻并把焦点交还给那一行。
|
|
26519
27298
|
*
|
|
26520
27299
|
* 它不查自己的行、不认行选择器:焦点动作由调用方注入 focusRow,行的 DOM 查询留在面板;
|
|
26521
27300
|
* 唯一的文档查询是 Esc 让路判据(问 ui/top-layer.ts 当前有没有打开的 dialog/menu)。
|
|
@@ -26524,17 +27303,18 @@ window.__ModuleLoader__.load({
|
|
|
26524
27303
|
* 判据(界面现状见 docs/ui-spec.md「键盘」):
|
|
26525
27304
|
* - 方向键走环:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷。
|
|
26526
27305
|
* - aria-activedescendant 只指向在 DOM 里的行;tab 落点永远有一行拿着。
|
|
26527
|
-
* - Esc
|
|
27306
|
+
* - Esc 只在下钻期间生效,回退分两程(先收孩子视图、再退下钻);退出下钻后把焦点交还选中行一次;
|
|
27307
|
+
* 最上层是确认框/菜单时让路。
|
|
26528
27308
|
*/
|
|
26529
|
-
/** 事件是不是清单自己的:从行(`role="
|
|
27309
|
+
/** 事件是不是清单自己的:从行(`role="row"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
|
|
26530
27310
|
* 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
|
|
26531
27311
|
function isListOwnedEvent(event) {
|
|
26532
27312
|
const target = event.target;
|
|
26533
27313
|
if (target == null || target === event.currentTarget) return true;
|
|
26534
|
-
return typeof target.getAttribute === "function" && target.getAttribute("role") === "
|
|
27314
|
+
return typeof target.getAttribute === "function" && target.getAttribute("role") === "row";
|
|
26535
27315
|
}
|
|
26536
27316
|
function useTrashNav(input) {
|
|
26537
|
-
const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck,
|
|
27317
|
+
const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, back, focusRow } = input;
|
|
26538
27318
|
/** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
|
|
26539
27319
|
const refocusRow = (0, react.useRef)(null);
|
|
26540
27320
|
/** 折叠起来的组只是收掉那一列,选中的 id 还留着——所以这条可见性得逐条对账,不能只看非空。 */
|
|
@@ -26548,6 +27328,7 @@ window.__ModuleLoader__.load({
|
|
|
26548
27328
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
26549
27329
|
const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
|
|
26550
27330
|
const target = ordered[next];
|
|
27331
|
+
if (target === void 0) return;
|
|
26551
27332
|
select(target);
|
|
26552
27333
|
focusRow(target.id);
|
|
26553
27334
|
return;
|
|
@@ -26578,7 +27359,7 @@ window.__ModuleLoader__.load({
|
|
|
26578
27359
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
26579
27360
|
if (hasTopLayer(document)) return;
|
|
26580
27361
|
refocusRow.current = selectedId;
|
|
26581
|
-
|
|
27362
|
+
back();
|
|
26582
27363
|
};
|
|
26583
27364
|
document.addEventListener("keydown", onKey);
|
|
26584
27365
|
return () => {
|
|
@@ -26587,7 +27368,7 @@ window.__ModuleLoader__.load({
|
|
|
26587
27368
|
}, [
|
|
26588
27369
|
drillEnabled,
|
|
26589
27370
|
drilled,
|
|
26590
|
-
|
|
27371
|
+
back,
|
|
26591
27372
|
selectedId
|
|
26592
27373
|
]);
|
|
26593
27374
|
(0, react.useEffect)(() => {
|
|
@@ -26611,6 +27392,9 @@ window.__ModuleLoader__.load({
|
|
|
26611
27392
|
* - 读不到就说读不到:失败进 `unknown`(原文上屏),不折成"空的"。
|
|
26612
27393
|
* - 附件读数只住在写动作的回执里:清单重读读不出来,所以它单独一条提示、**最新的写赢**。
|
|
26613
27394
|
*
|
|
27395
|
+
* 输入的引用**不需稳定**:`deps` / `t` / `gate` 经 `useLatest` 归一(判据住 `store.ts`),
|
|
27396
|
+
* 返回的三个回调身份恒稳,可安全进调用方 effect 的依赖。
|
|
27397
|
+
*
|
|
26614
27398
|
* 无主子会话的检查/清理**不在这一面**:那是插件设置页维护卡的手动动作(`use-maintenance.ts`)。
|
|
26615
27399
|
* 面板挂载、写后重读与清空都只碰清单,一次都不自动扫(ADR-0021)。
|
|
26616
27400
|
*/
|
|
@@ -26639,11 +27423,17 @@ window.__ModuleLoader__.load({
|
|
|
26639
27423
|
function useTrashQuery({ deps, t, gate }) {
|
|
26640
27424
|
const [list, setList] = (0, react.useState)({ status: "loading" });
|
|
26641
27425
|
const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
|
|
27426
|
+
/** 输入引用归一(判据住 `store.ts` 的 `useLatest`):回调经它读最新输入,身份恒稳。 */
|
|
27427
|
+
const latest = useLatest({
|
|
27428
|
+
deps,
|
|
27429
|
+
t,
|
|
27430
|
+
gate
|
|
27431
|
+
});
|
|
26642
27432
|
/** 清单的唯一读法:签到一张票再发请求;输给后发的读时这一次什么都不落地(不让旧响应盖掉新状态)。 */
|
|
26643
27433
|
const [reloadLatest] = (0, react.useState)(createLatestWins);
|
|
26644
27434
|
const reload = (0, react.useCallback)(async () => {
|
|
26645
27435
|
const ticket = reloadLatest.issue();
|
|
26646
|
-
const outcome = await callRemote(() => deps.list());
|
|
27436
|
+
const outcome = await callRemote(() => latest.current.deps.list());
|
|
26647
27437
|
if (!ticket.current) return;
|
|
26648
27438
|
if (!outcome.ok) {
|
|
26649
27439
|
setList({
|
|
@@ -26656,7 +27446,7 @@ window.__ModuleLoader__.load({
|
|
|
26656
27446
|
status: "ready",
|
|
26657
27447
|
rows: outcome.value
|
|
26658
27448
|
});
|
|
26659
|
-
}, [
|
|
27449
|
+
}, [reloadLatest]);
|
|
26660
27450
|
/**
|
|
26661
27451
|
* 附件读数上屏:`skipped`(这一轮部分或整体没敢清)优先于 `failed`(逐文件真出错)——前者是原因,
|
|
26662
27452
|
* 后者只是件数。**`removed > 0` 时不许说成"整轮没清"**:那一轮清掉了 `removed` 件,只是没清完,
|
|
@@ -26668,21 +27458,20 @@ window.__ModuleLoader__.load({
|
|
|
26668
27458
|
setAttachmentNotice(null);
|
|
26669
27459
|
return;
|
|
26670
27460
|
}
|
|
26671
|
-
const skipped = attachmentSkippedSentence(a, t);
|
|
27461
|
+
const skipped = attachmentSkippedSentence(a, latest.current.t);
|
|
26672
27462
|
if (skipped !== null) {
|
|
26673
27463
|
setAttachmentNotice(skipped);
|
|
26674
27464
|
return;
|
|
26675
27465
|
}
|
|
26676
|
-
setAttachmentNotice(a.failed > 0 ? t("trash.attachments.failed", { count: a.failed }) : null);
|
|
26677
|
-
}, [
|
|
27466
|
+
setAttachmentNotice(a.failed > 0 ? latest.current.t("trash.attachments.failed", { count: a.failed }) : null);
|
|
27467
|
+
}, []);
|
|
26678
27468
|
return {
|
|
26679
27469
|
list,
|
|
26680
27470
|
attachmentNotice,
|
|
26681
27471
|
noteAttachmentCleanup,
|
|
26682
|
-
|
|
26683
|
-
|
|
26684
|
-
|
|
26685
|
-
const outcome = await callRemote(() => deps.emptyTrash());
|
|
27472
|
+
reload,
|
|
27473
|
+
emptyTrash: (0, react.useCallback)(() => latest.current.gate.around(async () => {
|
|
27474
|
+
const outcome = await callRemote(() => latest.current.deps.emptyTrash());
|
|
26686
27475
|
if (!outcome.ok) {
|
|
26687
27476
|
reloadLatest.invalidate();
|
|
26688
27477
|
setList({
|
|
@@ -26697,8 +27486,6 @@ window.__ModuleLoader__.load({
|
|
|
26697
27486
|
noteAttachmentCleanup(outcome.value.attachments);
|
|
26698
27487
|
return true;
|
|
26699
27488
|
}), [
|
|
26700
|
-
gate,
|
|
26701
|
-
deps,
|
|
26702
27489
|
reload,
|
|
26703
27490
|
reloadLatest,
|
|
26704
27491
|
noteAttachmentCleanup
|
|
@@ -26708,7 +27495,7 @@ window.__ModuleLoader__.load({
|
|
|
26708
27495
|
//#endregion
|
|
26709
27496
|
//#region src/client/use-trash-panel-controller.ts
|
|
26710
27497
|
/**
|
|
26711
|
-
*
|
|
27498
|
+
* 回收站面板状态机:选中、孩子视图、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
|
|
26712
27499
|
* 失效重读,以及确认后的恢复/批量/清空与失败落点。
|
|
26713
27500
|
*
|
|
26714
27501
|
* 关键判据:
|
|
@@ -26716,36 +27503,87 @@ window.__ModuleLoader__.load({
|
|
|
26716
27503
|
* - 详情成功读数按条目 id 缓存;失败不缓存,重试会重新读。
|
|
26717
27504
|
* - 选中 id 不在新清单里 ⇒ 丢缓存、清选中、退出下钻;清单读不到(null)不算“没了”。
|
|
26718
27505
|
* - 自动选中只在 selectedId 为 null 时接管。
|
|
26719
|
-
* -
|
|
26720
|
-
* - back()
|
|
27506
|
+
* - 失效通告与挂载都只触发 reload();动作成功必须在同一趟里丢详情缓存。
|
|
27507
|
+
* - back() 先收孩子视图、再收下钻,不碰焦点;失败原文不翻译。
|
|
27508
|
+
*/
|
|
27509
|
+
/**
|
|
27510
|
+
* 「渲染态 + 同步镜像」成对出现时的唯一写口:setter 同步更新 ref。`setState` 要等下一拍,
|
|
27511
|
+
* 而落地判「用户还在不在这一条」(`loadOlder` 的晚到结果)是同一 tick 里的事。写只走 setter——
|
|
27512
|
+
* 再手写一处 `ref.current = x` 就是第二份同步,两处迟早漂移。
|
|
27513
|
+
*/
|
|
27514
|
+
function useSyncedRef(initial) {
|
|
27515
|
+
const ref = (0, react.useRef)(initial);
|
|
27516
|
+
const [value, setValue] = (0, react.useState)(initial);
|
|
27517
|
+
return [
|
|
27518
|
+
value,
|
|
27519
|
+
(0, react.useCallback)((next) => {
|
|
27520
|
+
ref.current = next;
|
|
27521
|
+
setValue(next);
|
|
27522
|
+
}, []),
|
|
27523
|
+
ref
|
|
27524
|
+
];
|
|
27525
|
+
}
|
|
27526
|
+
/**
|
|
27527
|
+
* 在飞句柄:`/detail` 与「更早一页」各持一份。两条时间线各有自己的载体,别混——判重在
|
|
27528
|
+
* **同步**这一侧(`begin`:同一 tick 连点不发第二条;登记按条目,A 在飞不挡 B,也绝不放
|
|
27529
|
+
* 同一条进两条),上屏在 **state** 那一侧(渲染位单槽要等下一拍,只回答「当前这条转不转圈」)。
|
|
27530
|
+
* 置位/清位只走这四个方法,不再一对 state+ref 镜像各写一份同步。
|
|
26721
27531
|
*/
|
|
27532
|
+
function useInFlight() {
|
|
27533
|
+
const flying = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
27534
|
+
const [shownId, setShownId] = (0, react.useState)(null);
|
|
27535
|
+
const show = (0, react.useCallback)((id) => setShownId(id), []);
|
|
27536
|
+
const begin = (0, react.useCallback)((id) => {
|
|
27537
|
+
if (flying.current.has(id)) return false;
|
|
27538
|
+
flying.current.add(id);
|
|
27539
|
+
return true;
|
|
27540
|
+
}, []);
|
|
27541
|
+
const end = (0, react.useCallback)((id) => {
|
|
27542
|
+
flying.current.delete(id);
|
|
27543
|
+
setShownId((current) => current === id ? null : current);
|
|
27544
|
+
}, []);
|
|
27545
|
+
const clear = (0, react.useCallback)(() => {
|
|
27546
|
+
flying.current.clear();
|
|
27547
|
+
setShownId(null);
|
|
27548
|
+
}, []);
|
|
27549
|
+
return [shownId, (0, react.useMemo)(() => ({
|
|
27550
|
+
show,
|
|
27551
|
+
begin,
|
|
27552
|
+
end,
|
|
27553
|
+
clear
|
|
27554
|
+
}), [
|
|
27555
|
+
show,
|
|
27556
|
+
begin,
|
|
27557
|
+
end,
|
|
27558
|
+
clear
|
|
27559
|
+
])];
|
|
27560
|
+
}
|
|
26722
27561
|
function useTrashPanelController(input) {
|
|
26723
27562
|
const { deps, gate, query, ordered, canAutoSelect } = input;
|
|
26724
|
-
const { list,
|
|
27563
|
+
const { list, reload, emptyTrash, noteAttachmentCleanup } = query;
|
|
26725
27564
|
const entries = listView(list).rows;
|
|
26726
|
-
const [selectedId, setSelectedId] = (
|
|
27565
|
+
const [selectedId, setSelectedId, selectedIdRef] = useSyncedRef(null);
|
|
27566
|
+
/** 详情主体可以离清单:孩子视图开着时画的是孩子,`selectedId` 仍指着父行。 */
|
|
27567
|
+
const [childView, setChildView, childViewRef] = useSyncedRef(null);
|
|
26727
27568
|
const [drilled, setDrilled] = (0, react.useState)(false);
|
|
26728
|
-
const [
|
|
27569
|
+
const [detailShown, detailFlight] = useInFlight();
|
|
26729
27570
|
const [detailFailure, setDetailFailure] = (0, react.useState)(null);
|
|
27571
|
+
const [olderShown, olderFlight] = useInFlight();
|
|
27572
|
+
const [olderFailureState, setOlderFailure] = (0, react.useState)(null);
|
|
27573
|
+
/** 缓存是 ref:合并更早一页后要有一个**必然变化**的 state 把新对象推上屏(极快的响应会把
|
|
27574
|
+
* 分页渲染位的置位/清位合批成净零变化,React 会直接 bail out)。 */
|
|
27575
|
+
const [detailVersion, setDetailVersion] = (0, react.useState)(0);
|
|
26730
27576
|
const [pending, setPending] = (0, react.useState)(null);
|
|
26731
27577
|
const [actionFailure, setActionFailure] = (0, react.useState)({});
|
|
26732
27578
|
/** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
|
|
26733
27579
|
const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
26734
|
-
/**
|
|
26735
|
-
|
|
27580
|
+
/** 失效重读的载体:`reload` 回调经 `useLatest` 读最新那一份(判据住 `store.ts`),
|
|
27581
|
+
* 失效 effect 的判据只有 `revision`——挂载首拍与后续通告现在是同一个动作,不再分两支。 */
|
|
27582
|
+
const refreshers = useLatest({ reload });
|
|
26736
27583
|
const revision = useTrashRevision();
|
|
26737
27584
|
(0, react.useEffect)(() => {
|
|
26738
|
-
|
|
26739
|
-
|
|
26740
|
-
refreshAll();
|
|
26741
|
-
return;
|
|
26742
|
-
}
|
|
26743
|
-
refreshAfterWrite();
|
|
26744
|
-
}, [
|
|
26745
|
-
refreshAll,
|
|
26746
|
-
refreshAfterWrite,
|
|
26747
|
-
revision
|
|
26748
|
-
]);
|
|
27585
|
+
refreshers.current.reload();
|
|
27586
|
+
}, [revision]);
|
|
26749
27587
|
(0, react.useEffect)(() => {
|
|
26750
27588
|
if (pending !== null && !gate.busy && list.status === "unknown") setPending(null);
|
|
26751
27589
|
}, [
|
|
@@ -26753,40 +27591,108 @@ window.__ModuleLoader__.load({
|
|
|
26753
27591
|
list.status,
|
|
26754
27592
|
pending
|
|
26755
27593
|
]);
|
|
27594
|
+
/** 按 entryId 确保详情在缓存里:命中直接摆,没命中读一次(在飞闸挡重复)。
|
|
27595
|
+
* 选中(select)与孩子视图(openChild)共用这一条读路——缓存/在飞/失败都按 entryId 键控,
|
|
27596
|
+
* 谁在屏幕上是 subject 的事,不是读路的事。 */
|
|
27597
|
+
const ensureDetail = (0, react.useCallback)(async (entryId) => {
|
|
27598
|
+
if (detailCache.current.has(entryId)) {
|
|
27599
|
+
detailFlight.show(null);
|
|
27600
|
+
setDetailFailure(null);
|
|
27601
|
+
return;
|
|
27602
|
+
}
|
|
27603
|
+
detailFlight.show(entryId);
|
|
27604
|
+
setDetailFailure(null);
|
|
27605
|
+
if (!detailFlight.begin(entryId)) return;
|
|
27606
|
+
const outcome = await callRemote(() => deps.detail(entryId));
|
|
27607
|
+
detailFlight.end(entryId);
|
|
27608
|
+
if (outcome.ok) detailCache.current.set(entryId, outcome.value);
|
|
27609
|
+
else setDetailFailure({
|
|
27610
|
+
entryId,
|
|
27611
|
+
message: outcome.failure.message
|
|
27612
|
+
});
|
|
27613
|
+
}, [deps, detailFlight]);
|
|
26756
27614
|
/**
|
|
26757
27615
|
* 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
|
|
26758
27616
|
* `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
|
|
27617
|
+
* 选清单行就是回清单:孩子视图跟着收掉(详情主体交还给选中那条)。
|
|
26759
27618
|
*/
|
|
26760
27619
|
const select = (0, react.useCallback)(async (entry, options = {}) => {
|
|
27620
|
+
setChildView(null);
|
|
26761
27621
|
setSelectedId(entry.id);
|
|
27622
|
+
setOlderFailure(null);
|
|
26762
27623
|
if (options.drill === true) setDrilled(true);
|
|
26763
|
-
|
|
26764
|
-
|
|
26765
|
-
|
|
27624
|
+
await ensureDetail(entry.id);
|
|
27625
|
+
}, [ensureDetail]);
|
|
27626
|
+
const openChild = (0, react.useCallback)(async (child) => {
|
|
27627
|
+
setChildView(child);
|
|
27628
|
+
setOlderFailure(null);
|
|
27629
|
+
await ensureDetail(child.id);
|
|
27630
|
+
}, [ensureDetail]);
|
|
27631
|
+
const closeChild = (0, react.useCallback)(() => {
|
|
27632
|
+
setChildView(null);
|
|
27633
|
+
setOlderFailure(null);
|
|
27634
|
+
}, []);
|
|
27635
|
+
/**
|
|
27636
|
+
* 按当前详情的 `olderCursor` 取更早一页,拼到现有块前面。
|
|
27637
|
+
*
|
|
27638
|
+
* 三条判据:没有游标 / 这一条已有更早页在飞 ⇒ 不发请求(同一 tick 连点、或切走再切回都撞
|
|
27639
|
+
* 在飞登记——晚到的第二条注定落不了屏,白打一次往返);用户已经切走或缓存已被换掉 ⇒ 晚到的
|
|
27640
|
+
* 页**不落屏**(A 的更早页不许盖掉 B);失败只走 `olderFailure`,已读到的正文照旧是 ready。
|
|
27641
|
+
*/
|
|
27642
|
+
const loadOlder = (0, react.useCallback)(async () => {
|
|
27643
|
+
const entryId = childView?.id ?? selectedId;
|
|
27644
|
+
if (entryId === null) return;
|
|
27645
|
+
const cached = detailCache.current.get(entryId);
|
|
27646
|
+
if (cached === void 0 || cached.olderCursor === null) return;
|
|
27647
|
+
olderFlight.show(entryId);
|
|
27648
|
+
setOlderFailure(null);
|
|
27649
|
+
if (!olderFlight.begin(entryId)) return;
|
|
27650
|
+
const cursor = cached.olderCursor;
|
|
27651
|
+
const outcome = await callRemote(() => deps.detail(entryId, cursor));
|
|
27652
|
+
olderFlight.end(entryId);
|
|
27653
|
+
if ((childViewRef.current?.id ?? selectedIdRef.current) !== entryId) return;
|
|
27654
|
+
const current = detailCache.current.get(entryId);
|
|
27655
|
+
if (current !== cached) return;
|
|
27656
|
+
if (outcome.ok) {
|
|
27657
|
+
detailCache.current.set(entryId, {
|
|
27658
|
+
...current,
|
|
27659
|
+
blocks: [...outcome.value.blocks, ...current.blocks],
|
|
27660
|
+
olderCursor: outcome.value.olderCursor
|
|
27661
|
+
});
|
|
27662
|
+
setDetailVersion((version) => version + 1);
|
|
26766
27663
|
return;
|
|
26767
27664
|
}
|
|
26768
|
-
|
|
26769
|
-
|
|
26770
|
-
const outcome = await callRemote(() => deps.detail(entry.id));
|
|
26771
|
-
setLoadingId((current) => current === entry.id ? null : current);
|
|
26772
|
-
if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
|
|
26773
|
-
else setDetailFailure({
|
|
26774
|
-
entryId: entry.id,
|
|
27665
|
+
setOlderFailure({
|
|
27666
|
+
entryId,
|
|
26775
27667
|
message: outcome.failure.message
|
|
26776
27668
|
});
|
|
26777
|
-
}, [
|
|
27669
|
+
}, [
|
|
27670
|
+
deps,
|
|
27671
|
+
childView,
|
|
27672
|
+
selectedId,
|
|
27673
|
+
olderFlight
|
|
27674
|
+
]);
|
|
26778
27675
|
(0, react.useEffect)(() => {
|
|
26779
27676
|
if (entries === null || selectedId === null) return;
|
|
26780
27677
|
if (entries.some((entry) => entry.id === selectedId)) return;
|
|
26781
27678
|
detailCache.current.delete(selectedId);
|
|
26782
27679
|
setSelectedId(null);
|
|
27680
|
+
setChildView(null);
|
|
27681
|
+
olderFlight.clear();
|
|
26783
27682
|
setDrilled(false);
|
|
26784
|
-
|
|
27683
|
+
setOlderFailure(null);
|
|
27684
|
+
}, [
|
|
27685
|
+
entries,
|
|
27686
|
+
selectedId,
|
|
27687
|
+
setSelectedId,
|
|
27688
|
+
olderFlight
|
|
27689
|
+
]);
|
|
26785
27690
|
(0, react.useEffect)(() => {
|
|
26786
27691
|
if (canAutoSelect && drilled) setDrilled(false);
|
|
26787
27692
|
}, [canAutoSelect, drilled]);
|
|
26788
27693
|
(0, react.useEffect)(() => {
|
|
26789
|
-
|
|
27694
|
+
const first = ordered[0];
|
|
27695
|
+
if (canAutoSelect && selectedId === null && first !== void 0) select(first);
|
|
26790
27696
|
}, [
|
|
26791
27697
|
canAutoSelect,
|
|
26792
27698
|
selectedId,
|
|
@@ -26798,6 +27704,34 @@ window.__ModuleLoader__.load({
|
|
|
26798
27704
|
* 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
|
|
26799
27705
|
*/
|
|
26800
27706
|
const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
|
|
27707
|
+
/** 详情现在画谁:孩子视图开着就是孩子,否则是清单选中那条。孩子不在清单里,
|
|
27708
|
+
* 所以它是离清单的导航态——selectedId 不动,清场/自动选中/组动作全部照旧。 */
|
|
27709
|
+
const subject = childView ?? selected;
|
|
27710
|
+
/**
|
|
27711
|
+
* 详情的导航面(ADR-0029 单级判据的唯一实现,住控制器):
|
|
27712
|
+
* - 孩子视图没开(主体是清单选中那条)⇒ `onOpenChild` 在场、`parentContext` 缺席。
|
|
27713
|
+
* 在孩子视图里再开一层会造出第二套导航路径,还会让那颗返回指着孙辈说话(更深一代走清单可达,
|
|
27714
|
+
* 判据住 `src/trash/groups.ts`),所以入口只在这半边交出。
|
|
27715
|
+
* - 孩子视图开着 ⇒ `onOpenChild` 缺席、`parentContext` 交回父:title 是**选中行的原文**
|
|
27716
|
+
* (未命名兜底由视图按 t 处理,控制器不碰翻译),onBack 收孩子视图。
|
|
27717
|
+
* - `selected` 为 null(清单换版把父行收走的那一帧,清场 effect 随后也把孩子视图收掉)
|
|
27718
|
+
* ⇒ `parentContext` 缺席:返回键不能指着一个不存在的父。
|
|
27719
|
+
*/
|
|
27720
|
+
const detailNav = (0, react.useMemo)(() => {
|
|
27721
|
+
if (childView === null) return { onOpenChild: (child) => {
|
|
27722
|
+
openChild(child);
|
|
27723
|
+
} };
|
|
27724
|
+
if (selected === null) return {};
|
|
27725
|
+
return { parentContext: {
|
|
27726
|
+
title: selected.title,
|
|
27727
|
+
onBack: closeChild
|
|
27728
|
+
} };
|
|
27729
|
+
}, [
|
|
27730
|
+
childView,
|
|
27731
|
+
selected,
|
|
27732
|
+
openChild,
|
|
27733
|
+
closeChild
|
|
27734
|
+
]);
|
|
26801
27735
|
/** 恢复确认框要摆的这一批:按勾选顺序,清单里已经找不到的 id 滤掉
|
|
26802
27736
|
* (别的窗口刚动过;重读会把勾选也剔掉)。单条与整站确认框不摆条目,各自念自己的那一句。 */
|
|
26803
27737
|
const pendingEntries = (0, react.useMemo)(() => {
|
|
@@ -26806,39 +27740,58 @@ window.__ModuleLoader__.load({
|
|
|
26806
27740
|
return pending.ids.map((id) => byId.get(id)).filter((entry) => entry !== void 0);
|
|
26807
27741
|
}, [entries, pending]);
|
|
26808
27742
|
const detail = (0, react.useMemo)(() => {
|
|
26809
|
-
if (
|
|
26810
|
-
if (
|
|
27743
|
+
if (subject === null) return { kind: "idle" };
|
|
27744
|
+
if (detailShown === subject.id) return {
|
|
26811
27745
|
kind: "loading",
|
|
26812
|
-
entry:
|
|
27746
|
+
entry: subject
|
|
26813
27747
|
};
|
|
26814
|
-
const cached = detailCache.current.get(
|
|
27748
|
+
const cached = detailCache.current.get(subject.id);
|
|
26815
27749
|
if (cached !== void 0) return {
|
|
26816
27750
|
kind: "ready",
|
|
26817
|
-
entry:
|
|
27751
|
+
entry: subject,
|
|
26818
27752
|
detail: cached
|
|
26819
27753
|
};
|
|
26820
|
-
if (detailFailure?.entryId ===
|
|
27754
|
+
if (detailFailure?.entryId === subject.id) return {
|
|
26821
27755
|
kind: "failed",
|
|
26822
|
-
entry:
|
|
27756
|
+
entry: subject,
|
|
26823
27757
|
message: detailFailure.message
|
|
26824
27758
|
};
|
|
26825
27759
|
return {
|
|
26826
27760
|
kind: "loading",
|
|
26827
|
-
entry:
|
|
27761
|
+
entry: subject
|
|
26828
27762
|
};
|
|
26829
27763
|
}, [
|
|
26830
|
-
|
|
26831
|
-
|
|
26832
|
-
detailFailure
|
|
27764
|
+
subject,
|
|
27765
|
+
detailShown,
|
|
27766
|
+
detailFailure,
|
|
27767
|
+
detailVersion
|
|
26833
27768
|
]);
|
|
27769
|
+
/** 分页/失败都只对当前详情主体上屏(孩子视图开着就是孩子);切到别的条目时它们是别人家的旧账。 */
|
|
27770
|
+
const subjectId = subject?.id ?? null;
|
|
27771
|
+
const loadingOlder = subjectId !== null && olderShown === subjectId;
|
|
27772
|
+
const olderFailure = olderFailureState !== null && olderFailureState.entryId === subjectId ? olderFailureState.message : null;
|
|
26834
27773
|
/** 「重试」只重读当前这一条,不牵清单。 */
|
|
26835
27774
|
const retry = (0, react.useCallback)(() => {
|
|
27775
|
+
if (childView !== null) {
|
|
27776
|
+
ensureDetail(childView.id);
|
|
27777
|
+
return;
|
|
27778
|
+
}
|
|
26836
27779
|
if (selected !== null) select(selected);
|
|
26837
|
-
}, [
|
|
26838
|
-
|
|
27780
|
+
}, [
|
|
27781
|
+
childView,
|
|
27782
|
+
ensureDetail,
|
|
27783
|
+
selected,
|
|
27784
|
+
select
|
|
27785
|
+
]);
|
|
27786
|
+
/** 窄档返回/Esc 的两程:孩子视图开着先回父详情(仍下钻),再按一次才退出下钻。
|
|
27787
|
+
* 选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
|
|
26839
27788
|
const back = (0, react.useCallback)(() => {
|
|
27789
|
+
if (childView !== null) {
|
|
27790
|
+
closeChild();
|
|
27791
|
+
return;
|
|
27792
|
+
}
|
|
26840
27793
|
setDrilled(false);
|
|
26841
|
-
}, []);
|
|
27794
|
+
}, [childView, closeChild]);
|
|
26842
27795
|
const request = (0, react.useCallback)((action) => {
|
|
26843
27796
|
setPending(action);
|
|
26844
27797
|
}, []);
|
|
@@ -26856,38 +27809,51 @@ window.__ModuleLoader__.load({
|
|
|
26856
27809
|
const clearDetailCache = (0, react.useCallback)((successIds) => {
|
|
26857
27810
|
for (const id of successIds) detailCache.current.delete(id);
|
|
26858
27811
|
}, []);
|
|
27812
|
+
/** 清掉这些行的旧失败;没有任何一行挂着失败时原样返回,不触发多余的一拍。 */
|
|
27813
|
+
const clearRowFailures = (0, react.useCallback)((ids) => {
|
|
27814
|
+
setActionFailure((current) => {
|
|
27815
|
+
let changed = false;
|
|
27816
|
+
const next = { ...current };
|
|
27817
|
+
for (const id of ids) if (Object.hasOwn(next, id)) {
|
|
27818
|
+
delete next[id];
|
|
27819
|
+
changed = true;
|
|
27820
|
+
}
|
|
27821
|
+
return changed ? next : current;
|
|
27822
|
+
});
|
|
27823
|
+
}, []);
|
|
26859
27824
|
/**
|
|
26860
27825
|
* 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把整趟交给面板恢复座位。
|
|
26861
27826
|
* 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
|
|
26862
27827
|
* 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
|
|
26863
|
-
* 「重读也在这趟里」由 `runWrite`
|
|
27828
|
+
* 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次——再点那一次
|
|
27829
|
+
* 被同步挡回、整趟不跑(座位交回 `undefined`),这一行保持原样,等在飞那一趟的结论。
|
|
26864
27830
|
* 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
|
|
26865
27831
|
* 座位的 refresh 仍按 `WriteOutcome` 给(那只信封里没有附件读数要转交):这里只把它换成"重读"这一个动作,
|
|
26866
|
-
* outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021
|
|
27832
|
+
* outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。座位字段仍叫 `refreshAfterWrite`
|
|
27833
|
+
* (`write-seats.ts` 的信封接口,写座位自己的名字),与清单重读已合一的旧双名无关。
|
|
26867
27834
|
*/
|
|
26868
27835
|
const restore = (0, react.useCallback)(async (entry) => {
|
|
26869
|
-
|
|
26870
|
-
|
|
26871
|
-
delete next[entry.id];
|
|
26872
|
-
return next;
|
|
26873
|
-
});
|
|
26874
|
-
const failure = (await restoreFromPanel({
|
|
27836
|
+
clearRowFailures([entry.id]);
|
|
27837
|
+
const outcome = await restoreFromPanel({
|
|
26875
27838
|
deps,
|
|
26876
27839
|
ids: [entry.id],
|
|
26877
27840
|
single: true,
|
|
26878
27841
|
entries,
|
|
26879
27842
|
gate,
|
|
26880
|
-
refreshAfterWrite: () =>
|
|
27843
|
+
refreshAfterWrite: () => reload(),
|
|
26881
27844
|
clearDetailCache
|
|
26882
|
-
})
|
|
27845
|
+
});
|
|
27846
|
+
if (outcome === void 0) return;
|
|
27847
|
+
const failure = outcome.failures.get(entry.id);
|
|
26883
27848
|
if (failure !== void 0) failRow(entry.id, failure);
|
|
26884
27849
|
}, [
|
|
26885
27850
|
clearDetailCache,
|
|
27851
|
+
clearRowFailures,
|
|
26886
27852
|
deps,
|
|
26887
27853
|
entries,
|
|
26888
27854
|
failRow,
|
|
26889
27855
|
gate,
|
|
26890
|
-
|
|
27856
|
+
reload
|
|
26891
27857
|
]);
|
|
26892
27858
|
/**
|
|
26893
27859
|
* 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
|
|
@@ -26895,10 +27861,11 @@ window.__ModuleLoader__.load({
|
|
|
26895
27861
|
* 不能断言哪几条落地:原文保守地铺到每一个 id(别让清单看起来"删成功了"),清单照样重读。
|
|
26896
27862
|
*/
|
|
26897
27863
|
const confirmApply = (0, react.useCallback)(async (action, ids) => {
|
|
27864
|
+
clearRowFailures(ids);
|
|
26898
27865
|
const settle = (outcome) => {
|
|
26899
27866
|
setPending(null);
|
|
26900
27867
|
if (action === "purge") noteAttachmentCleanup(outcome.ok ? outcome.summary.attachmentCleanup : void 0);
|
|
26901
|
-
return
|
|
27868
|
+
return reload();
|
|
26902
27869
|
};
|
|
26903
27870
|
const seat = {
|
|
26904
27871
|
deps,
|
|
@@ -26911,6 +27878,7 @@ window.__ModuleLoader__.load({
|
|
|
26911
27878
|
...seat,
|
|
26912
27879
|
entries
|
|
26913
27880
|
});
|
|
27881
|
+
if (outcome === void 0) return;
|
|
26914
27882
|
if (outcome.failures.size === 0) return;
|
|
26915
27883
|
setActionFailure((current) => {
|
|
26916
27884
|
const next = { ...current };
|
|
@@ -26919,10 +27887,11 @@ window.__ModuleLoader__.load({
|
|
|
26919
27887
|
});
|
|
26920
27888
|
}, [
|
|
26921
27889
|
clearDetailCache,
|
|
27890
|
+
clearRowFailures,
|
|
26922
27891
|
deps,
|
|
26923
27892
|
entries,
|
|
26924
27893
|
gate,
|
|
26925
|
-
|
|
27894
|
+
reload,
|
|
26926
27895
|
noteAttachmentCleanup
|
|
26927
27896
|
]);
|
|
26928
27897
|
/**
|
|
@@ -26937,6 +27906,7 @@ window.__ModuleLoader__.load({
|
|
|
26937
27906
|
if (target.kind === "entries") return confirmApply("purge", target.ids);
|
|
26938
27907
|
if (target.kind === "restore") return confirmApply("restore", target.ids);
|
|
26939
27908
|
const emptied = await emptyTrash();
|
|
27909
|
+
if (emptied === void 0) return;
|
|
26940
27910
|
setPending(null);
|
|
26941
27911
|
if (emptied) detailCache.current.clear();
|
|
26942
27912
|
}, [
|
|
@@ -26948,12 +27918,19 @@ window.__ModuleLoader__.load({
|
|
|
26948
27918
|
busy: gate.busy,
|
|
26949
27919
|
selected,
|
|
26950
27920
|
selectedId,
|
|
27921
|
+
childView,
|
|
27922
|
+
detailNav,
|
|
27923
|
+
openChild,
|
|
27924
|
+
closeChild,
|
|
26951
27925
|
drilled,
|
|
26952
27926
|
detail,
|
|
27927
|
+
loadingOlder,
|
|
27928
|
+
olderFailure,
|
|
26953
27929
|
pending,
|
|
26954
27930
|
pendingEntries,
|
|
26955
27931
|
actionFailure,
|
|
26956
27932
|
select,
|
|
27933
|
+
loadOlder,
|
|
26957
27934
|
retry,
|
|
26958
27935
|
back,
|
|
26959
27936
|
request,
|
|
@@ -27091,6 +28068,9 @@ window.__ModuleLoader__.load({
|
|
|
27091
28068
|
const HANDLE_STEP = 20;
|
|
27092
28069
|
/** 详情正文的收口宽度:中央列再宽也不把文字拉成长线。 */
|
|
27093
28070
|
const DETAIL_TEXT_MAX_WIDTH = 860;
|
|
28071
|
+
/** 清单 grid 的列数:条目行恒为两格(常态:内容 + 动作;多选态:勾选框占内容前的一格、动作列退场);
|
|
28072
|
+
* 组头只有一格,用 colspan 横跨整行——各行列数一致是 grid 的播报契约(ADR-0028)。 */
|
|
28073
|
+
const LIST_COLUMN_COUNT = 2;
|
|
27094
28074
|
/**
|
|
27095
28075
|
* 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
|
|
27096
28076
|
*
|
|
@@ -27177,6 +28157,15 @@ window.__ModuleLoader__.load({
|
|
|
27177
28157
|
unit: unitWord(unit, t)
|
|
27178
28158
|
});
|
|
27179
28159
|
}
|
|
28160
|
+
/**
|
|
28161
|
+
* 行的 DOM id。entry id 里可能带空白/引号(目录名只挡路径分隔符),直接拼进 HTML id 是非法值、
|
|
28162
|
+
* `aria-activedescendant` 也会失配——所以**在 DOM 这一侧编码**(issue #8 第 7 条):`data-entry-id`
|
|
28163
|
+
* 仍留原始 id,焦点交还按属性值比对不受影响。entry id 本身不许改形:它是回收站目录名,
|
|
28164
|
+
* 恢复要按它反推原目录名放回(`trash/entry.ts` 的往返契约)。
|
|
28165
|
+
*/
|
|
28166
|
+
function domRowId(entryId) {
|
|
28167
|
+
return `sd-trash-${encodeURIComponent(entryId)}`;
|
|
28168
|
+
}
|
|
27180
28169
|
function TrashPanel({ deps, t = zhTranslate }) {
|
|
27181
28170
|
/** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住 `busy-gate.ts`,查询侧的写动作也吃它。 */
|
|
27182
28171
|
const gate = useBusyGate();
|
|
@@ -27185,11 +28174,13 @@ window.__ModuleLoader__.load({
|
|
|
27185
28174
|
t,
|
|
27186
28175
|
gate
|
|
27187
28176
|
});
|
|
27188
|
-
const { list: listState,
|
|
28177
|
+
const { list: listState, reload } = query;
|
|
27189
28178
|
const view = listView(listState);
|
|
27190
28179
|
const entries = view.rows;
|
|
27191
28180
|
const listRef = (0, react.useRef)(null);
|
|
27192
28181
|
const rootRef = (0, react.useRef)(null);
|
|
28182
|
+
/** 详情栏的滚动容器:分页 prepend 后按高度差补偿滚动位置(归 `trash-detail.tsx` 消费)。 */
|
|
28183
|
+
const detailScrollRef = (0, react.useRef)(null);
|
|
27193
28184
|
const width = usePanelWidth(rootRef);
|
|
27194
28185
|
/**
|
|
27195
28186
|
* 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
|
|
@@ -27211,9 +28202,16 @@ window.__ModuleLoader__.load({
|
|
|
27211
28202
|
entries,
|
|
27212
28203
|
groups
|
|
27213
28204
|
});
|
|
27214
|
-
/** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。
|
|
28205
|
+
/** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。
|
|
28206
|
+
* 按属性值自己比,不把 `entryId` 拼进选择器:id 只挡路径分隔符(`trash/entry.ts`),另一半是
|
|
28207
|
+
* 宿主给的目录名,带引号时选择器直接抛。 */
|
|
27215
28208
|
const focusRow = (0, react.useCallback)((entryId) => {
|
|
27216
|
-
|
|
28209
|
+
const rows = listRef.current?.querySelectorAll("[data-entry-id]");
|
|
28210
|
+
if (rows === void 0) return;
|
|
28211
|
+
for (const row of rows) if (row.getAttribute("data-entry-id") === entryId) {
|
|
28212
|
+
row.focus();
|
|
28213
|
+
return;
|
|
28214
|
+
}
|
|
27217
28215
|
}, []);
|
|
27218
28216
|
/**
|
|
27219
28217
|
* 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / 写动作与失败落点,整块住在
|
|
@@ -27240,7 +28238,7 @@ window.__ModuleLoader__.load({
|
|
|
27240
28238
|
drilled: controller.drilled,
|
|
27241
28239
|
select: controller.select,
|
|
27242
28240
|
toggleCheck,
|
|
27243
|
-
|
|
28241
|
+
back: controller.back,
|
|
27244
28242
|
focusRow
|
|
27245
28243
|
});
|
|
27246
28244
|
const pending = controller.pending;
|
|
@@ -27256,14 +28254,19 @@ window.__ModuleLoader__.load({
|
|
|
27256
28254
|
/**
|
|
27257
28255
|
* 清单容器(roving tabindex 的唯一 tab 巢)。`flex:'1 1 auto'` 撑起空态居中的父链;
|
|
27258
28256
|
* `min-height:auto` 让长清单保持内容高,滚动交给外层。
|
|
28257
|
+
*
|
|
28258
|
+
* ARIA 模式是 **grid** 而不是 listbox(为什么:裁决见 ADR-0028)。只有真有行时才挂 grid
|
|
28259
|
+
* 与它的 aria:失败/加载/空态是一块普通内容,不是「空的 grid」,generic div 不背 aria 名字。
|
|
27259
28260
|
*/
|
|
28261
|
+
const gridActive = view.failure === null && hasEntries;
|
|
27260
28262
|
const list = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
27261
28263
|
ref: listRef,
|
|
27262
28264
|
"data-dsh-session-delete": "trash-list",
|
|
27263
|
-
role: "
|
|
27264
|
-
"aria-label": t("detail.list.label"),
|
|
27265
|
-
"aria-
|
|
27266
|
-
"aria-
|
|
28265
|
+
role: gridActive ? "grid" : void 0,
|
|
28266
|
+
"aria-label": gridActive ? t("detail.list.label") : void 0,
|
|
28267
|
+
"aria-colcount": gridActive ? LIST_COLUMN_COUNT : void 0,
|
|
28268
|
+
"aria-activedescendant": gridActive && nav.activeDescendantId !== null ? domRowId(nav.activeDescendantId) : void 0,
|
|
28269
|
+
"aria-multiselectable": gridActive && selectMode ? true : void 0,
|
|
27267
28270
|
tabIndex: -1,
|
|
27268
28271
|
onKeyDown: nav.onListKeyDown,
|
|
27269
28272
|
style: {
|
|
@@ -27320,7 +28323,7 @@ window.__ModuleLoader__.load({
|
|
|
27320
28323
|
variant: "outline",
|
|
27321
28324
|
size: "sm",
|
|
27322
28325
|
style: { flex: "none" },
|
|
27323
|
-
onClick: () => void
|
|
28326
|
+
onClick: () => void reload(),
|
|
27324
28327
|
children: t("common.retry")
|
|
27325
28328
|
})
|
|
27326
28329
|
]
|
|
@@ -27376,6 +28379,7 @@ window.__ModuleLoader__.load({
|
|
|
27376
28379
|
})
|
|
27377
28380
|
]
|
|
27378
28381
|
}) : groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
28382
|
+
role: "rowgroup",
|
|
27379
28383
|
"data-dsh-session-delete": "trash-group",
|
|
27380
28384
|
"aria-label": group.label,
|
|
27381
28385
|
style: {
|
|
@@ -27384,7 +28388,8 @@ window.__ModuleLoader__.load({
|
|
|
27384
28388
|
gap: 8,
|
|
27385
28389
|
minWidth: 0
|
|
27386
28390
|
},
|
|
27387
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
28391
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
28392
|
+
role: "row",
|
|
27388
28393
|
"data-dsh-session-delete": "trash-group-row",
|
|
27389
28394
|
style: {
|
|
27390
28395
|
display: "flex",
|
|
@@ -27392,71 +28397,82 @@ window.__ModuleLoader__.load({
|
|
|
27392
28397
|
gap: 6,
|
|
27393
28398
|
minWidth: 0
|
|
27394
28399
|
},
|
|
27395
|
-
children:
|
|
27396
|
-
|
|
27397
|
-
|
|
27398
|
-
label: t("trash.select.group", {
|
|
27399
|
-
project: group.label,
|
|
27400
|
-
count: group.entries.length
|
|
27401
|
-
}),
|
|
27402
|
-
onChange: () => {
|
|
27403
|
-
toggleGroupCheck(group);
|
|
27404
|
-
}
|
|
27405
|
-
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
27406
|
-
type: "button",
|
|
27407
|
-
className: "sd-disclosure-row",
|
|
27408
|
-
"data-dsh-session-delete": "trash-group-head",
|
|
27409
|
-
"data-expandable": "",
|
|
27410
|
-
"aria-expanded": !isCollapsed(group.projectDir),
|
|
27411
|
-
onClick: () => {
|
|
27412
|
-
toggleGroup(group.projectDir);
|
|
27413
|
-
},
|
|
28400
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
28401
|
+
role: "gridcell",
|
|
28402
|
+
"aria-colspan": LIST_COLUMN_COUNT,
|
|
27414
28403
|
style: {
|
|
27415
|
-
|
|
27416
|
-
|
|
27417
|
-
|
|
27418
|
-
font: "inherit",
|
|
27419
|
-
textAlign: "start",
|
|
28404
|
+
display: "flex",
|
|
28405
|
+
alignItems: "center",
|
|
28406
|
+
gap: 6,
|
|
27420
28407
|
flex: "1 1 auto",
|
|
27421
|
-
|
|
28408
|
+
minWidth: 0
|
|
27422
28409
|
},
|
|
27423
|
-
children: [
|
|
27424
|
-
|
|
27425
|
-
|
|
27426
|
-
|
|
27427
|
-
|
|
27428
|
-
|
|
27429
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
27430
|
-
style: {
|
|
27431
|
-
display: "inline-flex",
|
|
27432
|
-
flex: "none",
|
|
27433
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
27434
|
-
},
|
|
27435
|
-
"aria-hidden": "true",
|
|
27436
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
|
|
28410
|
+
children: [selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
28411
|
+
checked: groupFullyChecked(group),
|
|
28412
|
+
indeterminate: groupPartiallyChecked(group),
|
|
28413
|
+
label: t("trash.select.group", {
|
|
28414
|
+
project: group.label,
|
|
28415
|
+
count: group.entries.length
|
|
27437
28416
|
}),
|
|
27438
|
-
|
|
27439
|
-
|
|
27440
|
-
|
|
27441
|
-
|
|
27442
|
-
|
|
27443
|
-
|
|
27444
|
-
|
|
27445
|
-
|
|
27446
|
-
|
|
27447
|
-
|
|
27448
|
-
|
|
27449
|
-
|
|
27450
|
-
|
|
27451
|
-
|
|
27452
|
-
|
|
27453
|
-
|
|
27454
|
-
|
|
27455
|
-
|
|
27456
|
-
|
|
27457
|
-
|
|
27458
|
-
|
|
27459
|
-
|
|
28417
|
+
onChange: () => {
|
|
28418
|
+
toggleGroupCheck(group);
|
|
28419
|
+
}
|
|
28420
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
28421
|
+
type: "button",
|
|
28422
|
+
className: "sd-disclosure-row",
|
|
28423
|
+
"data-dsh-session-delete": "trash-group-head",
|
|
28424
|
+
"data-expandable": "",
|
|
28425
|
+
"aria-expanded": !isCollapsed(group.projectDir),
|
|
28426
|
+
onClick: () => {
|
|
28427
|
+
toggleGroup(group.projectDir);
|
|
28428
|
+
},
|
|
28429
|
+
style: {
|
|
28430
|
+
background: "none",
|
|
28431
|
+
border: "none",
|
|
28432
|
+
padding: 0,
|
|
28433
|
+
font: "inherit",
|
|
28434
|
+
textAlign: "start",
|
|
28435
|
+
flex: "1 1 auto",
|
|
28436
|
+
gap: 6
|
|
28437
|
+
},
|
|
28438
|
+
children: [
|
|
28439
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
28440
|
+
className: "sd-disclosure-leading",
|
|
28441
|
+
"aria-hidden": "true",
|
|
28442
|
+
children: isCollapsed(group.projectDir) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 })
|
|
28443
|
+
}),
|
|
28444
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
28445
|
+
style: {
|
|
28446
|
+
display: "inline-flex",
|
|
28447
|
+
flex: "none",
|
|
28448
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
28449
|
+
},
|
|
28450
|
+
"aria-hidden": "true",
|
|
28451
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
|
|
28452
|
+
}),
|
|
28453
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
28454
|
+
className: "sd-disclosure-title",
|
|
28455
|
+
style: {
|
|
28456
|
+
...ELLIPSIS,
|
|
28457
|
+
minWidth: 0,
|
|
28458
|
+
flex: "1 1 auto",
|
|
28459
|
+
fontWeight: 500
|
|
28460
|
+
},
|
|
28461
|
+
title: group.projectDir,
|
|
28462
|
+
children: group.label
|
|
28463
|
+
}),
|
|
28464
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
28465
|
+
style: {
|
|
28466
|
+
marginLeft: "auto",
|
|
28467
|
+
flex: "none",
|
|
28468
|
+
fontSize: 11.5,
|
|
28469
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
28470
|
+
},
|
|
28471
|
+
children: t("trash.group.count", { count: group.entries.length })
|
|
28472
|
+
})
|
|
28473
|
+
]
|
|
28474
|
+
})]
|
|
28475
|
+
})
|
|
27460
28476
|
}), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
|
|
27461
28477
|
const isSelected = entry.id === controller.selectedId;
|
|
27462
28478
|
const isChecked = checked.has(entry.id);
|
|
@@ -27464,8 +28480,8 @@ window.__ModuleLoader__.load({
|
|
|
27464
28480
|
const kids = entry.subagentCount ?? 0;
|
|
27465
28481
|
const stranded = entry.strandedSubagents ?? 0;
|
|
27466
28482
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
27467
|
-
id:
|
|
27468
|
-
role: "
|
|
28483
|
+
id: domRowId(entry.id),
|
|
28484
|
+
role: "row",
|
|
27469
28485
|
"aria-selected": selectMode ? isChecked : isSelected,
|
|
27470
28486
|
tabIndex: entry.id === nav.tabStopId ? 0 : -1,
|
|
27471
28487
|
"data-dsh-session-delete": "trash-row",
|
|
@@ -27479,14 +28495,22 @@ window.__ModuleLoader__.load({
|
|
|
27479
28495
|
controller.select(entry, { drill: !split });
|
|
27480
28496
|
},
|
|
27481
28497
|
children: [
|
|
27482
|
-
selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
27483
|
-
|
|
27484
|
-
|
|
27485
|
-
|
|
27486
|
-
|
|
27487
|
-
}
|
|
28498
|
+
selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
28499
|
+
role: "gridcell",
|
|
28500
|
+
style: {
|
|
28501
|
+
display: "flex",
|
|
28502
|
+
alignItems: "center"
|
|
28503
|
+
},
|
|
28504
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
28505
|
+
checked: isChecked,
|
|
28506
|
+
label: t("trash.select.row", { title }),
|
|
28507
|
+
onChange: () => {
|
|
28508
|
+
toggleCheck(entry.id);
|
|
28509
|
+
}
|
|
28510
|
+
})
|
|
27488
28511
|
}) : null,
|
|
27489
28512
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
28513
|
+
role: "gridcell",
|
|
27490
28514
|
style: {
|
|
27491
28515
|
display: "flex",
|
|
27492
28516
|
flexDirection: "column",
|
|
@@ -27551,6 +28575,7 @@ window.__ModuleLoader__.load({
|
|
|
27551
28575
|
]
|
|
27552
28576
|
}),
|
|
27553
28577
|
selectMode ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
28578
|
+
role: "gridcell",
|
|
27554
28579
|
style: {
|
|
27555
28580
|
display: "flex",
|
|
27556
28581
|
gap: 6,
|
|
@@ -27586,7 +28611,12 @@ window.__ModuleLoader__.load({
|
|
|
27586
28611
|
})]
|
|
27587
28612
|
}, group.projectDir))
|
|
27588
28613
|
});
|
|
28614
|
+
/**
|
|
28615
|
+
* 孩子视图的开与回(ADR-0029 单级判据)住控制器的 `detailNav`:这里只转交,不再自己拼
|
|
28616
|
+
* openChild / parentContext 两个派生块——判据的第二个家会让视图与测试各自复述一遍语义。
|
|
28617
|
+
*/
|
|
27589
28618
|
const detailPane = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
28619
|
+
ref: detailScrollRef,
|
|
27590
28620
|
"data-dsh-session-delete": "trash-detail",
|
|
27591
28621
|
style: DETAIL_COLUMN,
|
|
27592
28622
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -27597,6 +28627,15 @@ window.__ModuleLoader__.load({
|
|
|
27597
28627
|
wide: split,
|
|
27598
28628
|
onRetry: () => controller.retry(),
|
|
27599
28629
|
onBack: () => controller.back(),
|
|
28630
|
+
detailNav: controller.detailNav,
|
|
28631
|
+
pager: {
|
|
28632
|
+
loadingOlder: controller.loadingOlder,
|
|
28633
|
+
olderFailure: controller.olderFailure,
|
|
28634
|
+
onLoadOlder: () => {
|
|
28635
|
+
controller.loadOlder();
|
|
28636
|
+
},
|
|
28637
|
+
scrollRef: detailScrollRef
|
|
28638
|
+
},
|
|
27600
28639
|
t
|
|
27601
28640
|
})
|
|
27602
28641
|
})
|
|
@@ -27840,6 +28879,7 @@ window.__ModuleLoader__.load({
|
|
|
27840
28879
|
children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
27841
28880
|
type: "button",
|
|
27842
28881
|
className: "sd-toast-action",
|
|
28882
|
+
disabled: action.disabled,
|
|
27843
28883
|
onClick: action.onClick,
|
|
27844
28884
|
children: action.label
|
|
27845
28885
|
}, action.label))]
|
|
@@ -27851,31 +28891,12 @@ window.__ModuleLoader__.load({
|
|
|
27851
28891
|
/**
|
|
27852
28892
|
* 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
|
|
27853
28893
|
*
|
|
27854
|
-
* 现场与发布住在 `undo-notices.ts
|
|
28894
|
+
* 现场与发布住在 `undo-notices.ts`;本文件只做组件:画通知台已有的事实,「撤销」整趟按下交给
|
|
28895
|
+
* `write-seats.ts` 的 `restoreFromUndo`(编排归恢复座位,见 ADR-0018 的边界)。
|
|
27855
28896
|
* 为什么组件要独立:宿主的菜单行随菜单**一起卸载**(真产物 Menu 的注释原文
|
|
27856
28897
|
* "the rows unmount with the list"),而提示要在菜单关掉之后还活着。宿主的官方配方同形——
|
|
27857
28898
|
* 会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的 `workspace.row-toast` 读它。
|
|
27858
28899
|
*/
|
|
27859
|
-
/** 撤销:单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
|
|
27860
|
-
* null 是协议错);失败就把原因换到同一条横幅上,不静默。 */
|
|
27861
|
-
async function undo(deps, current) {
|
|
27862
|
-
const ids = current.kind === "one" ? [current.entryId] : current.entryIds;
|
|
27863
|
-
if (current.kind === "one" && current.entryId === "") return;
|
|
27864
|
-
const outcome = await restoreFromUndo(deps, {
|
|
27865
|
-
ids,
|
|
27866
|
-
single: current.kind === "one"
|
|
27867
|
-
});
|
|
27868
|
-
if (!outcome.ok) {
|
|
27869
|
-
failNotice(current, outcome.failure.message);
|
|
27870
|
-
return;
|
|
27871
|
-
}
|
|
27872
|
-
const failure = outcome.failures.values().next().value;
|
|
27873
|
-
if (failure !== void 0) {
|
|
27874
|
-
failNotice(current, failure);
|
|
27875
|
-
return;
|
|
27876
|
-
}
|
|
27877
|
-
if (ownsNotice(current)) clearUndoNotice();
|
|
27878
|
-
}
|
|
27879
28900
|
/**
|
|
27880
28901
|
* 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
|
|
27881
28902
|
* pill all belong here"):没有待撤销的事时什么都不渲染。
|
|
@@ -27883,6 +28904,15 @@ window.__ModuleLoader__.load({
|
|
|
27883
28904
|
function UndoToast({ deps, t = zhTranslate }) {
|
|
27884
28905
|
const current = useUndoNotice();
|
|
27885
28906
|
if (current === null) return null;
|
|
28907
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeToast, {
|
|
28908
|
+
deps,
|
|
28909
|
+
current,
|
|
28910
|
+
t
|
|
28911
|
+
}, current.seq);
|
|
28912
|
+
}
|
|
28913
|
+
/** 一条通知的躯体:busy 生命周期归 `useBusyGate`(连点的同步判重也在闸里),这里只组合呈现。 */
|
|
28914
|
+
function NoticeToast({ deps, current, t }) {
|
|
28915
|
+
const gate = useBusyGate();
|
|
27886
28916
|
const { seq, failure } = current;
|
|
27887
28917
|
if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
|
|
27888
28918
|
text: failure,
|
|
@@ -27897,7 +28927,10 @@ window.__ModuleLoader__.load({
|
|
|
27897
28927
|
holdMs: UNDO_HOLD_MS,
|
|
27898
28928
|
actions: canUndo ? [{
|
|
27899
28929
|
label: t("undo.undo"),
|
|
27900
|
-
|
|
28930
|
+
disabled: gate.busy,
|
|
28931
|
+
onClick: () => {
|
|
28932
|
+
gate.around(() => restoreFromUndo(deps, current, t));
|
|
28933
|
+
}
|
|
27901
28934
|
}] : void 0,
|
|
27902
28935
|
onDone: clearUndoNotice
|
|
27903
28936
|
}, seq);
|
|
@@ -27977,10 +29010,13 @@ window.__ModuleLoader__.load({
|
|
|
27977
29010
|
}
|
|
27978
29011
|
/** 多选模式行首勾选框的宿主:槽位只投 `sessionId`,标题从 root hook 的 `byId[id].title` 读。 */
|
|
27979
29012
|
function PickLeadingRow({ sessionId, useSessions, t }) {
|
|
27980
|
-
const
|
|
29013
|
+
const select = typeof useSessions === "function" ? useSessions : NEVER_SESSIONS;
|
|
29014
|
+
const title = select((state) => state?.byId?.[sessionId]?.title?.trim() ?? "");
|
|
29015
|
+
const live = select((state) => state?.byId?.[sessionId]?.running === true);
|
|
27981
29016
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatchPickCheckbox, {
|
|
27982
29017
|
sessionId,
|
|
27983
29018
|
title,
|
|
29019
|
+
live,
|
|
27984
29020
|
t
|
|
27985
29021
|
});
|
|
27986
29022
|
}
|
|
@@ -28029,10 +29065,12 @@ window.__ModuleLoader__.load({
|
|
|
28029
29065
|
/**
|
|
28030
29066
|
* **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
|
|
28031
29067
|
*
|
|
28032
|
-
* - 探测不过(打不通 /
|
|
28033
|
-
*
|
|
28034
|
-
*
|
|
28035
|
-
*
|
|
29068
|
+
* - 探测不过(打不通 / 回的不是信封 / 401·403 表示没有宿主身份 / 失败信封)⇒ **早返回**:
|
|
29069
|
+
* 一个槽位贡献都不注册。页面上的表现是"本插件不存在",而不是点下去必然失败的入口——半吊子 UI
|
|
29070
|
+
* 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
|
|
29071
|
+
* - 探测过(`ping` 回成功信封的 `pong`)⇒ 注册:那次往返证明路由在、依赖接上了、鉴权也过了。
|
|
29072
|
+
*
|
|
29073
|
+
* 支持面是 web UI 与桌面端,二者都从宿主 `/api` 取数(ADR-0033);没有旧前缀可退回。
|
|
28036
29074
|
*
|
|
28037
29075
|
* 面板那两条是**一对座位**:行的 `selectPanel(id)` 要求 `main` 里有同字 key,缺了面板那一头,
|
|
28038
29076
|
* 用户点一下就是宿主自己的 `layout.selectPanel: main panel "…" is not registered`。
|