dsh-session-curator 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,1430 +1,2665 @@
1
- /**
2
- * dsh-session-curator — client 半区(免构建手写束)。
3
- *
4
- * 三个贡献:
5
- * 1. `sidebar.workspaces.session.menu.item`(order 500,排在官方 archive 之后):
6
- * 一行「删除会话」,点击后弹确认框,确认即软删除到回收站。
7
- * 2. `shell.overlay`:全局确认弹窗宿主(菜单项与设置面板共用)。
8
- * 3. `settings.section`(order 45):会话管理面板 —— 已归档 / 已置顶 / 回收站。
9
- *
10
- * 与宿主通信走 Connection 的共享 `/api` 通道:官方调用器
11
- * (`ctx.get('connection').rpc.call`,若该服务在客户端存在)或逐字复刻的
12
- * `fetch` 兜底,目标都是 `/api/session-curator/<endpoint>`。信任栅栏与会话鉴权
13
- * 仍由宿主 Connection 的 `/api` 前缀路由把关,插件自身无需任何鉴权代码。
14
- */
1
+ // 本文件由 `node scripts/build.mjs` 从 src/client/**/*.ts 生成,不要手改。
15
2
  window.__ModuleLoader__.load({
16
3
  id: "dsh-session-curator",
4
+ // 浏览器只认这种「工厂 + 局部 require」的束:factory 收到的 require 就是 DSH 的模块表,
5
+ // esbuild 把 external 的 import 编译成了对这个参数的调用。
17
6
  factory: (require) => {
18
- const bundleModule = { exports: {} };
19
- Object.defineProperty(bundleModule.exports, Symbol.toStringTag, { value: "Module" });
20
-
21
- const React = require("react");
22
- const {
23
- Button,
24
- Menu,
25
- MenuItemButton,
26
- Modal,
27
- SegmentedControl,
28
- Switch,
29
- IconChevronDownOutlineRegular,
30
- IconChevronRightOutlineRegular,
31
- IconEllipsisOutlineRegular,
32
- IconFolderCloseRegular,
33
- IconFolderOpenRegular,
34
- IconPinOutlineRegular,
35
- IconRefreshOutlineRegular,
36
- IconTrashOutlineRegular,
37
- IconUnarchiveOutlineRegular
38
- } = require("@deepseek-ai/dsh-client-ui-primitives");
39
- const h = React.createElement;
40
-
41
- const NS = "session-curator";
42
- /** 官方共享通道;本插件占 `/api/session-curator/<endpoint>`。 */
43
- const API_CHANNEL = "/api";
44
- const ROUTE_PREFIX = "session-curator";
45
- const MENU_ID = "session-curator.delete";
46
- const OVERLAY_ID = "session-curator.dialog";
47
- /** 本插件自己的设置页 id(也是"没得借"时的兜底)。 */
48
- const SECTION_ID = "session-curator";
49
- /**
50
- * 设置页导航栏的图标**不是注册时给的** —— 设置外壳
51
- * `@deepseek-ai/dsh-client-ui-settings-general` 的 `navIcon(id)` 是按 **section id 查表**:
52
- *
53
- * ```js
54
- * account → 人像 / models → 数据 / agent-presets → 预设 / plugins → 个性化
55
- * archived-sessions → 归档盒子 / 其它一律 → 齿轮(默认)
56
- * ```
57
- *
58
- * 我们的 `session-curator` 不在表里,所以一直是"默认齿轮"(`settings.section` 的
59
- * options 只读 id / order / label,外壳不看 icon 字段,插件没法自带导航图标)。
60
- * 表里**唯一没人用**的 id 是 `archived-sessions`,它映射到归档盒子图标 —— 语义刚好贴合
61
- * 本插件(归档 / 回收站管理),所以借用它。
62
- */
63
- const NAV_SECTION_ID = "archived-sessions";
64
-
65
- /**
66
- * 借 id 之前先看槽位账本:**一旦将来 DSH 自己注册了 `archived-sessions`,
67
- * 我们就退回自己的 id**(拿默认齿轮),绝不会跟官方撞车。
68
- */
69
- function sectionIdFor(ctx) {
70
- try {
71
- const taken = ctx.slots.entries("settings.section").some((entry) => entry?.options?.id === NAV_SECTION_ID);
72
- return taken ? SECTION_ID : NAV_SECTION_ID;
73
- } catch {
74
- // 拿不到账本(没有 entries)就退回自己的 id。
75
- return SECTION_ID;
76
- }
77
- }
78
-
79
- // ── 文案 ────────────────────────────────────────────────────────────
80
- const zh = {
81
- sectionLabel: "会话管理",
82
- menuDelete: "删除会话",
83
- titleArchived: "已归档",
84
- titlePinned: "已置顶",
85
- titleTrash: "回收站",
86
- refresh: "刷新",
87
- loading: "读取中…",
88
- retry: "重试",
89
- emptyArchived: "还没有归档的会话。在会话行的「⋯」菜单里归档后会出现在这里。",
90
- emptyPinned: "没有置顶的会话。",
91
- emptyTrash: "回收站是空的。",
92
- unarchive: "取消归档",
93
- pin: "置顶",
94
- unpin: "取消置顶",
95
- restore: "恢复",
96
- purge: "永久删除",
97
- moveToTrash: "移入回收站",
98
- rowActions: "更多操作",
99
- selectAll: "全选",
100
- selectNone: "取消选择",
101
- selected: "已选 {n} 项",
102
- emptyTrashAll: "清空回收站",
103
- total: "合计 {n} 个会话 · {size}",
104
- working: "处理中…",
105
- sessionRunning: "会话正在运行,先停止它再删除。",
106
- confirmTrashTitle: "删除会话",
107
- confirmTrashBody: "「{title}」会被移到回收站,之后可以在「设置 → 会话管理」里恢复。",
108
- confirmTrashAction: "移到回收站",
109
- confirmPurgeTitle: "永久删除",
110
- confirmPurgeBody: "「{title}」将从回收站永久删除,日志文件会被彻底移除,无法恢复。",
111
- confirmPurgeManyBody: "选中的 {n} 个会话将从回收站永久删除,无法恢复。",
112
- confirmPurgeAction: "永久删除",
113
- ackPurge: "我明白这无法撤销",
114
- cancel: "取消",
115
- close: "关闭",
116
- noProject: "(未知目录)",
117
- files: "{n} 个文件",
118
- groupingOn: "分组:工作区",
119
- groupingOff: "分组:平铺",
120
- groupHint: "按会话原来的工作区文件夹归类;点分组标题折叠,左边的勾选框选中整个工作区",
121
- sessions: "{n} 个会话",
122
- workspaces: "{n} 个工作区",
123
- selectGroup: "全选该工作区",
124
- clearGroup: "取消选中该工作区",
125
- selectAllShort: "全选",
126
- clearShort: "取消",
127
- collapse: "折叠",
128
- expand: "展开",
129
- titleConfig: "配置",
130
- configTitle: "基础设置",
131
- configTab: "默认页签",
132
- configTabHint: "打开这里时先显示哪个页签",
133
- configGrouping: "按工作区分组",
134
- configGroupingHint: "关掉就是平铺列表;顶部的「分组」按钮改的是同一个值",
135
- configSidebarArchived: "侧边栏显示已归档会话",
136
- configSidebarArchivedHint: "对应侧边栏「视图选项」里的「隐藏已归档 / 全部对话(显示已归档)」;存的是官方那份设置,改完要刷新页面才生效",
137
- configSidebarRefreshHint: "已写入官方设置,但侧边栏要刷新页面才会跟着变。",
138
- refreshPage: "刷新页面",
139
- configMeta: "行内显示体积与文件数",
140
- configMetaHint: "关掉后每行只留标题(平铺时仍会显示文件夹)",
141
- configOrder: "排序",
142
- configOrderHint: "原顺序就是宿主给的顺序(回收站里是最近删除在前)",
143
- orderHost: "原顺序",
144
- orderTitle: "标题",
145
- orderBytes: "体积",
146
- configAutoUpdate: "自动检查更新",
147
- configAutoUpdateHint: "打开这个页面时问一次 npm registry(10 分钟内有缓存)",
148
- configReset: "恢复默认",
149
- configResetDone: "已恢复默认设置",
150
- configSaved: "已保存",
151
- aboutTitle: "关于",
152
- versionLabel: "版本",
153
- updateChecking: "正在检查更新…",
154
- updateLatest: "已是最新版本",
155
- updateAvailable: "有新版本 {latest}(当前 {current})",
156
- updateFailed: "检查更新失败:{reason}",
157
- updateRetry: "重新检查",
158
- updateCheckNow: "检查更新",
159
- updateStepsTitle: "升级步骤",
160
- updateStepOne: "在终端里升级包(按你自己的 profile 名替换 desktop):",
161
- updateStepTwo: "重启 DSH 让宿主半区生效;只改了客户端半区的话刷新页面就够。",
162
- updateOpen: "打开 npm 页面"
163
- };
164
- const en = {
165
- sectionLabel: "Session manager",
166
- menuDelete: "Delete session",
167
- titleArchived: "Archived",
168
- titlePinned: "Pinned",
169
- titleTrash: "Trash",
170
- refresh: "Refresh",
171
- loading: "Loading…",
172
- retry: "Retry",
173
- emptyArchived: "No archived sessions yet. Archive one from its “⋯” menu and it shows up here.",
174
- emptyPinned: "No pinned sessions.",
175
- emptyTrash: "The trash is empty.",
176
- unarchive: "Unarchive",
177
- pin: "Pin",
178
- unpin: "Unpin",
179
- restore: "Restore",
180
- purge: "Delete permanently",
181
- moveToTrash: "Move to trash",
182
- rowActions: "More actions",
183
- selectAll: "Select all",
184
- selectNone: "Clear selection",
185
- selected: "{n} selected",
186
- emptyTrashAll: "Empty trash",
187
- total: "{n} sessions · {size}",
188
- working: "Working…",
189
- sessionRunning: "This session is running; stop it before deleting.",
190
- confirmTrashTitle: "Delete session",
191
- confirmTrashBody: "“{title}” moves to the trash. You can restore it later under Settings → Session manager.",
192
- confirmTrashAction: "Move to trash",
193
- confirmPurgeTitle: "Delete permanently",
194
- confirmPurgeBody: "“{title}” is removed from the trash for good; its log files are gone and cannot be recovered.",
195
- confirmPurgeManyBody: "{n} selected sessions are removed from the trash for good and cannot be recovered.",
196
- confirmPurgeAction: "Delete permanently",
197
- ackPurge: "I understand this cannot be undone",
198
- cancel: "Cancel",
199
- close: "Close",
200
- noProject: "(unknown directory)",
201
- files: "{n} files",
202
- groupingOn: "Grouped: workspace",
203
- groupingOff: "Grouped: off",
204
- groupHint: "Group sessions under their original workspace folder; click a header to collapse it, the checkbox selects the whole workspace",
205
- sessions: "{n} sessions",
206
- workspaces: "{n} workspaces",
207
- selectGroup: "Select this workspace",
208
- clearGroup: "Clear this workspace",
209
- selectAllShort: "Select all",
210
- clearShort: "Clear",
211
- collapse: "Collapse",
212
- expand: "Expand",
213
- titleConfig: "Settings",
214
- configTitle: "Basics",
215
- configTab: "Default tab",
216
- configTabHint: "Which tab the panel opens on",
217
- configGrouping: "Group by workspace",
218
- configGroupingHint: "Off renders a flat list; the top “Grouped” button edits the same value",
219
- configSidebarArchived: "Show archived sessions in the sidebar",
220
- configSidebarArchivedHint: "Mirrors “Hide archived / All conversations” in the sidebar's view options; it is the official setting, so a page refresh applies it",
221
- configSidebarRefreshHint: "Written to the official setting — the sidebar only follows it after a page refresh.",
222
- refreshPage: "Refresh page",
223
- configMeta: "Show size and file count per row",
224
- configMetaHint: "Off leaves only the title (the folder still shows in the flat list)",
225
- configOrder: "Order",
226
- configOrderHint: "Host order is what the host sent (the trash is most-recently-deleted first)",
227
- orderHost: "Host",
228
- orderTitle: "Title",
229
- orderBytes: "Size",
230
- configAutoUpdate: "Check for updates automatically",
231
- configAutoUpdateHint: "Asks the npm registry when this page opens (cached for 10 minutes)",
232
- configReset: "Reset to defaults",
233
- configResetDone: "Defaults restored",
234
- configSaved: "Saved",
235
- aboutTitle: "About",
236
- versionLabel: "Version",
237
- updateChecking: "Checking for updates…",
238
- updateLatest: "You are on the latest version",
239
- updateAvailable: "Version {latest} is available (you have {current})",
240
- updateFailed: "Update check failed: {reason}",
241
- updateRetry: "Check again",
242
- updateCheckNow: "Check for updates",
243
- updateStepsTitle: "How to upgrade",
244
- updateStepOne: "Upgrade the package in a terminal (replace desktop with your profile name):",
245
- updateStepTwo: "Restart DSH so the host half reloads; a client-half change only needs a page refresh.",
246
- updateOpen: "Open the npm page"
247
- };
248
-
249
- // ── 两个模块级小 store(弹窗宿主与设置面板共享) ─────────────────────
250
- function createStore(initial) {
251
- let value = initial;
252
- const listeners = new Set();
253
- return {
254
- get: () => value,
255
- set: (next) => {
256
- value = next;
257
- for (const listener of listeners) listener();
258
- },
259
- subscribe: (listener) => {
260
- listeners.add(listener);
261
- return () => listeners.delete(listener);
262
- }
263
- };
264
- }
265
- function useStore(store) {
266
- const [value, setValue] = React.useState(store.get);
267
- React.useEffect(() => store.subscribe(() => setValue(store.get())), [store]);
268
- return value;
269
- }
270
-
271
- /** 待确认的请求;null 表示没有弹窗。 */
272
- const dialogStore = createStore(null);
273
- /** 每次写操作成功后自增,让设置面板重新拉快照。 */
274
- const mutationStore = createStore(0);
275
-
276
- // ── 面板设置:localStorage 持久化,拿不到就只在内存里生效 ─────────────
277
- const SETTINGS_KEY = "dsh-session-curator.settings";
278
- /** `tab` 只允许三个数据页签:配置页不能当"默认页签"。 */
279
- const DEFAULT_SETTINGS = { tab: "archived", grouping: true, showMeta: true, order: "host", autoUpdate: true };
280
-
281
- function readSettings() {
282
- try {
283
- const raw = globalThis.localStorage ? globalThis.localStorage.getItem(SETTINGS_KEY) : null;
284
- if (typeof raw !== "string" || raw === "") return { ...DEFAULT_SETTINGS };
285
- const parsed = JSON.parse(raw);
286
- const next = { ...DEFAULT_SETTINGS };
287
- for (const key of Object.keys(DEFAULT_SETTINGS)) {
288
- // 类型不符(手改过的 localStorage / 老版本留下的键)一律退回默认值。
289
- if (typeof parsed[key] === typeof DEFAULT_SETTINGS[key]) next[key] = parsed[key];
290
- }
291
- if (next.tab === "config") next.tab = DEFAULT_SETTINGS.tab;
292
- return next;
293
- } catch {
294
- return { ...DEFAULT_SETTINGS };
295
- }
296
- }
297
-
298
- const settingsStore = createStore(readSettings());
299
- function updateSettings(patch) {
300
- const next = { ...settingsStore.get(), ...patch };
301
- settingsStore.set(next);
302
- try {
303
- if (globalThis.localStorage) globalThis.localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
304
- } catch {
305
- // 隐私模式 / 配额满:设置本次会话内有效。
306
- }
307
- return next;
308
- }
309
-
310
- /** "按工作区分组"开关;初值来自设置,工具条上改它也会写回设置。 */
311
- const groupingStore = createStore(settingsStore.get().grouping);
312
-
313
- // ── 官方侧边栏的"显示已归档" ─────────────────────────────────────────
314
- /**
315
- * 官方那份"视图"存储的 localStorage 键:`@deepseek-ai/dsh-client-ui-workspace` 里
316
- * `defineStore({ …, persist: "dsh.workspace.view.v5" })`,整份 JSON 形如
317
- * `{ groupBy, orderBy, groupExpansion, sessionOrderByAccount, archivedFilter }`。
318
- * 侧边栏「视图选项」菜单里的 隐藏已归档 / 全部对话(显示已归档)/ 仅显示已归档
319
- * 写的就是 `archivedFilter`(`default` / `show` / `only`)。
320
- */
321
- const SIDEBAR_VIEW_KEY = "dsh.workspace.view.v5";
322
-
323
- function readSidebarArchived() {
324
- try {
325
- const raw = globalThis.localStorage ? globalThis.localStorage.getItem(SIDEBAR_VIEW_KEY) : null;
326
- if (typeof raw !== "string" || raw === "") return "default";
327
- const parsed = JSON.parse(raw);
328
- const value = parsed !== null && typeof parsed === "object" ? parsed.archivedFilter : null;
329
- return value === "show" || value === "only" ? value : "default";
330
- } catch {
331
- return "default";
332
- }
333
- }
334
-
335
- /**
336
- * 只改 `archivedFilter` 一个字段,其余(分组方式 / 排序 / 折叠 / 手动顺序)原样保留。
337
- *
338
- * 为什么只能"写存储 + 刷新页面":`@deepseek-ai/dsh-client-store` 的 `attachPersistence()`
339
- * **只在 store 创建时读一次** localStorage(之后只在自己变更时写),不监听 `storage` 事件;
340
- * 而那个实例活在官方 bundle 内部(`@deepseek-ai/dsh-client-ui-workspace` 只导出
341
- * `apply` / `inject`),插件既拿不到实例也拿不到它的写接口。刷新页面后官方 store 才会
342
- * 带着新值重建,侧边栏跟着变。
343
- */
344
- function writeSidebarArchived(show) {
345
- try {
346
- if (!globalThis.localStorage) return false;
347
- const raw = globalThis.localStorage.getItem(SIDEBAR_VIEW_KEY);
348
- let parsed = raw === null || raw === "" ? null : JSON.parse(raw);
349
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) parsed = {};
350
- parsed.archivedFilter = show ? "show" : "default";
351
- globalThis.localStorage.setItem(SIDEBAR_VIEW_KEY, JSON.stringify(parsed));
352
- return true;
353
- } catch {
354
- return false;
355
- }
356
- }
357
-
358
- /** 更新检查状态:`{ state: idle|checking|done|failed, value, error }`。 */
359
- const updateStore = createStore({ state: "idle", value: null, error: null });
360
- async function checkUpdate(force) {
361
- updateStore.set({ state: "checking", value: updateStore.get().value, error: null });
362
- try {
363
- const value = await call("check-update", force === true ? { force: true } : {});
364
- updateStore.set({ state: "done", value, error: null });
365
- } catch (error) {
366
- updateStore.set({ state: "failed", value: updateStore.get().value, error: reportError(error) });
367
- }
368
- }
369
-
370
- function requestTrash(ids, title) {
371
- dialogStore.set({ kind: "trash", ids, title });
372
- }
373
- function requestPurge(ids, title) {
374
- dialogStore.set({ kind: "purge", ids, title });
375
- }
376
-
377
- // ── 宿主调用 ────────────────────────────────────────────────────────
378
- let ctxRef = null;
379
-
380
- /** RFC 4122 v4;浏览器在非安全上下文也有 crypto.getRandomValues。 */
381
- function randomUuid() {
382
- const bytes = new Uint8Array(16);
383
- const crypto = globalThis.crypto;
384
- if (crypto && typeof crypto.getRandomValues === "function") crypto.getRandomValues(bytes);
385
- else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
386
- const view = new DataView(bytes.buffer);
387
- view.setUint8(6, (view.getUint8(6) & 15) | 64);
388
- view.setUint8(8, (view.getUint8(8) & 63) | 128);
389
- const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
390
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
391
- }
392
-
393
- /** 端点相对共享通道的目标:`/api/session-curator/<endpoint>`。 */
394
- function target(endpoint) {
395
- return `${ROUTE_PREFIX}/${endpoint}`;
396
- }
397
-
398
- /**
399
- * 手写 Connection 线协议:逐字复刻 `@deepseek-ai/dsh-client-connection/lib/client.js`
400
- * 的 `createWebConnectionRpc().call` —— POST `${channel}/${endpoint}`(去掉前导 `/`,
401
- * 即文档相对路径),body 是 `{type:'client-request', rpcId, method, payload}`,
402
- * 其中 `method` 是**相对通道**的端点(宿主按它核对路由),响应信封是
403
- * `{type:'server-response', rpcId, result:{ok,value|error}}`。
404
- * 这样客户端**不依赖任何 Cordis 服务**;这条 HTTP 路由的信任栅栏与会话鉴权
405
- * 仍然由宿主 Connection 的 `/api` 前缀路由把关。
406
- */
407
- async function rawCall(endpoint, payload) {
408
- const rpcId = randomUuid();
409
- const url = `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`;
410
- const response = await fetch(url.slice(1), {
411
- method: "POST",
412
- headers: { "content-type": "application/json" },
413
- body: JSON.stringify({ type: "client-request", rpcId, method: target(endpoint), payload: payload || {} })
414
- });
415
- if (!response.ok) throw new Error(`HTTP ${response.status}(${url})`);
416
- let envelope;
417
- try {
418
- envelope = await response.json();
419
- } catch {
420
- throw new Error(`响应不是 JSON(${url})`);
421
- }
422
- if (!envelope || envelope.type !== "server-response" || typeof envelope.rpcId !== "string") {
423
- throw new Error("无效的 server-response 信封");
424
- }
425
- if (envelope.rpcId !== rpcId) throw new Error(`rpcId 不匹配(${endpoint})`);
426
- const result = envelope.result;
427
- if (!result || (result.ok !== true && result.ok !== false)) throw new Error("无效的 server-response 结果");
428
- if (!result.ok) throw new Error(`${result.error ? result.error.code : "error"}: ${result.error ? result.error.message : "未知错误"}`);
429
- return result.value;
430
- }
431
-
432
- /** 首选宿主自带的调用器(若客户端真有这个服务),否则退回手写 fetch。 */
433
- async function call(endpoint, payload) {
434
- const connection = ctxRef === null ? null : ctxRef.get("connection");
435
- const rpc = connection ? connection.rpc : null;
436
- if (rpc && typeof rpc.call === "function") {
437
- const result = await rpc.call(API_CHANNEL, target(endpoint), payload || {});
438
- if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
439
- return result.value;
440
- }
441
- return rawCall(endpoint, payload);
442
- }
443
- function reportError(error) {
444
- const message = error instanceof Error ? error.message : String(error);
445
- if (ctxRef !== null && ctxRef.logger) ctxRef.logger.warn(`[session-curator] ${message}`);
446
- return message;
447
- }
448
- /** 逐项结果里第一条失败原因,用于把宿主拒绝转成一句人话。 */
449
- function firstFailure(results) {
450
- for (const row of results || []) {
451
- if (!row.ok) return row.reason || "操作失败";
452
- }
453
- return null;
454
- }
455
-
456
- // ── 展示辅助 ────────────────────────────────────────────────────────
457
- function formatBytes(bytes) {
458
- if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return "0 B";
459
- const units = ["B", "KB", "MB", "GB"];
460
- let value = bytes;
461
- let unit = 0;
462
- while (value >= 1024 && unit < units.length - 1) {
463
- value /= 1024;
464
- unit += 1;
465
- }
466
- return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
467
- }
468
- function formatTime(ms) {
469
- if (typeof ms !== "number" || ms <= 0) return "";
470
- try {
471
- return new Date(ms).toLocaleString();
472
- } catch {
473
- return "";
474
- }
475
- }
476
- function text(t, key, params) {
477
- let value = t(key);
478
- if (params) {
479
- for (const name of Object.keys(params)) value = value.split(`{${name}}`).join(String(params[name]));
480
- }
481
- return value;
482
- }
483
- function baseName(path) {
484
- if (typeof path !== "string" || path === "") return "";
485
- const parts = path.split(/[\\/]/).filter((part) => part !== "");
486
- return parts.length > 0 ? parts[parts.length - 1] : path;
487
- }
488
-
489
- // ── 按工作区文件夹分组 ──────────────────────────────────────────────
490
- /**
491
- * 分组键 = 会话**原来的工作区文件夹**。
492
- * 优先 cwd(Windows 大小写不敏感、末尾分隔符无关,所以归一化后再比),
493
- * 没有 cwd 时退到 workspaceId,两者都没有就落进"未知目录"那一组。
494
- */
495
- function groupKeyOf(row) {
496
- if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
497
- return `cwd:${row.cwd.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase()}`;
498
- }
499
- if (typeof row.workspaceId === "string" && row.workspaceId !== "") return `id:${row.workspaceId}`;
500
- return "unknown";
501
- }
502
-
503
- /**
504
- * 分成 `[{ key, title, cwd, bytes, rows[] }]`。组内保持宿主给的顺序
505
- * (回收站本来就是"最近删除在前");组间按文件夹名排序,"未知目录"永远在最后 ——
506
- * 归档 / 取消归档之后列表不会整片跳位。
507
- */
508
- function groupRows(rows, t) {
509
- const groups = new Map();
510
- for (const row of rows) {
511
- const key = groupKeyOf(row);
512
- let group = groups.get(key);
513
- if (group === undefined) {
514
- const cwd = typeof row.cwd === "string" && row.cwd !== "" ? row.cwd : null;
515
- const title = (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "" ? row.workspaceTitle : null)
516
- ?? (cwd === null ? null : baseName(cwd) || cwd)
517
- ?? t("noProject");
518
- group = { key, title, cwd, bytes: 0, rows: [] };
519
- groups.set(key, group);
520
- }
521
- group.rows.push(row);
522
- if (typeof row.bytes === "number" && Number.isFinite(row.bytes)) group.bytes += row.bytes;
523
- }
524
- const list = [...groups.values()];
525
- list.sort((left, right) => {
526
- if (left.key === "unknown" || right.key === "unknown") {
527
- return left.key === right.key ? 0 : left.key === "unknown" ? 1 : -1;
528
- }
529
- return left.title.localeCompare(right.title);
530
- });
531
- return list;
532
- }
533
-
534
- // ── 内联样式(全部走主题 token,不依赖 styles 服务) ────────────────
535
- const S = {
536
- column: { display: "flex", flexDirection: "column", gap: "10px", padding: "4px 0", minWidth: 0 },
537
- rowLine: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
538
- tabBar: { display: "flex", alignItems: "center", gap: "6px" },
539
- card: {
540
- border: "1px solid var(--dsw-alias-border-l1)",
541
- borderRadius: "10px",
542
- overflow: "hidden"
543
- },
544
- list: { display: "flex", flexDirection: "column" },
545
- item: {
546
- display: "flex",
547
- alignItems: "center",
548
- gap: "10px",
549
- padding: "8px 10px",
550
- borderTop: "1px solid var(--dsw-alias-border-l1)"
551
- },
552
- itemMain: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0, flex: "1 1 auto" },
553
- itemTitle: {
554
- color: "var(--dsw-alias-label-primary)",
555
- fontSize: "13px",
556
- overflow: "hidden",
557
- textOverflow: "ellipsis",
558
- whiteSpace: "nowrap"
559
- },
560
- itemMeta: {
561
- color: "var(--dsw-alias-label-secondary)",
562
- fontSize: "11px",
563
- overflow: "hidden",
564
- textOverflow: "ellipsis",
565
- whiteSpace: "nowrap"
566
- },
567
- itemActions: { display: "flex", alignItems: "center", gap: "6px", flex: "0 0 auto" },
568
- /** 「⋯」触发器:图标按钮,弱化成次要前景色(跟侧边栏行的图标按钮一致)。 */
569
- rowMenuButton: { color: "var(--dsw-alias-label-secondary)", padding: "0 6px" },
570
- /**
571
- * 文件夹那一行的底色。**不能用 `--dsw-alias-bg-layer-*`**:浅色主题里
572
- * base / layer-1 / layer-2 / layer-3 都指向同一个颜色(`neutral-bluish-00`),
573
- * 铺上去等于没铺(这就是之前"看不出区别"的原因)。
574
- * `interactive-bg-active` 是带 alpha 的叠加色(#2631481a,约 10%),
575
- * 深浅主题都有效;比行 hover 用的 `interactive-bg-hover`(约 6%)略重一点,
576
- * 这样"文件夹条"和"鼠标划过的会话行"不会糊在一起。
577
- */
578
- groupHead: {
579
- display: "flex",
580
- alignItems: "center",
581
- gap: "10px",
582
- padding: "6px 10px",
583
- background: "var(--dsw-alias-interactive-bg-active)",
584
- borderTop: "1px solid var(--dsw-alias-border-l1)"
585
- },
586
- groupToggle: {
587
- display: "flex",
588
- alignItems: "center",
589
- gap: "8px",
590
- minWidth: 0,
591
- flex: "1 1 auto",
592
- background: "transparent",
593
- border: "none",
594
- padding: 0,
595
- margin: 0,
596
- color: "inherit",
597
- font: "inherit",
598
- textAlign: "left",
599
- cursor: "pointer"
600
- },
601
- /** 图标只吃 `size` / `className`,不接 `style`:颜色靠外层 span 的 currentColor 传下去。 */
602
- groupCaret: { color: "var(--dsw-alias-label-tertiary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
603
- groupIcon: { color: "var(--dsw-alias-label-secondary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
604
- groupTitle: {
605
- color: "var(--dsw-alias-label-primary)",
606
- fontSize: "12px",
607
- fontWeight: 600,
608
- overflow: "hidden",
609
- textOverflow: "ellipsis",
610
- whiteSpace: "nowrap",
611
- flex: "0 1 auto"
612
- },
613
- groupMeta: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", flex: "0 0 auto" },
614
- groupPath: {
615
- color: "var(--dsw-alias-label-tertiary)",
616
- fontSize: "11px",
617
- overflow: "hidden",
618
- textOverflow: "ellipsis",
619
- whiteSpace: "nowrap",
620
- minWidth: 0,
621
- flex: "0 1 auto"
622
- },
623
- empty: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "14px 10px", textAlign: "center" },
624
- error: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px" },
625
- footer: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", display: "flex", gap: "10px", flexWrap: "wrap" },
626
- mono: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
627
- ack: { display: "flex", alignItems: "center", gap: "8px", fontSize: "12px", color: "var(--dsw-alias-label-primary)" },
628
- /** 顶部条:页签在左,动作靠 `marginInlineStart: auto` 推到右边。 */
629
- header: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
630
- headerActions: { display: "flex", alignItems: "center", gap: "8px", marginInlineStart: "auto" },
631
- /** 配置页的一行:左边标签 + 说明,右边控件。 */
632
- configRow: {
633
- display: "flex",
634
- alignItems: "center",
635
- justifyContent: "space-between",
636
- gap: "12px",
637
- padding: "10px 12px",
638
- borderTop: "1px solid var(--dsw-alias-border-l1)"
639
- },
640
- configMain: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
641
- configLabel: { color: "var(--dsw-alias-label-primary)", fontSize: "13px" },
642
- configHint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px" },
643
- configControl: { display: "flex", alignItems: "center", gap: "8px", flex: "0 0 auto" },
644
- configFoot: {
645
- display: "flex",
646
- alignItems: "center",
647
- gap: "8px",
648
- flexWrap: "wrap",
649
- padding: "10px 12px",
650
- borderTop: "1px solid var(--dsw-alias-border-l1)"
651
- },
652
- /** 版本号:浅色小字。 */
653
- version: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px" },
654
- /**
655
- * 升级提示块。底色用带 alpha 的 `interactive-bg-active` —— 浅色主题里
656
- * base / layer-1 / layer-2 / layer-3 是同一个颜色,铺上去等于没铺。
657
- */
658
- updateBox: {
659
- display: "flex",
660
- flexDirection: "column",
661
- gap: "6px",
662
- padding: "10px 12px",
663
- borderTop: "1px solid var(--dsw-alias-border-l1)",
664
- background: "var(--dsw-alias-interactive-bg-active)"
665
- },
666
- updateTitle: { color: "var(--dsw-alias-label-primary)", fontSize: "12px", fontWeight: 600 },
667
- updateSteps: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", display: "flex", flexDirection: "column", gap: "4px" },
668
- code: {
669
- display: "block",
670
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
671
- fontSize: "11px",
672
- color: "var(--dsw-alias-label-primary)",
673
- background: "var(--dsw-alias-interactive-bg-active)",
674
- borderRadius: "6px",
675
- padding: "6px 8px",
676
- overflowX: "auto",
677
- whiteSpace: "pre"
678
- },
679
- link: { color: "var(--dsw-alias-label-primary)", fontSize: "11px" }
680
- };
681
-
682
- // ── 会话行 ──────────────────────────────────────────────────────────
683
- /**
684
- * 一行会话。分组开着时 `showCwd` 为 false —— 文件夹已经写在组标题上,
685
- * 行里不必再重复一遍;平铺时才把它显示回来。
686
- *
687
- * 右侧原来并排 2–3 个文字按钮("看着有点乱"),现在统一成**一个「⋯」菜单**
688
- * (`Menu` + `IconEllipsisOutlineRegular`,与侧边栏会话行的 ⋯ 完全同一套写法):
689
- * 每行的形状一致,动作收进菜单,危险项用 `danger` 标红。
690
- */
691
- function SessionRow(props) {
692
- const row = props.row;
693
- const t = props.t;
694
- const meta = [];
695
- if (props.showCwd) meta.push(row.cwd ? row.cwd : t("noProject"));
696
- // 体积 / 文件数可以在配置页关掉(默认开);文件夹那一段不受影响。
697
- if (props.showMeta !== false) {
698
- if (row.bytes) meta.push(formatBytes(row.bytes));
699
- if (row.files) meta.push(text(t, "files", { n: row.files }));
700
- }
701
- const time = props.showDeletedAt ? formatTime(row.deletedAt) : formatTime(row.createdAt);
702
- if (time !== "") meta.push(time);
703
-
704
- const menu = props.menu;
705
- return h(
706
- "div",
707
- { style: props.first ? { ...S.item, borderTop: "none" } : S.item },
708
- h("input", {
709
- type: "checkbox",
710
- checked: props.checked,
711
- disabled: props.busy,
712
- "aria-label": row.title,
713
- onChange: () => props.onToggle()
714
- }),
715
- h(
716
- "div",
717
- { style: S.itemMain },
718
- h("div", { style: S.itemTitle, title: row.title }, row.title),
719
- h("div", { style: S.itemMeta, title: row.cwd || "" }, meta.join(" · "))
720
- ),
721
- h(
722
- "div",
723
- { style: S.itemActions },
724
- h(Menu, {
725
- open: props.menuOpen,
726
- portal: true,
727
- side: "bottom",
728
- align: "end",
729
- closeOnPointerLeave: true,
730
- items: menu.items,
731
- onSelect: (id) => menu.run(id),
732
- onClose: () => props.onCloseMenu(),
733
- anchor: h(Button, {
734
- size: "sm",
735
- variant: "ghost",
736
- disabled: props.busy,
737
- style: S.rowMenuButton,
738
- icon: h(IconEllipsisOutlineRegular, {}),
739
- "aria-label": `${row.title} — ${t("rowActions")}`,
740
- onClick: (event) => {
741
- event.stopPropagation();
742
- props.onToggleMenu();
743
- }
744
- })
745
- })
746
- )
747
- );
748
- }
749
-
750
- /** 一个工作区分组的标题行:左边(箭头 + 文件夹图标 + 名字)折叠 / 展开,右边一个小按钮选整组。 */
751
- function GroupHeader(props) {
752
- const group = props.group;
753
- const t = props.t;
754
- const total = group.rows.length;
755
- const allSelected = total > 0 && group.rows.every((row) => props.selected.includes(row.sessionId));
756
- const action = allSelected ? t("clearShort") : t("selectAllShort");
757
- return h(
758
- "div",
759
- { style: props.first ? { ...S.groupHead, borderTop: "none" } : S.groupHead },
760
- h(
761
- "button",
762
- {
763
- type: "button",
764
- style: S.groupToggle,
765
- title: props.collapsed ? t("expand") : t("collapse"),
766
- onClick: () => props.onToggleCollapse()
767
- },
768
- // 箭头与文件夹图标都用 DSH 自带图标(跟侧边栏的项目行同款,展开 = 打开的文件夹)。
769
- h("span", { style: S.groupCaret }, props.collapsed
770
- ? h(IconChevronRightOutlineRegular, { size: 12 })
771
- : h(IconChevronDownOutlineRegular, { size: 12 })),
772
- h("span", { style: S.groupIcon }, props.collapsed
773
- ? h(IconFolderCloseRegular, { size: 14 })
774
- : h(IconFolderOpenRegular, { size: 14 })),
775
- h("span", { style: S.groupTitle, title: group.cwd || group.title }, group.title),
776
- h("span", { style: S.groupMeta }, text(t, "sessions", { n: total })),
777
- group.bytes > 0 ? h("span", { style: S.groupMeta }, formatBytes(group.bytes)) : null,
778
- group.cwd !== null ? h("span", { style: { ...S.groupPath, ...S.mono }, title: group.cwd }, group.cwd) : null
779
- ),
780
- h(Button, {
781
- size: "sm",
782
- variant: "ghost",
783
- disabled: props.busy,
784
- title: `${group.title} — ${allSelected ? t("clearGroup") : t("selectGroup")}`,
785
- onClick: () => props.onToggleGroup()
786
- }, action)
787
- );
788
- }
789
-
790
- // ── 设置面板 ────────────────────────────────────────────────────────
791
- function SessionManagerPanel(props) {
792
- const t = props.t;
793
- const settings = useStore(settingsStore);
794
- /** 初始页签来自配置页的"默认页签"。 */
795
- const [tab, setTab] = React.useState(settings.tab);
796
- const [snapshot, setSnapshot] = React.useState(null);
797
- const [error, setError] = React.useState(null);
798
- const [loading, setLoading] = React.useState(true);
799
- const [busy, setBusy] = React.useState(false);
800
- const [selected, setSelected] = React.useState([]);
801
- // 折叠的分组键(`<tab>:<groupKey>`),按页签各记各的。
802
- const [collapsed, setCollapsed] = React.useState([]);
803
- // 同一时刻只允许一个行的「⋯」菜单开着:存 sessionId。
804
- const [menuFor, setMenuFor] = React.useState(null);
805
- const grouping = useStore(groupingStore);
806
- const revision = useStore(mutationStore);
807
- const update = useStore(updateStore);
808
- // 官方侧边栏的"显示已归档":初值从它自己的 localStorage 读;改完要刷新页面才生效,
809
- // 所以另外记住"挂载时是什么",两者不一致时才提示刷新。
810
- const [sidebarArchived, setSidebarArchived] = React.useState(readSidebarArchived);
811
- const [sidebarAtMount] = React.useState(readSidebarArchived);
812
-
813
- const applyResult = (value) => {
814
- if (value && value.archived) setSnapshot(value);
815
- };
816
-
817
- const refresh = React.useCallback(async () => {
818
- setLoading(true);
819
- try {
820
- applyResult(await call("snapshot", {}));
821
- setError(null);
822
- } catch (failure) {
823
- setError(reportError(failure));
824
- } finally {
825
- setLoading(false);
826
- }
827
- }, []);
828
-
829
- React.useEffect(() => {
830
- void refresh();
831
- }, [refresh, revision]);
832
-
833
- // 打开配置页时自动查一次更新(可以在配置页关掉;宿主那边 10 分钟内复用缓存)。
834
- React.useEffect(() => {
835
- if (tab !== "config" || !settings.autoUpdate || update.state !== "idle") return;
836
- void checkUpdate(false);
837
- }, [tab, settings.autoUpdate, update.state]);
838
-
839
- // 换页签时清空选择,避免把上一个页签的 id 带到下一个页签的操作里。
840
- React.useEffect(() => {
841
- setSelected([]);
842
- setMenuFor(null);
843
- }, [tab]);
844
-
845
- const run = async (endpoint, ids, payload) => {
846
- if (ids.length === 0) return;
847
- setBusy(true);
848
- try {
849
- const value = await call(endpoint, { ids, ...(payload || {}) });
850
- applyResult(value);
851
- setError(firstFailure(value.results));
852
- setSelected([]);
853
- } catch (failure) {
854
- setError(reportError(failure));
855
- } finally {
856
- setBusy(false);
857
- }
858
- };
859
-
860
- const toggle = (sessionId) => {
861
- setSelected((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : current.concat([sessionId]));
862
- };
863
-
864
- /** 整组一起选 / 取消选:组内全选就整组取消,否则补齐。 */
865
- const toggleGroup = (group) => {
866
- const ids = group.rows.map((row) => row.sessionId);
867
- const all = ids.every((id) => selected.includes(id));
868
- setSelected((current) => all
869
- ? current.filter((id) => !ids.includes(id))
870
- : [...new Set(current.concat(ids))]);
871
- };
872
-
873
- const archived = snapshot ? snapshot.archived : [];
874
- const pinned = snapshot ? snapshot.pinned : [];
875
- const trashed = snapshot ? snapshot.trashed : [];
876
- /** 排序是显示层的事:`host` 就是宿主给的顺序(回收站是最近删除在前)。 */
877
- const sortRows = (list) => {
878
- if (settings.order === "title") {
879
- return [...list].sort((left, right) => String(left.title ?? "").localeCompare(String(right.title ?? "")));
880
- }
881
- if (settings.order === "bytes") {
882
- return [...list].sort((left, right) => (right.bytes ?? 0) - (left.bytes ?? 0));
883
- }
884
- return list;
885
- };
886
- const rows = sortRows(tab === "archived" ? archived : tab === "pinned" ? pinned : tab === "trash" ? trashed : []);
887
- const selectedRows = rows.filter((row) => selected.includes(row.sessionId));
888
-
889
- // 分组只是显示层:选择、批量操作、计数仍然按整个页签的行来算。
890
- const groups = grouping ? groupRows(rows, t) : [];
891
- const blockOf = (group) => {
892
- const key = `${tab}:${group.key}`;
893
- return { key, group, rows: collapsed.includes(key) ? [] : group.rows };
894
- };
895
- const blocks = grouping ? groups.map(blockOf) : [{ key: `${tab}:flat`, group: null, rows }];
896
-
897
- const tabs = [
898
- { id: "archived", label: t("titleArchived"), count: archived.length },
899
- { id: "pinned", label: t("titlePinned"), count: pinned.length },
900
- { id: "trash", label: t("titleTrash"), count: trashed.length },
901
- { id: "config", label: t("titleConfig"), count: 0 }
902
- ];
903
-
904
- /**
905
- * 一行的「⋯」菜单内容。每个页签的动作本来就不同,但**控件形状统一**:
906
- * 都是一行会话 + 一个 ⋯。
907
- *
908
- * 注意已归档那一组的第三项:以前是"永久删除",可它走的是 `purge`,而 `purge`
909
- * 只认回收站里的目录 —— 归档会话不在回收站,所以那个按钮**从来只会报
910
- * "回收站里没有这个会话"**。现在改成真正可用的"移入回收站"(`trash`:先取消归档
911
- * 再搬走,随时能恢复)。
912
- */
913
- const rowMenu = (row) => {
914
- const moveToTrash = {
915
- id: "trash",
916
- label: t("moveToTrash"),
917
- icon: h(IconTrashOutlineRegular, { size: 14 }),
918
- danger: true
919
- };
920
- if (tab === "archived") {
921
- return {
922
- items: [
923
- { id: "unarchive", label: t("unarchive"), icon: h(IconUnarchiveOutlineRegular, { size: 14 }) },
924
- { id: "pin", label: t("pin"), icon: h(IconPinOutlineRegular, { size: 14 }) },
925
- moveToTrash
926
- ],
927
- run: (id) => {
928
- setMenuFor(null);
929
- if (id === "trash") requestTrash([row.sessionId], row.title);
930
- else void run(id, [row.sessionId]);
931
- }
932
- };
933
- }
934
- if (tab === "pinned") {
935
- return {
936
- items: [
937
- { id: "unpin", label: t("unpin"), icon: h(IconPinOutlineRegular, { size: 14 }) },
938
- moveToTrash
939
- ],
940
- run: (id) => {
941
- setMenuFor(null);
942
- if (id === "trash") requestTrash([row.sessionId], row.title);
943
- else void run(id, [row.sessionId]);
944
- }
945
- };
946
- }
947
- return {
948
- items: [
949
- { id: "restore", label: t("restore"), icon: h(IconRefreshOutlineRegular, { size: 14 }) },
950
- { id: "purge", label: t("purge"), icon: h(IconTrashOutlineRegular, { size: 14 }), danger: true }
951
- ],
952
- run: (id) => {
953
- setMenuFor(null);
954
- if (id === "purge") requestPurge([row.sessionId], row.title);
955
- else void run(id, [row.sessionId]);
956
- }
957
- };
958
- };
959
-
960
- const batch = [];
961
- if (tab === "archived" && selected.length > 0) {
962
- batch.push({ label: t("unarchive"), run: () => void run("unarchive", selected) });
963
- batch.push({ label: t("moveToTrash"), variant: "outline", run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "") });
964
- }
965
- if (tab === "pinned" && selected.length > 0) {
966
- batch.push({ label: t("unpin"), run: () => void run("unpin", selected) });
967
- batch.push({ label: t("moveToTrash"), variant: "outline", run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "") });
968
- }
969
- if (tab === "trash" && selected.length > 0) {
970
- batch.push({ label: t("restore"), run: () => void run("restore", selected) });
971
- batch.push({ label: t("purge"), variant: "outline", run: () => requestPurge(selected, selectedRows[0] ? selectedRows[0].title : "") });
972
- }
973
-
974
- const totals = snapshot ? snapshot.totals : null;
975
- const totalBytes = tab === "archived"
976
- ? (totals ? totals.archivedBytes : 0)
977
- : tab === "trash"
978
- ? (totals ? totals.trashBytes : 0)
979
- : 0;
980
-
981
- // 卡片里的显示节点:分组标题 + 行。第一个节点不要上边框(卡片自己有)。
982
- const nodes = [];
983
- let first = true;
984
- for (const block of blocks) {
985
- if (block.group !== null) {
986
- const group = block.group;
987
- const isCollapsed = collapsed.includes(block.key);
988
- nodes.push(h(GroupHeader, {
989
- key: `group:${block.key}`,
990
- group,
991
- t,
992
- first,
993
- busy,
994
- selected,
995
- collapsed: isCollapsed,
996
- onToggleGroup: () => toggleGroup(group),
997
- onToggleCollapse: () => setCollapsed((current) => current.includes(block.key)
998
- ? current.filter((item) => item !== block.key)
999
- : current.concat([block.key]))
1000
- }));
1001
- first = false;
1002
- }
1003
- for (const row of block.rows) {
1004
- nodes.push(h(SessionRow, {
1005
- key: row.sessionId,
1006
- row,
1007
- t,
1008
- first,
1009
- busy,
1010
- showCwd: block.group === null,
1011
- showMeta: settings.showMeta,
1012
- showDeletedAt: tab === "trash",
1013
- checked: selected.includes(row.sessionId),
1014
- onToggle: () => toggle(row.sessionId),
1015
- menu: rowMenu(row),
1016
- menuOpen: menuFor === row.sessionId,
1017
- onToggleMenu: () => setMenuFor(menuFor === row.sessionId ? null : row.sessionId),
1018
- onCloseMenu: () => setMenuFor(null)
1019
- }));
1020
- first = false;
1021
- }
1022
- }
1023
-
1024
- const isConfig = tab === "config";
1025
-
1026
- /** 配置页的一行:左边"标签 + 说明",右边控件。 */
1027
- const configRow = (key, label, hint, control, firstRow) => h(
1028
- "div",
1029
- { key, style: firstRow ? { ...S.configRow, borderTop: "none" } : S.configRow },
1030
- h(
1031
- "div",
1032
- { style: S.configMain },
1033
- h("span", { style: S.configLabel }, label),
1034
- hint === null ? null : h("span", { style: S.configHint }, hint)
1035
- ),
1036
- h("div", { style: S.configControl }, control)
1037
- );
1038
-
1039
- /** 版本号优先用宿主报的(快照里带 `version`),其次用更新检查回来的当前版本。 */
1040
- const version = typeof (snapshot && snapshot.version) === "string"
1041
- ? snapshot.version
1042
- : (update.value && typeof update.value.current === "string" ? update.value.current : null);
1043
- const outdated = update.state === "done" && update.value !== null && update.value.outdated === true;
1044
- /** 状态行:检查中 / 失败 / 已是最新。有新版本时改用下面的升级块。 */
1045
- const updateLine = update.state === "checking"
1046
- ? h("span", { style: S.configHint }, t("updateChecking"))
1047
- : update.state === "failed"
1048
- ? h("span", { style: S.error }, text(t, "updateFailed", { reason: update.error === null ? "?" : update.error }))
1049
- : update.value !== null && typeof update.value.error === "string"
1050
- ? h("span", { style: S.configHint }, text(t, "updateFailed", { reason: update.value.error }))
1051
- : update.state === "done" && !outdated
1052
- ? h("span", { style: S.configHint }, t("updateLatest"))
1053
- : null;
1054
-
1055
- const configPage = h(
1056
- "div",
1057
- { style: S.column },
1058
- h(
1059
- "div",
1060
- { style: S.card },
1061
- configRow("config:tab", t("configTab"), t("configTabHint"), h(SegmentedControl, {
1062
- id: "session-curator-default-tab",
1063
- value: settings.tab,
1064
- options: [
1065
- { value: "archived", label: t("titleArchived") },
1066
- { value: "pinned", label: t("titlePinned") },
1067
- { value: "trash", label: t("titleTrash") }
1068
- ],
1069
- label: t("configTab"),
1070
- onChange: (next) => void updateSettings({ tab: next })
1071
- }), true),
1072
- configRow("config:grouping", t("configGrouping"), t("configGroupingHint"), h(Switch, {
1073
- checked: settings.grouping,
1074
- label: t("configGrouping"),
1075
- onChange: (next) => {
1076
- updateSettings({ grouping: next });
1077
- groupingStore.set(next);
1078
- }
1079
- })),
1080
- configRow("config:sidebar-archived", t("configSidebarArchived"), t("configSidebarArchivedHint"), h(Switch, {
1081
- checked: sidebarArchived !== "default",
1082
- label: t("configSidebarArchived"),
1083
- onChange: (next) => {
1084
- writeSidebarArchived(next);
1085
- setSidebarArchived(next ? "show" : "default");
1086
- }
1087
- })),
1088
- // 官方 store 只在创建时读一次 localStorage,所以改完必须刷新页面。
1089
- sidebarArchived === sidebarAtMount ? null : h(
1090
- "div",
1091
- { key: "config:sidebar-refresh", style: S.configFoot },
1092
- h("span", { style: S.configHint }, t("configSidebarRefreshHint")),
1093
- h(Button, {
1094
- size: "sm",
1095
- variant: "outline",
1096
- onClick: () => {
1097
- try {
1098
- globalThis.location.reload();
1099
- } catch {
1100
- // 没有 location(自检环境)时只保留提示
1101
- }
1102
- }
1103
- }, t("refreshPage"))
1104
- ),
1105
- configRow("config:meta", t("configMeta"), t("configMetaHint"), h(Switch, {
1106
- checked: settings.showMeta,
1107
- label: t("configMeta"),
1108
- onChange: (next) => void updateSettings({ showMeta: next })
1109
- })),
1110
- configRow("config:order", t("configOrder"), t("configOrderHint"), h(SegmentedControl, {
1111
- id: "session-curator-order",
1112
- value: settings.order,
1113
- options: [
1114
- { value: "host", label: t("orderHost") },
1115
- { value: "title", label: t("orderTitle") },
1116
- { value: "bytes", label: t("orderBytes") }
1117
- ],
1118
- label: t("configOrder"),
1119
- onChange: (next) => void updateSettings({ order: next })
1120
- })),
1121
- configRow("config:auto", t("configAutoUpdate"), t("configAutoUpdateHint"), h(Switch, {
1122
- checked: settings.autoUpdate,
1123
- label: t("configAutoUpdate"),
1124
- onChange: (next) => void updateSettings({ autoUpdate: next })
1125
- })),
1126
- h(
1127
- "div",
1128
- { key: "config:reset", style: { ...S.configFoot, justifyContent: "flex-end" } },
1129
- h(Button, {
1130
- size: "sm",
1131
- variant: "outline",
1132
- onClick: () => {
1133
- updateSettings({ ...DEFAULT_SETTINGS });
1134
- groupingStore.set(DEFAULT_SETTINGS.grouping);
1135
- }
1136
- }, t("configReset"))
1137
- )
1138
- ),
1139
-
1140
- h(
1141
- "div",
1142
- { style: S.card },
1143
- h(
1144
- "div",
1145
- { style: { ...S.configRow, borderTop: "none" } },
1146
- h(
1147
- "div",
1148
- { style: S.configMain },
1149
- h("span", { style: S.configLabel }, t("aboutTitle")),
1150
- h("span", { style: S.configHint }, "dsh-session-curator")
1151
- ),
1152
- h(
1153
- "div",
1154
- { style: S.configControl },
1155
- // 版本号:浅色小字。
1156
- h("span", { style: S.version }, `${t("versionLabel")} ${version === null ? "—" : `v${version}`}`),
1157
- h(Button, {
1158
- size: "sm",
1159
- variant: "outline",
1160
- disabled: update.state === "checking",
1161
- onClick: () => void checkUpdate(true)
1162
- }, t("updateCheckNow"))
1163
- )
1164
- ),
1165
- updateLine === null ? null : h("div", { key: "config:update", style: S.configFoot }, updateLine),
1166
- outdated
1167
- ? h(
1168
- "div",
1169
- { key: "config:upgrade", style: S.updateBox },
1170
- h("span", { style: S.updateTitle }, text(t, "updateAvailable", { latest: update.value.latest, current: version === null ? "?" : version })),
1171
- h(
1172
- "span",
1173
- { style: S.updateSteps },
1174
- h("span", null, t("updateStepsTitle")),
1175
- h("span", null, t("updateStepOne")),
1176
- h("code", { style: S.code }, "dsh plugin --profile desktop add dsh-session-curator@latest"),
1177
- h("span", null, t("updateStepTwo"))
1178
- ),
1179
- h("a", {
1180
- style: S.link,
1181
- href: "https://www.npmjs.com/package/dsh-session-curator",
1182
- target: "_blank",
1183
- rel: "noreferrer"
1184
- }, t("updateOpen"))
1185
- )
1186
- : null
1187
- )
1188
- );
1189
-
1190
- return h(
1191
- "div",
1192
- { style: S.column },
1193
- h(
1194
- "div",
1195
- { style: S.header },
1196
- h(
1197
- "div",
1198
- { style: S.tabBar },
1199
- tabs.map((entry) => h(Button, {
1200
- key: entry.id,
1201
- size: "sm",
1202
- variant: tab === entry.id ? "primary" : "ghost",
1203
- onClick: () => setTab(entry.id)
1204
- }, `${entry.label}${entry.count > 0 ? ` (${entry.count})` : ""}`))
1205
- ),
1206
- // 分组与刷新推到右边;配置页上没有它们。
1207
- isConfig ? null : h(
1208
- "div",
1209
- { style: S.headerActions },
1210
- h(Button, {
1211
- size: "sm",
1212
- variant: "outline",
1213
- title: t("groupHint"),
1214
- onClick: () => {
1215
- const next = !grouping;
1216
- groupingStore.set(next);
1217
- updateSettings({ grouping: next });
1218
- }
1219
- }, grouping ? t("groupingOn") : t("groupingOff")),
1220
- h(Button, { size: "sm", variant: "outline", disabled: loading || busy, onClick: () => void refresh() }, t("refresh"))
1221
- )
1222
- ),
1223
-
1224
- error ? h("div", { style: S.error }, error) : null,
1225
-
1226
- isConfig ? null : h(
1227
- "div",
1228
- { style: S.card },
1229
- loading && !snapshot
1230
- ? h("div", { style: S.empty }, t("loading"))
1231
- : rows.length === 0
1232
- ? h("div", { style: S.empty }, tab === "archived" ? t("emptyArchived") : tab === "pinned" ? t("emptyPinned") : t("emptyTrash"))
1233
- : h("div", { style: S.list }, nodes)
1234
- ),
1235
-
1236
- isConfig ? null : h(
1237
- "div",
1238
- { style: S.rowLine },
1239
- h(Button, {
1240
- size: "sm",
1241
- variant: "ghost",
1242
- disabled: busy || rows.length === 0,
1243
- onClick: () => setSelected(selected.length === rows.length ? [] : rows.map((row) => row.sessionId))
1244
- }, selected.length === rows.length && rows.length > 0 ? t("selectNone") : t("selectAll")),
1245
- batch.map((action) => h(Button, {
1246
- key: action.label,
1247
- size: "sm",
1248
- variant: action.variant || "ghost",
1249
- disabled: busy,
1250
- onClick: action.run
1251
- }, action.label)),
1252
- tab === "trash" && trashed.length > 0
1253
- ? h(Button, {
1254
- size: "sm",
1255
- variant: "outline",
1256
- disabled: busy,
1257
- onClick: () => requestPurge(trashed.map((row) => row.sessionId), "")
1258
- }, t("emptyTrashAll"))
1259
- : null,
1260
- busy ? h("span", { style: S.footer }, t("working")) : null
1261
- ),
1262
-
1263
- isConfig ? configPage : null,
1264
-
1265
- isConfig ? null : h(
1266
- "div",
1267
- { style: S.footer },
1268
- h("span", null, text(t, "total", { n: rows.length, size: formatBytes(totalBytes) })),
1269
- grouping && groups.length > 0 ? h("span", null, text(t, "workspaces", { n: groups.length })) : null,
1270
- selected.length > 0 ? h("span", null, text(t, "selected", { n: selected.length })) : null,
1271
- snapshot ? h("span", { style: S.mono, title: snapshot.trashDir }, snapshot.trashDir) : null
1272
- )
1273
- );
1274
- }
1275
-
1276
- // ── 全局确认弹窗宿主(挂在 shell.overlay 上) ────────────────────────
1277
- function DialogHost(props) {
1278
- const t = props.t;
1279
- const request = useStore(dialogStore);
1280
- const [busy, setBusy] = React.useState(false);
1281
- const [ack, setAck] = React.useState(false);
1282
- const [error, setError] = React.useState(null);
1283
-
1284
- // 每次新请求都重置勾选与错误(hooks 先于条件渲染,保持调用顺序稳定)。
1285
- const requestKey = request === null ? "" : `${request.kind}:${request.ids.join(",")}:${request.title}`;
1286
- React.useEffect(() => {
1287
- setAck(false);
1288
- setError(null);
1289
- }, [requestKey]);
1290
-
1291
- if (request === null) return null;
1292
-
1293
- const purge = request.kind === "purge";
1294
- const many = request.ids.length > 1;
1295
- const title = purge ? t("confirmPurgeTitle") : t("confirmTrashTitle");
1296
- const body = purge
1297
- ? (many ? text(t, "confirmPurgeManyBody", { n: request.ids.length }) : text(t, "confirmPurgeBody", { title: request.title }))
1298
- : text(t, "confirmTrashBody", { title: request.title });
1299
-
1300
- const close = () => {
1301
- if (busy) return;
1302
- dialogStore.set(null);
1303
- };
1304
-
1305
- const confirm = async () => {
1306
- setBusy(true);
1307
- try {
1308
- const value = await call(purge ? "purge" : "trash", { ids: request.ids });
1309
- const failure = firstFailure(value.results);
1310
- if (failure === null) {
1311
- dialogStore.set(null);
1312
- mutationStore.set(mutationStore.get() + 1);
1313
- } else {
1314
- setError(failure);
1315
- }
1316
- } catch (thrown) {
1317
- setError(reportError(thrown));
1318
- } finally {
1319
- setBusy(false);
1320
- }
1321
- };
1322
-
1323
- return h(
1324
- Modal,
1325
- {
1326
- open: true,
1327
- onClose: close,
1328
- title,
1329
- closeLabel: t("close"),
1330
- description: body,
1331
- footer: h(
1332
- "div",
1333
- { style: S.rowLine },
1334
- h(Button, { size: "md", variant: "ghost", disabled: busy, onClick: close }, t("cancel")),
1335
- h(Button, {
1336
- size: "md",
1337
- variant: "primary",
1338
- disabled: busy || (purge && !ack),
1339
- onClick: () => void confirm()
1340
- }, purge ? t("confirmPurgeAction") : t("confirmTrashAction"))
1341
- )
1342
- },
1343
- purge
1344
- ? h(
1345
- "div",
1346
- { style: S.column },
1347
- h(
1348
- "label",
1349
- { style: S.ack },
1350
- h("input", {
1351
- type: "checkbox",
1352
- checked: ack,
1353
- disabled: busy,
1354
- onChange: (event) => setAck(Boolean(event.target.checked))
1355
- }),
1356
- t("ackPurge")
1357
- ),
1358
- error ? h("div", { style: S.error }, error) : null
1359
- )
1360
- : (error ? h("div", { style: S.error }, error) : null)
1361
- );
1362
- }
1363
-
1364
- // ── 插件装配 ────────────────────────────────────────────────────────
1365
- const inject = ["slots", "locale"];
1366
-
1367
- function apply(ctx) {
1368
- ctxRef = ctx;
1369
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-session-curator: dictionaries");
1370
- const t = ctx.locale.bind(NS);
1371
-
1372
- // 逐个贡献:某个槽位声明缺失时只丢这一个贡献,不连累其余的。
1373
- const contribute = (slot, options, component, label) => {
1374
- try {
1375
- ctx.slots.inject(slot, () => ctx.slots.register(options, component));
1376
- } catch (error) {
1377
- reportError(error);
1378
- if (ctx.logger) ctx.logger.warn(`[session-curator] slot ${slot} 注册失败(${label})`);
1379
- }
1380
- };
1381
-
1382
- // 1) 会话行「⋯」菜单里的删除项,排在官方 archive(400)之后。
1383
- contribute("sidebar.workspaces.session.menu.item", {
1384
- name: "sidebar.workspaces.session.menu.item",
1385
- id: MENU_ID,
1386
- order: 500,
1387
- label: () => t("menuDelete"),
1388
- locale: NS
1389
- }, ({ sessionId, displayTitle, useMenuOpenState }) => {
1390
- const [, setMenuOpen] = useMenuOpenState();
1391
- return h(MenuItemButton, {
1392
- danger: true,
1393
- separatorBefore: true,
1394
- onSelect: () => {
1395
- setMenuOpen(false);
1396
- requestTrash([sessionId], displayTitle || sessionId);
1397
- }
1398
- }, t("menuDelete"));
1399
- }, "menu item");
1400
-
1401
- // 2) 全局确认弹窗宿主。
1402
- contribute("shell.overlay", {
1403
- name: "shell.overlay",
1404
- id: OVERLAY_ID,
1405
- order: 60,
1406
- locale: NS
1407
- }, DialogHost, "dialog host");
1408
-
1409
- // 3) 设置页:已归档 / 已置顶 / 回收站(id 见 sectionIdFor:为了导航栏的归档图标)。
1410
- contribute("settings.section", {
1411
- name: "settings.section",
1412
- id: sectionIdFor(ctx),
1413
- order: 45,
1414
- label: () => t("sectionLabel"),
1415
- locale: NS
1416
- }, SessionManagerPanel, "settings section");
1417
- }
1418
-
1419
- bundleModule.exports.NS = NS;
1420
- bundleModule.exports.apply = apply;
1421
- bundleModule.exports.inject = inject;
1422
- // 纯函数 / 纯组件,给 scripts/client.mjs 直接单测(浏览器里用不到这几个导出)。
1423
- bundleModule.exports.groupRows = groupRows;
1424
- bundleModule.exports.groupKeyOf = groupKeyOf;
1425
- bundleModule.exports.SessionRow = SessionRow;
1426
- bundleModule.exports.GroupHeader = GroupHeader;
1427
- bundleModule.exports.sectionIdFor = sectionIdFor;
1428
- return bundleModule.exports;
7
+ const module = { exports: {} };
8
+ (function (module, exports, require) {
9
+ var __create = Object.create;
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __getProtoOf = Object.getPrototypeOf;
14
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
15
+ var __export = (target2, all) => {
16
+ for (var name in all)
17
+ __defProp(target2, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
28
+ // If the importer is in node compatibility mode or this is not an ESM
29
+ // file that has been converted to a CommonJS file using a Babel-
30
+ // compatible transform (i.e. "__esModule" has not been set), then set
31
+ // "default" to the CommonJS "module.exports" for node compatibility.
32
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target2, "default", { value: mod, enumerable: true }) : target2,
33
+ mod
34
+ ));
35
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
36
+
37
+ // src/client/index.ts
38
+ var index_exports = {};
39
+ __export(index_exports, {
40
+ Dropdown: () => Dropdown,
41
+ GroupHeader: () => GroupHeader,
42
+ NS: () => NS,
43
+ SessionRow: () => SessionRow,
44
+ TRASH_DAYS_MAX: () => TRASH_DAYS_MAX,
45
+ TRASH_DAYS_MIN: () => TRASH_DAYS_MIN,
46
+ TRASH_RETENTION_DAYS: () => TRASH_RETENTION_DAYS,
47
+ UsagePanel: () => UsagePanel,
48
+ apply: () => apply,
49
+ cachedSnapshot: () => cachedSnapshot,
50
+ collapseKey: () => collapseKey,
51
+ describeSession: () => describeSession,
52
+ dialogStore: () => dialogStore,
53
+ effectiveTrashDays: () => effectiveTrashDays,
54
+ filterRows: () => filterRows,
55
+ forgetDetails: () => forgetDetails,
56
+ forgetSnapshot: () => forgetSnapshot,
57
+ groupKeyOf: () => groupKeyOf,
58
+ groupRows: () => groupRows,
59
+ inject: () => inject,
60
+ isFilterActive: () => isFilterActive,
61
+ isGroupCollapsed: () => isGroupCollapsed,
62
+ matchesFilter: () => matchesFilter,
63
+ matchesSearch: () => matchesSearch,
64
+ parseCustomDays: () => parseCustomDays,
65
+ sectionIdFor: () => sectionIdFor,
66
+ showNotice: () => showNotice,
67
+ showTrashUndo: () => showTrashUndo,
68
+ sortRows: () => sortRows,
69
+ toastStore: () => toastStore,
70
+ toggledCollapse: () => toggledCollapse,
71
+ undoTrash: () => undoTrash,
72
+ workspaceSummaries: () => workspaceSummaries
73
+ });
74
+ module.exports = __toCommonJS(index_exports);
75
+ var React11 = __toESM(require("react"), 1);
76
+ var import_dsh_client_ui_primitives9 = require("@deepseek-ai/dsh-client-ui-primitives");
77
+
78
+ // src/client/components/dialog-host.ts
79
+ var React2 = __toESM(require("react"), 1);
80
+ var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
81
+
82
+ // src/client/runtime.ts
83
+ var current = null;
84
+ function setContext(ctx) {
85
+ current = ctx;
86
+ }
87
+ function connectionRpc() {
88
+ const ctx = current;
89
+ if (ctx === null) return null;
90
+ const connection = ctx.get("connection");
91
+ const rpc = connection === null || connection === void 0 ? null : connection.rpc;
92
+ return rpc && typeof rpc.call === "function" ? rpc : null;
93
+ }
94
+ function ctxLogger() {
95
+ const ctx = current;
96
+ return ctx !== null && ctx.logger ? ctx.logger : null;
97
+ }
98
+ function reportError(error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ const logger = ctxLogger();
101
+ if (logger) logger.warn(`[session-curator] ${message}`);
102
+ return message;
103
+ }
104
+
105
+ // src/client/api.ts
106
+ var API_CHANNEL = "/api";
107
+ var ROUTE_PREFIX = "session-curator";
108
+ function randomUuid() {
109
+ const bytes = new Uint8Array(16);
110
+ const crypto = globalThis.crypto;
111
+ if (crypto && typeof crypto.getRandomValues === "function") crypto.getRandomValues(bytes);
112
+ else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
113
+ const view = new DataView(bytes.buffer);
114
+ view.setUint8(6, view.getUint8(6) & 15 | 64);
115
+ view.setUint8(8, view.getUint8(8) & 63 | 128);
116
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
117
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
118
+ }
119
+ function target(endpoint) {
120
+ return `${ROUTE_PREFIX}/${endpoint}`;
121
+ }
122
+ async function rawCall(endpoint, payload) {
123
+ const rpcId = randomUuid();
124
+ const url = `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`;
125
+ const response = await fetch(url.slice(1), {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify({ type: "client-request", rpcId, method: target(endpoint), payload: payload || {} })
129
+ });
130
+ if (!response.ok) throw new Error(`HTTP ${response.status}(${url})`);
131
+ let envelope;
132
+ try {
133
+ envelope = await response.json();
134
+ } catch {
135
+ throw new Error(`响应不是 JSON(${url})`);
136
+ }
137
+ const message = envelope;
138
+ if (!message || message.type !== "server-response" || typeof message.rpcId !== "string") {
139
+ throw new Error("无效的 server-response 信封");
140
+ }
141
+ if (message.rpcId !== rpcId) throw new Error(`rpcId 不匹配(${endpoint})`);
142
+ const result = message.result;
143
+ if (!result || result.ok !== true && result.ok !== false) throw new Error("无效的 server-response 结果");
144
+ if (result.ok !== true) {
145
+ throw new Error(`${result.error ? result.error.code : "error"}: ${result.error ? result.error.message : "未知错误"}`);
146
+ }
147
+ return result.value;
148
+ }
149
+ async function call(endpoint, payload) {
150
+ const rpc = connectionRpc();
151
+ if (rpc !== null) {
152
+ const result = await rpc.call(API_CHANNEL, target(endpoint), payload || {});
153
+ if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
154
+ return result.value;
155
+ }
156
+ return await rawCall(endpoint, payload);
157
+ }
158
+ function firstFailure(results) {
159
+ for (const row of results || []) {
160
+ if (!row.ok) return row.reason || "操作失败";
161
+ }
162
+ return null;
163
+ }
164
+
165
+ // src/client/stores.ts
166
+ var React = __toESM(require("react"), 1);
167
+ function createStore(initial) {
168
+ let value = initial;
169
+ const listeners = /* @__PURE__ */ new Set();
170
+ return {
171
+ get: () => value,
172
+ set: (next) => {
173
+ value = next;
174
+ for (const listener of listeners) listener();
175
+ },
176
+ subscribe: (listener) => {
177
+ listeners.add(listener);
178
+ return () => {
179
+ listeners.delete(listener);
180
+ };
181
+ }
182
+ };
183
+ }
184
+ function useStore(store) {
185
+ const [value, setValue] = React.useState(store.get);
186
+ React.useEffect(() => store.subscribe(() => setValue(store.get())), [store]);
187
+ return value;
188
+ }
189
+
190
+ // src/client/dialog.ts
191
+ var dialogStore = createStore(null);
192
+ var mutationStore = createStore(0);
193
+ function requestTrash(ids, title) {
194
+ dialogStore.set({ kind: "trash", ids, title });
195
+ }
196
+ function requestPurge(ids, title) {
197
+ dialogStore.set({ kind: "purge", ids, title });
198
+ }
199
+ function requestPurgeOld(days, count, bytes) {
200
+ dialogStore.set({ kind: "purge-old", ids: [], title: "", days, count, bytes });
201
+ }
202
+
203
+ // src/client/format.ts
204
+ function formatBytes(bytes) {
205
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return "0 B";
206
+ const units = ["B", "KB", "MB", "GB"];
207
+ let value = bytes;
208
+ let unit = 0;
209
+ while (value >= 1024 && unit < units.length - 1) {
210
+ value /= 1024;
211
+ unit += 1;
212
+ }
213
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
214
+ }
215
+ function formatTime(ms) {
216
+ if (typeof ms !== "number" || ms <= 0) return "";
217
+ try {
218
+ return new Date(ms).toLocaleString();
219
+ } catch {
220
+ return "";
221
+ }
222
+ }
223
+ function baseName(path) {
224
+ if (typeof path !== "string" || path === "") return "";
225
+ const parts = path.split(/[\\/]/).filter((part) => part !== "");
226
+ return parts.length > 0 ? parts[parts.length - 1] : path;
227
+ }
228
+
229
+ // src/client/i18n.ts
230
+ var NS = "session-curator";
231
+ var zh = {
232
+ sectionLabel: "会话管理",
233
+ menuDelete: "删除会话",
234
+ titleArchived: "已归档",
235
+ titlePinned: "已置顶",
236
+ titleTrash: "回收站",
237
+ refresh: "刷新",
238
+ loading: "读取中…",
239
+ retry: "重试",
240
+ emptyArchived: "还没有归档的会话。在会话行的「⋯」菜单里归档后会出现在这里。",
241
+ emptyPinned: "没有置顶的会话。",
242
+ emptyTrash: "回收站是空的。",
243
+ unarchive: "取消归档",
244
+ pin: "置顶",
245
+ unpin: "取消置顶",
246
+ restore: "恢复",
247
+ restoreUnavailable: "无法恢复(缺元数据)",
248
+ purge: "永久删除",
249
+ moveToTrash: "移入回收站",
250
+ rowActions: "更多操作",
251
+ selectAll: "全选",
252
+ selectNone: "取消选择",
253
+ selected: "已选 {n} 项",
254
+ emptyTrashAll: "清空回收站",
255
+ total: "合计 {n} 个会话 · {size}",
256
+ working: "处理中…",
257
+ sessionRunning: "会话正在运行,先停止它再删除。",
258
+ confirmTrashTitle: "删除会话",
259
+ confirmTrashBody: "「{title}」会被移到回收站,之后可以在「设置 → 会话管理」里恢复。",
260
+ confirmTrashAction: "移到回收站",
261
+ confirmPurgeTitle: "永久删除",
262
+ confirmPurgeBody: "「{title}」将从回收站永久删除,日志文件会被彻底移除,无法恢复。",
263
+ confirmPurgeManyBody: "选中的 {n} 个会话将从回收站永久删除,无法恢复。",
264
+ confirmPurgeAction: "永久删除",
265
+ ackPurge: "我明白这无法撤销",
266
+ cancel: "取消",
267
+ close: "关闭",
268
+ noProject: "(未知目录)",
269
+ files: "{n} 个文件",
270
+ groupingOn: "分组:工作区",
271
+ groupingOff: "分组:平铺",
272
+ groupHint: "按会话原来的工作区文件夹归类;点分组标题折叠,左边的勾选框选中整个工作区",
273
+ sessions: "{n} 个会话",
274
+ workspaces: "{n} 个工作区",
275
+ selectGroup: "全选该工作区",
276
+ clearGroup: "取消选中该工作区",
277
+ selectAllShort: "全选",
278
+ clearShort: "取消",
279
+ collapse: "折叠",
280
+ expand: "展开",
281
+ // 跨页签的"整个工作区一起处理"(分组标题右侧的「⋯」)。
282
+ workspaceActions: "整个工作区",
283
+ workspaceMenu: "工作区操作",
284
+ archiveWorkspace: "整个工作区归档",
285
+ trashWorkspace: "整个工作区移入回收站",
286
+ workspaceRunningHint: "这个工作区里有会话正在运行,危险动作已禁用",
287
+ // 运行中徽标(宿主用官方的 workspace/session-activity 判定)。
288
+ running: "运行中",
289
+ runningHint: "会话正在运行,先停止它再操作",
290
+ // 导出 / 定位。
291
+ copyPath: "复制路径",
292
+ revealInFolder: "在资源管理器中显示",
293
+ pathCopied: "已复制路径:{path}",
294
+ pathCopyFailed: "复制失败,路径是:{path}",
295
+ revealFailed: "没能打开文件管理器({reason}),路径是:{path}",
296
+ // 磁盘占用视图。
297
+ titleUsage: "磁盘占用",
298
+ usageTitle: "磁盘占用",
299
+ usageTotal: "共 {n} 个会话 · {size}(磁盘上 {disk} 个)",
300
+ usageByWorkspace: "按工作区",
301
+ usageLargest: "最占地方的会话",
302
+ usageOldest: "最久没打开的会话",
303
+ usageLoading: "正在统计…",
304
+ usageArchived: "已归档",
305
+ usagePinned: "已置顶",
306
+ usageTrashed: "在回收站",
307
+ usageSuggestArchive: "建议归档",
308
+ usageArchive: "归档",
309
+ usageUnarchive: "取消归档",
310
+ usageNoData: "没有可统计的会话。",
311
+ titleConfig: "配置",
312
+ configTitle: "基础设置",
313
+ configTab: "默认页签",
314
+ configTabHint: "打开这里时先显示哪个页签",
315
+ configGrouping: "按工作区分组",
316
+ configGroupingHint: "关掉就是平铺列表;顶部的「分组」按钮改的是同一个值",
317
+ configGroupDefault: "分组默认状态",
318
+ configGroupDefaultHint: "以后打开的文件夹默认是折叠还是展开;手动点过的文件夹保留手动状态",
319
+ groupExpanded: "展开",
320
+ groupCollapsed: "折叠",
321
+ configSidebarArchived: "侧边栏显示已归档会话",
322
+ configSidebarArchivedHint: "对应侧边栏「视图选项」里的「隐藏已归档 / 全部对话(显示已归档)」;存的是官方那份设置,改完要刷新页面才生效",
323
+ configSidebarRefreshHint: "已写入官方设置,但侧边栏要刷新页面才会跟着变。",
324
+ refreshPage: "刷新页面",
325
+ configMeta: "行内显示体积与文件数",
326
+ configMetaHint: "关掉后每行只留标题(平铺时仍会显示文件夹)",
327
+ configOrder: "排序",
328
+ configOrderHint: "原顺序就是宿主给的顺序(回收站里是最近删除在前)",
329
+ orderHost: "原顺序",
330
+ orderTitle: "标题",
331
+ orderBytes: "体积",
332
+ orderCreatedAt: "创建时间",
333
+ orderDeletedAt: "删除时间",
334
+ // 搜索与过滤(只影响显示;批量操作与计数仍按整个页签算)。
335
+ searchLabel: "搜索会话",
336
+ searchPlaceholder: "搜索标题或目录…",
337
+ searchClear: "清除搜索",
338
+ timeFilterLabel: "时间",
339
+ timeAll: "全部",
340
+ timeRecent: "最近 7 天",
341
+ filtered: "已过滤出 {n} 个会话(共 {total} 个)",
342
+ emptyFiltered: "没有符合搜索条件的会话。",
343
+ // 回收站保留策略(永久删除:永远先预览、再确认,绝不自动后台清理)。
344
+ configTrashDays: "回收站保留天数",
345
+ configTrashDaysHint: "「清理 N 天前」按这个天数算;不会自动执行,每次都要先看预览再确认",
346
+ retentionDays: "{n} 天",
347
+ retentionCustom: "自定义",
348
+ customDaysPlaceholder: "天数",
349
+ customDaysLabel: "自定义保留天数",
350
+ customDaysHint: "填 {min}–{max} 之间的整数;留空就用上面的档位",
351
+ customDaysInvalid: "天数要在 {min}–{max} 之间",
352
+ trashCleanup: "删除 {n} 天前的会话",
353
+ trashPreview: "将删除 {n} 个会话 · {size}",
354
+ trashPreviewNone: "没有超过 {days} 天的会话",
355
+ trashPreviewLoading: "正在统计…",
356
+ confirmPurgeOldTitle: "清理旧会话",
357
+ confirmPurgeOldBody: "将永久删除 {n} 个在 {days} 天前移入回收站的会话({size}),无法恢复。",
358
+ ackPurgeOld: "我明白这无法撤销",
359
+ configAutoUpdate: "自动检查更新",
360
+ configAutoUpdateHint: "打开这个页面时问一次 npm registry(10 分钟内有缓存)",
361
+ configReset: "恢复默认",
362
+ configResetDone: "已恢复默认设置",
363
+ configSaved: "已保存",
364
+ aboutTitle: "关于",
365
+ versionLabel: "版本",
366
+ updateChecking: "正在检查更新…",
367
+ updateLatest: "已是最新版本",
368
+ updateAvailable: "有新版本 {latest}(当前 {current})",
369
+ updateFailed: "检查更新失败:{reason}",
370
+ updateRetry: "重新检查",
371
+ updateCheckNow: "检查更新",
372
+ updateStepsTitle: "升级步骤",
373
+ updateStepOne: "在终端里升级包(按你自己的 profile 名替换 desktop):",
374
+ updateStepTwo: "重启 DSH 让宿主半区生效;只改了客户端半区的话刷新页面就够。",
375
+ updateOpen: "打开 npm 页面",
376
+ // 会话行 hover 卡片(`sidebar.session.row.hover`)。
377
+ hoverStats: "{size} · {n} 个文件",
378
+ hoverCwd: "原目录",
379
+ hoverArchived: "已归档",
380
+ hoverPinned: "已置顶",
381
+ hoverTrashed: "在回收站",
382
+ hoverDeleted: "删除于",
383
+ // 删除成功后的撤销提示(`Toast`)。
384
+ toastTrashed: "「{title}」已移入回收站",
385
+ toastTrashedMany: "{n} 个会话已移入回收站",
386
+ toastUndo: "撤销",
387
+ toastUndoFailed: "撤销失败:{reason}"
388
+ };
389
+ var en = {
390
+ sectionLabel: "Session manager",
391
+ menuDelete: "Delete session",
392
+ titleArchived: "Archived",
393
+ titlePinned: "Pinned",
394
+ titleTrash: "Trash",
395
+ refresh: "Refresh",
396
+ loading: "Loading…",
397
+ retry: "Retry",
398
+ emptyArchived: "No archived sessions yet. Archive one from its “⋯” menu and it shows up here.",
399
+ emptyPinned: "No pinned sessions.",
400
+ emptyTrash: "The trash is empty.",
401
+ unarchive: "Unarchive",
402
+ pin: "Pin",
403
+ unpin: "Unpin",
404
+ restore: "Restore",
405
+ restoreUnavailable: "Cannot restore (no metadata)",
406
+ purge: "Delete permanently",
407
+ moveToTrash: "Move to trash",
408
+ rowActions: "More actions",
409
+ selectAll: "Select all",
410
+ selectNone: "Clear selection",
411
+ selected: "{n} selected",
412
+ emptyTrashAll: "Empty trash",
413
+ total: "{n} sessions · {size}",
414
+ working: "Working…",
415
+ sessionRunning: "This session is running; stop it before deleting.",
416
+ confirmTrashTitle: "Delete session",
417
+ confirmTrashBody: "“{title}” moves to the trash. You can restore it later under Settings → Session manager.",
418
+ confirmTrashAction: "Move to trash",
419
+ confirmPurgeTitle: "Delete permanently",
420
+ confirmPurgeBody: "“{title}” is removed from the trash for good; its log files are gone and cannot be recovered.",
421
+ confirmPurgeManyBody: "{n} selected sessions are removed from the trash for good and cannot be recovered.",
422
+ confirmPurgeAction: "Delete permanently",
423
+ ackPurge: "I understand this cannot be undone",
424
+ cancel: "Cancel",
425
+ close: "Close",
426
+ noProject: "(unknown directory)",
427
+ files: "{n} files",
428
+ groupingOn: "Grouped: workspace",
429
+ groupingOff: "Grouped: off",
430
+ groupHint: "Group sessions under their original workspace folder; click a header to collapse it, the checkbox selects the whole workspace",
431
+ sessions: "{n} sessions",
432
+ workspaces: "{n} workspaces",
433
+ selectGroup: "Select this workspace",
434
+ clearGroup: "Clear this workspace",
435
+ selectAllShort: "Select all",
436
+ clearShort: "Clear",
437
+ collapse: "Collapse",
438
+ expand: "Expand",
439
+ // Cross-tab "handle the whole workspace" (the ⋯ on a folder header).
440
+ workspaceActions: "Whole workspace",
441
+ workspaceMenu: "Workspace actions",
442
+ archiveWorkspace: "Archive this whole workspace",
443
+ trashWorkspace: "Move this whole workspace to the trash",
444
+ workspaceRunningHint: "A session in this workspace is running, so the destructive actions are off",
445
+ // Running badge (the host asks the official workspace/session-activity hook).
446
+ running: "Running",
447
+ runningHint: "This session is running; stop it first",
448
+ // Export / locate.
449
+ copyPath: "Copy path",
450
+ revealInFolder: "Show in file explorer",
451
+ pathCopied: "Path copied: {path}",
452
+ pathCopyFailed: "Copy failed — the path is: {path}",
453
+ revealFailed: "Could not open the file explorer ({reason}); the path is: {path}",
454
+ // Disk usage view.
455
+ titleUsage: "Disk usage",
456
+ usageTitle: "Disk usage",
457
+ usageTotal: "{n} sessions · {size} ({disk} on disk)",
458
+ usageByWorkspace: "By workspace",
459
+ usageLargest: "Largest sessions",
460
+ usageOldest: "Least recently opened",
461
+ usageLoading: "Measuring…",
462
+ usageArchived: "Archived",
463
+ usagePinned: "Pinned",
464
+ usageTrashed: "In trash",
465
+ usageSuggestArchive: "Archive suggested",
466
+ usageArchive: "Archive",
467
+ usageUnarchive: "Unarchive",
468
+ usageNoData: "No sessions to measure.",
469
+ titleConfig: "Settings",
470
+ configTitle: "Basics",
471
+ configTab: "Default tab",
472
+ configTabHint: "Which tab the panel opens on",
473
+ configGrouping: "Group by workspace",
474
+ configGroupingHint: "Off renders a flat list; the top “Grouped” button edits the same value",
475
+ configGroupDefault: "Default folder state",
476
+ configGroupDefaultHint: "Whether folders start collapsed or expanded; folders you toggled keep their manual state",
477
+ groupExpanded: "Expanded",
478
+ groupCollapsed: "Collapsed",
479
+ configSidebarArchived: "Show archived sessions in the sidebar",
480
+ configSidebarArchivedHint: "Mirrors “Hide archived / All conversations” in the sidebar's view options; it is the official setting, so a page refresh applies it",
481
+ configSidebarRefreshHint: "Written to the official setting — the sidebar only follows it after a page refresh.",
482
+ refreshPage: "Refresh page",
483
+ configMeta: "Show size and file count per row",
484
+ configMetaHint: "Off leaves only the title (the folder still shows in the flat list)",
485
+ configOrder: "Order",
486
+ configOrderHint: "Host order is what the host sent (the trash is most-recently-deleted first)",
487
+ orderHost: "Host",
488
+ orderTitle: "Title",
489
+ orderBytes: "Size",
490
+ orderCreatedAt: "Created",
491
+ orderDeletedAt: "Deleted",
492
+ // Search and filtering (display only; batch actions and counts still cover the whole tab).
493
+ searchLabel: "Search sessions",
494
+ searchPlaceholder: "Search title or folder…",
495
+ searchClear: "Clear search",
496
+ timeFilterLabel: "Time",
497
+ timeAll: "All",
498
+ timeRecent: "Last 7 days",
499
+ filtered: "{n} of {total} sessions match",
500
+ emptyFiltered: "No session matches the search.",
501
+ // Trash retention (permanent deletion: always preview, then confirm; never automatic).
502
+ configTrashDays: "Trash retention",
503
+ configTrashDaysHint: "How “clean up N days ago” counts the days; it never runs on its own — every cleanup needs a preview and a confirmation",
504
+ retentionDays: "{n} days",
505
+ retentionCustom: "Custom",
506
+ customDaysPlaceholder: "Days",
507
+ customDaysLabel: "Custom retention days",
508
+ customDaysHint: "An integer between {min} and {max}; leave it empty to use the preset above",
509
+ customDaysInvalid: "Days must be between {min} and {max}",
510
+ trashCleanup: "Delete sessions older than {n} days",
511
+ trashPreview: "{n} sessions · {size} will be deleted",
512
+ trashPreviewNone: "Nothing is older than {days} days",
513
+ trashPreviewLoading: "Counting…",
514
+ confirmPurgeOldTitle: "Clean up old sessions",
515
+ confirmPurgeOldBody: "{n} sessions moved to the trash more than {days} days ago ({size}) are deleted for good and cannot be recovered.",
516
+ ackPurgeOld: "I understand this cannot be undone",
517
+ configAutoUpdate: "Check for updates automatically",
518
+ configAutoUpdateHint: "Asks the npm registry when this page opens (cached for 10 minutes)",
519
+ configReset: "Reset to defaults",
520
+ configResetDone: "Defaults restored",
521
+ configSaved: "Saved",
522
+ aboutTitle: "About",
523
+ versionLabel: "Version",
524
+ updateChecking: "Checking for updates…",
525
+ updateLatest: "You are on the latest version",
526
+ updateAvailable: "Version {latest} is available (you have {current})",
527
+ updateFailed: "Update check failed: {reason}",
528
+ updateRetry: "Check again",
529
+ updateCheckNow: "Check for updates",
530
+ updateStepsTitle: "How to upgrade",
531
+ updateStepOne: "Upgrade the package in a terminal (replace desktop with your profile name):",
532
+ updateStepTwo: "Restart DSH so the host half reloads; a client-half change only needs a page refresh.",
533
+ updateOpen: "Open the npm page",
534
+ // Session-row hover card (`sidebar.session.row.hover`).
535
+ hoverStats: "{size} · {n} files",
536
+ hoverCwd: "Folder",
537
+ hoverArchived: "Archived",
538
+ hoverPinned: "Pinned",
539
+ hoverTrashed: "In trash",
540
+ hoverDeleted: "Deleted",
541
+ // Undo notice after a successful delete (`Toast`).
542
+ toastTrashed: "“{title}” moved to the trash",
543
+ toastTrashedMany: "{n} sessions moved to the trash",
544
+ toastUndo: "Undo",
545
+ toastUndoFailed: "Undo failed: {reason}"
546
+ };
547
+ var dictionaries = { zh, en };
548
+ function text(t, key, params) {
549
+ let value = t(key);
550
+ if (params) {
551
+ for (const name of Object.keys(params)) value = value.split(`{${name}}`).join(String(params[name]));
552
+ }
553
+ return value;
554
+ }
555
+ var TAB_LABEL = {
556
+ archived: "titleArchived",
557
+ pinned: "titlePinned",
558
+ trash: "titleTrash"
559
+ };
560
+
561
+ // src/client/snapshot.ts
562
+ var last = null;
563
+ function cachedSnapshot() {
564
+ return last;
565
+ }
566
+ function rememberSnapshot(value) {
567
+ last = value;
568
+ }
569
+ function forgetSnapshot() {
570
+ last = null;
571
+ }
572
+
573
+ // src/client/styles.ts
574
+ var ROW_ICON_BUTTON = {
575
+ display: "inline-flex",
576
+ alignItems: "center",
577
+ justifyContent: "center",
578
+ width: "16px",
579
+ height: "16px",
580
+ padding: 0,
581
+ margin: 0,
582
+ border: "none",
583
+ background: "transparent",
584
+ borderRadius: "var(--dsw-radius-xs)",
585
+ cursor: "pointer",
586
+ flex: "none"
587
+ };
588
+ var S = {
589
+ column: { display: "flex", flexDirection: "column", gap: "10px", padding: "4px 0", minWidth: 0 },
590
+ rowLine: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
591
+ tabBar: { display: "flex", alignItems: "center", gap: "6px" },
592
+ card: {
593
+ border: "1px solid var(--dsw-alias-border-l1)",
594
+ borderRadius: "10px",
595
+ overflow: "hidden"
596
+ },
597
+ list: { display: "flex", flexDirection: "column" },
598
+ item: {
599
+ display: "flex",
600
+ alignItems: "center",
601
+ gap: "10px",
602
+ padding: "8px 10px",
603
+ borderTop: "1px solid var(--dsw-alias-border-l1)"
604
+ },
605
+ itemMain: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0, flex: "1 1 auto" },
606
+ /**
607
+ * 标题那一行:标题 + 状态胶囊(「运行中」)。
608
+ *
609
+ * 胶囊 `flex: 0 0 auto`、标题 `minWidth: 0` + 省略号 —— 长标题只挤自己那一段,
610
+ * 不会把胶囊压成两行(与分组标题那次"长路径挤坏按钮"同一条教训,见 groupAction)。
611
+ */
612
+ itemTitleRow: { display: "flex", alignItems: "center", gap: "6px", minWidth: 0 },
613
+ itemTitle: {
614
+ color: "var(--dsw-alias-label-primary)",
615
+ fontSize: "13px",
616
+ overflow: "hidden",
617
+ textOverflow: "ellipsis",
618
+ whiteSpace: "nowrap",
619
+ minWidth: 0
620
+ },
621
+ itemMeta: {
622
+ color: "var(--dsw-alias-label-secondary)",
623
+ fontSize: "11px",
624
+ overflow: "hidden",
625
+ textOverflow: "ellipsis",
626
+ whiteSpace: "nowrap"
627
+ },
628
+ /** 状态胶囊:与 hover 卡片那几个同款(带 alpha 的叠加色,深浅主题都有效)。 */
629
+ badge: {
630
+ color: "var(--dsw-alias-label-secondary)",
631
+ fontSize: "10px",
632
+ lineHeight: "16px",
633
+ padding: "0 6px",
634
+ borderRadius: "8px",
635
+ background: "var(--dsw-alias-interactive-bg-active)",
636
+ flex: "0 0 auto"
637
+ },
638
+ badgeDanger: {
639
+ color: "var(--dsw-alias-state-error-primary)",
640
+ fontSize: "10px",
641
+ lineHeight: "16px",
642
+ padding: "0 6px",
643
+ borderRadius: "8px",
644
+ background: "var(--dsw-alias-interactive-bg-active)",
645
+ flex: "0 0 auto"
646
+ },
647
+ /** 「运行中」:同款胶囊,但用业务蓝表示"它是活的",不是危险。 */
648
+ badgeRunning: {
649
+ color: "var(--dsw-alias-state-business-primary)",
650
+ fontSize: "10px",
651
+ lineHeight: "16px",
652
+ padding: "0 6px",
653
+ borderRadius: "8px",
654
+ background: "var(--dsw-alias-interactive-bg-active)",
655
+ flex: "0 0 auto"
656
+ },
657
+ itemActions: { display: "flex", alignItems: "center", gap: "6px", flex: "0 0 auto" },
658
+ /**
659
+ * 「⋯」触发器:图标按钮,弱化成次要前景色(跟侧边栏行的图标按钮一致)。
660
+ * `flex: 0 0 auto` 防"长标题挤坏按钮":行内标题再长也只能挤 `itemMain`
661
+ *(那边有 minWidth: 0 + 省略号),别让收缩落到这个按钮上。
662
+ */
663
+ rowMenuButton: { color: "var(--dsw-alias-label-secondary)", padding: "0 6px", flex: "0 0 auto" },
664
+ /**
665
+ * 文件夹那一行的底色。**不能用 `--dsw-alias-bg-layer-*`**:浅色主题里
666
+ * base / layer-1 / layer-2 / layer-3 都指向同一个颜色(`neutral-bluish-00`),
667
+ * 铺上去等于没铺(这就是之前"看不出区别"的原因)。
668
+ * `interactive-bg-active` 是带 alpha 的叠加色(#2631481a,约 10%),
669
+ * 深浅主题都有效;比行 hover 用的 `interactive-bg-hover`(约 6%)略重一点,
670
+ * 这样"文件夹条"和"鼠标划过的会话行"不会糊在一起。
671
+ */
672
+ groupHead: {
673
+ display: "flex",
674
+ alignItems: "center",
675
+ gap: "10px",
676
+ padding: "6px 10px",
677
+ background: "var(--dsw-alias-interactive-bg-active)",
678
+ borderTop: "1px solid var(--dsw-alias-border-l1)"
679
+ },
680
+ groupToggle: {
681
+ display: "flex",
682
+ alignItems: "center",
683
+ gap: "8px",
684
+ minWidth: 0,
685
+ flex: "1 1 auto",
686
+ background: "transparent",
687
+ border: "none",
688
+ padding: 0,
689
+ margin: 0,
690
+ color: "inherit",
691
+ font: "inherit",
692
+ textAlign: "left",
693
+ cursor: "pointer"
694
+ },
695
+ groupCaret: { color: "var(--dsw-alias-label-tertiary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
696
+ groupIcon: { color: "var(--dsw-alias-label-secondary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
697
+ groupTitle: {
698
+ color: "var(--dsw-alias-label-primary)",
699
+ fontSize: "12px",
700
+ fontWeight: 600,
701
+ overflow: "hidden",
702
+ textOverflow: "ellipsis",
703
+ whiteSpace: "nowrap",
704
+ flex: "0 1 auto"
705
+ },
706
+ groupMeta: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", flex: "0 0 auto" },
707
+ /**
708
+ * 分组标题右侧的「全选 / 取消」。
709
+ *
710
+ * 为什么必须写这两条:路径一长,`groupHead` 这行必然溢出,浏览器按 flex 基准**按比例**
711
+ * 分配收缩量 —— 右侧按钮也会被挤窄。而 DSH 的 `Button` 是 `display: inline-flex`
712
+ * 且**没有 `white-space: nowrap`**,窄到一定程度「全选」两个字就在按钮里折成两行。
713
+ * 让它完全不参与收缩(`0 0 auto`)、再兜一条 `nowrap`,收缩就只剩左边的折叠按钮承担:
714
+ * 标题 / 路径各自 `minWidth: 0` + 省略号,该截断的地方截断。
715
+ */
716
+ groupAction: { flex: "0 0 auto", whiteSpace: "nowrap" },
717
+ groupPath: {
718
+ color: "var(--dsw-alias-label-tertiary)",
719
+ fontSize: "11px",
720
+ overflow: "hidden",
721
+ textOverflow: "ellipsis",
722
+ whiteSpace: "nowrap",
723
+ minWidth: 0,
724
+ flex: "0 1 auto"
725
+ },
726
+ empty: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "14px 10px", textAlign: "center" },
727
+ error: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px" },
728
+ footer: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", display: "flex", gap: "10px", flexWrap: "wrap" },
729
+ /**
730
+ * 数据页签上的过滤工具条:搜索框在左、时间档在右。
731
+ *
732
+ * `flexWrap` 是必需的:设置页本身可能很窄(侧边栏展开时尤其),搜索框的
733
+ * `minWidth` 兜住它不被压成一条线,挤不下时时间档换行,而不是把输入框挤没。
734
+ */
735
+ filterBar: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
736
+ /** 搜索框自己撑开剩余宽度;给个下限,窄屏下也不至于只剩图标。 */
737
+ searchInput: { flex: "1 1 auto", minWidth: "160px" },
738
+ /** 把时间档推到右边(搜索框与它之间那段空白)。 */
739
+ filterSpacer: { flex: "1 1 auto" },
740
+ // ── 磁盘占用视图(配置页里的一块) ─────────────────────────────────────
741
+ usageBody: { display: "flex", flexDirection: "column", padding: "0 12px 12px", gap: "2px" },
742
+ usageHeading: {
743
+ color: "var(--dsw-alias-label-tertiary)",
744
+ fontSize: "11px",
745
+ marginTop: "8px",
746
+ marginBottom: "2px"
747
+ },
748
+ /** 一条工作区横条:轨道 + 名字 + 数字。名字可以很长,所以它自己省略。 */
749
+ usageBarRow: { display: "flex", alignItems: "center", gap: "8px", minWidth: 0 },
750
+ usageBarTrack: {
751
+ width: "70px",
752
+ height: "6px",
753
+ borderRadius: "3px",
754
+ background: "var(--dsw-alias-interactive-bg-active)",
755
+ flex: "0 0 auto",
756
+ overflow: "hidden"
757
+ },
758
+ usageBarFill: { display: "block", height: "100%", background: "var(--dsw-alias-state-business-primary)" },
759
+ usageBarName: {
760
+ color: "var(--dsw-alias-label-primary)",
761
+ fontSize: "12px",
762
+ overflow: "hidden",
763
+ textOverflow: "ellipsis",
764
+ whiteSpace: "nowrap",
765
+ minWidth: 0,
766
+ flex: "1 1 auto"
767
+ },
768
+ usageBarMeta: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", flex: "0 0 auto" },
769
+ /** 榜单里的一行:与列表行同一套几何,只是没有勾选框。 */
770
+ usageRow: { display: "flex", alignItems: "center", gap: "10px", padding: "6px 0", minWidth: 0 },
771
+ mono: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
772
+ ack: { display: "flex", alignItems: "center", gap: "8px", fontSize: "12px", color: "var(--dsw-alias-label-primary)" },
773
+ /** 顶部条:页签在左,动作靠 `marginInlineStart: auto` 推到右边。 */
774
+ header: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
775
+ headerActions: { display: "flex", alignItems: "center", gap: "8px", marginInlineStart: "auto" },
776
+ /** 配置页的一行:左边标签 + 说明,右边控件。 */
777
+ configRow: {
778
+ display: "flex",
779
+ alignItems: "center",
780
+ justifyContent: "space-between",
781
+ gap: "12px",
782
+ padding: "10px 12px",
783
+ borderTop: "1px solid var(--dsw-alias-border-l1)"
784
+ },
785
+ configMain: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
786
+ configLabel: { color: "var(--dsw-alias-label-primary)", fontSize: "13px" },
787
+ configHint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px" },
788
+ configControl: { display: "flex", alignItems: "center", gap: "8px", flex: "0 0 auto" },
789
+ /** 下拉锚点:文字 + 双向箭头图标,别被挤窄(长标签在按钮里折行是这个仓库踩过的坑)。 */
790
+ dropdownAnchor: { flex: "0 0 auto" },
791
+ dropdownButton: { flex: "0 0 auto", whiteSpace: "nowrap" },
792
+ /** 保留策略那一格:档位下拉 + 自定义天数输入框。 */
793
+ retentionDays: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap", justifyContent: "flex-end" },
794
+ customDaysInput: { width: "88px", flex: "0 0 auto" },
795
+ /** 天数非法时只把边框标红,不动尺寸(否则整行会跳一下)。 */
796
+ customDaysInvalid: { borderColor: "var(--dsw-alias-state-error-primary)" },
797
+ configFoot: {
798
+ display: "flex",
799
+ alignItems: "center",
800
+ gap: "8px",
801
+ flexWrap: "wrap",
802
+ padding: "10px 12px",
803
+ borderTop: "1px solid var(--dsw-alias-border-l1)"
804
+ },
805
+ /** 版本号:浅色小字。 */
806
+ version: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px" },
807
+ /**
808
+ * 升级提示块。底色用带 alpha 的 `interactive-bg-active` —— 浅色主题里
809
+ * base / layer-1 / layer-2 / layer-3 是同一个颜色,铺上去等于没铺。
810
+ */
811
+ updateBox: {
812
+ display: "flex",
813
+ flexDirection: "column",
814
+ gap: "6px",
815
+ padding: "10px 12px",
816
+ borderTop: "1px solid var(--dsw-alias-border-l1)",
817
+ background: "var(--dsw-alias-interactive-bg-active)"
818
+ },
819
+ updateTitle: { color: "var(--dsw-alias-label-primary)", fontSize: "12px", fontWeight: 600 },
820
+ updateSteps: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", display: "flex", flexDirection: "column", gap: "4px" },
821
+ code: {
822
+ display: "block",
823
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
824
+ fontSize: "11px",
825
+ color: "var(--dsw-alias-label-primary)",
826
+ background: "var(--dsw-alias-interactive-bg-active)",
827
+ borderRadius: "6px",
828
+ padding: "6px 8px",
829
+ overflowX: "auto",
830
+ whiteSpace: "pre"
831
+ },
832
+ link: { color: "var(--dsw-alias-label-primary)", fontSize: "11px" },
833
+ /**
834
+ * 会话行 hover 卡片里我们那一节。卡片自己的 `hoverContent` 已经是纵向 flex 且有间距,
835
+ * 这里只补一点点上边距,不加分隔线 —— 上面那行是官方画的相对时间。
836
+ */
837
+ hoverBlock: { display: "flex", flexDirection: "column", gap: "3px", marginTop: "2px", minWidth: 0 },
838
+ hoverLine: { display: "flex", alignItems: "center", gap: "6px", minWidth: 0 },
839
+ hoverLabel: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", flex: "0 0 auto" },
840
+ hoverMeta: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", flex: "0 0 auto" },
841
+ /** 原目录可能很长:让它自己省略,别把卡片撑宽。 */
842
+ hoverPath: {
843
+ color: "var(--dsw-alias-label-secondary)",
844
+ fontSize: "11px",
845
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
846
+ overflow: "hidden",
847
+ textOverflow: "ellipsis",
848
+ whiteSpace: "nowrap",
849
+ minWidth: 0,
850
+ flex: "0 1 auto"
851
+ },
852
+ hoverBadges: { display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" },
853
+ /** 状态胶囊:底色用带 alpha 的 `interactive-bg-active`(浅色主题里 layer 系列是同一个颜色)。 */
854
+ hoverBadge: {
855
+ color: "var(--dsw-alias-label-secondary)",
856
+ fontSize: "10px",
857
+ lineHeight: "16px",
858
+ padding: "0 6px",
859
+ borderRadius: "8px",
860
+ background: "var(--dsw-alias-interactive-bg-active)",
861
+ flex: "0 0 auto"
862
+ },
863
+ hoverBadgeDanger: {
864
+ color: "var(--dsw-alias-state-error-primary)",
865
+ fontSize: "10px",
866
+ lineHeight: "16px",
867
+ padding: "0 6px",
868
+ borderRadius: "8px",
869
+ background: "var(--dsw-alias-interactive-bg-active)",
870
+ flex: "0 0 auto"
871
+ },
872
+ /**
873
+ * 侧边栏会话行 hover 时那个「移入回收站」图标按钮(见
874
+ * `components/trash-row-button.ts`):16×16 的朴素按钮,**两种状态都没有底色**,
875
+ * 只有前景色变深一档。
876
+ *
877
+ * `color-mix` 是 DSH 自己的产物里就在用的写法(例如 ui-deliverables 的
878
+ * `color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent)`)。
879
+ * 为什么不换 `state-error-secondary`:那个 token 在浅色主题里是 red-400,比 primary
880
+ * (red-600)**更浅**,方向正好相反。
881
+ */
882
+ rowIconButton: { ...ROW_ICON_BUTTON, color: "var(--dsw-alias-state-error-primary)" },
883
+ rowIconButtonHover: { ...ROW_ICON_BUTTON, color: "color-mix(in srgb, var(--dsw-alias-state-error-primary) 78%, #000)" }
884
+ };
885
+
886
+ // src/client/toast.ts
887
+ var toastStore = createStore(null);
888
+ var sequence = 0;
889
+ function showTrashUndo(ids, title) {
890
+ if (ids.length === 0) return;
891
+ sequence += 1;
892
+ toastStore.set({ seq: sequence, ids: [...ids], title, error: null });
893
+ }
894
+ function showNotice(message) {
895
+ if (message === "") return;
896
+ sequence += 1;
897
+ toastStore.set({ seq: sequence, ids: [], title: message, error: null });
898
+ }
899
+ function dismissToast() {
900
+ toastStore.set(null);
901
+ }
902
+ function failToast(reason) {
903
+ const current2 = toastStore.get();
904
+ if (current2 === null) return;
905
+ toastStore.set({ ...current2, error: reason });
906
+ }
907
+ async function undoTrash() {
908
+ const current2 = toastStore.get();
909
+ if (current2 === null || current2.error !== null) return;
910
+ try {
911
+ const value = await call("restore", { ids: current2.ids });
912
+ const failure = firstFailure(value.results);
913
+ if (failure !== null) {
914
+ failToast(failure);
915
+ return;
916
+ }
917
+ mutationStore.set(mutationStore.get() + 1);
918
+ rememberSnapshot(value);
919
+ dismissToast();
920
+ } catch (thrown) {
921
+ failToast(reportError(thrown));
922
+ }
923
+ }
924
+
925
+ // src/client/components/dialog-host.ts
926
+ function DialogHost(props) {
927
+ const t = props.t;
928
+ const request = useStore(dialogStore);
929
+ const [busy, setBusy] = React2.useState(false);
930
+ const [ack, setAck] = React2.useState(false);
931
+ const [error, setError] = React2.useState(null);
932
+ const requestKey = request === null ? "" : `${request.kind}:${request.ids.join(",")}:${request.title}:${request.days ?? ""}`;
933
+ React2.useEffect(() => {
934
+ setAck(false);
935
+ setError(null);
936
+ }, [requestKey]);
937
+ if (request === null) return null;
938
+ const purgeOld = request.kind === "purge-old";
939
+ const purge = request.kind === "purge" || purgeOld;
940
+ const many = request.ids.length > 1;
941
+ const days = request.days ?? 0;
942
+ const title = purgeOld ? t("confirmPurgeOldTitle") : purge ? t("confirmPurgeTitle") : t("confirmTrashTitle");
943
+ const body = purgeOld ? text(t, "confirmPurgeOldBody", { n: request.count ?? 0, days, size: formatBytes(request.bytes ?? 0) }) : purge ? many ? text(t, "confirmPurgeManyBody", { n: request.ids.length }) : text(t, "confirmPurgeBody", { title: request.title }) : text(t, "confirmTrashBody", { title: request.title });
944
+ const close = () => {
945
+ if (busy) return;
946
+ dialogStore.set(null);
947
+ };
948
+ const confirm = async () => {
949
+ setBusy(true);
950
+ try {
951
+ const endpoint = purgeOld ? "purge-older-than" : purge ? "purge" : "trash";
952
+ const value = await call(endpoint, purgeOld ? { days } : { ids: request.ids });
953
+ const failure = firstFailure(value.results);
954
+ if (failure === null) {
955
+ dialogStore.set(null);
956
+ rememberSnapshot(value);
957
+ mutationStore.set(mutationStore.get() + 1);
958
+ if (!purge) showTrashUndo(request.ids, request.title);
959
+ } else {
960
+ setError(failure);
961
+ }
962
+ } catch (thrown) {
963
+ setError(reportError(thrown));
964
+ } finally {
965
+ setBusy(false);
966
+ }
967
+ };
968
+ return React2.createElement(
969
+ import_dsh_client_ui_primitives.Modal,
970
+ {
971
+ open: true,
972
+ onClose: close,
973
+ title,
974
+ closeLabel: t("close"),
975
+ description: body,
976
+ footer: React2.createElement(
977
+ "div",
978
+ { style: S.rowLine },
979
+ React2.createElement(import_dsh_client_ui_primitives.Button, { size: "md", variant: "ghost", disabled: busy, onClick: close }, t("cancel")),
980
+ React2.createElement(
981
+ import_dsh_client_ui_primitives.Button,
982
+ {
983
+ size: "md",
984
+ variant: "primary",
985
+ disabled: busy || purge && !ack,
986
+ onClick: () => void confirm()
987
+ },
988
+ purge ? t("confirmPurgeAction") : t("confirmTrashAction")
989
+ )
990
+ )
991
+ },
992
+ purge ? React2.createElement(
993
+ "div",
994
+ { style: S.column },
995
+ React2.createElement(
996
+ "label",
997
+ { style: S.ack },
998
+ React2.createElement("input", {
999
+ type: "checkbox",
1000
+ checked: ack,
1001
+ disabled: busy,
1002
+ onChange: (event) => setAck(Boolean(event.target.checked))
1003
+ }),
1004
+ purgeOld ? t("ackPurgeOld") : t("ackPurge")
1005
+ ),
1006
+ error ? React2.createElement("div", { style: S.error }, error) : null
1007
+ ) : error ? React2.createElement("div", { style: S.error }, error) : null
1008
+ );
1009
+ }
1010
+
1011
+ // src/client/components/session-hover.ts
1012
+ var React3 = __toESM(require("react"), 1);
1013
+
1014
+ // src/client/detail.ts
1015
+ var TTL_MS = 3e4;
1016
+ var cache = /* @__PURE__ */ new Map();
1017
+ var inflight = /* @__PURE__ */ new Map();
1018
+ var watching = false;
1019
+ function forgetDetails() {
1020
+ cache.clear();
1021
+ }
1022
+ function watchMutations() {
1023
+ if (watching) return;
1024
+ watching = true;
1025
+ mutationStore.subscribe(forgetDetails);
1026
+ }
1027
+ async function describeSession(sessionId) {
1028
+ watchMutations();
1029
+ const cached = cache.get(sessionId);
1030
+ if (cached !== void 0 && Date.now() - cached.at < TTL_MS) return cached.value;
1031
+ const running = inflight.get(sessionId);
1032
+ if (running !== void 0) return running;
1033
+ const request = call("describe", { ids: [sessionId] }).then((value) => {
1034
+ cache.set(sessionId, { at: Date.now(), value });
1035
+ return value;
1036
+ }).finally(() => {
1037
+ inflight.delete(sessionId);
1038
+ });
1039
+ inflight.set(sessionId, request);
1040
+ return request;
1041
+ }
1042
+
1043
+ // src/client/components/session-hover.ts
1044
+ function SessionHover(props) {
1045
+ const t = props.t;
1046
+ const [detail, setDetail] = React3.useState(null);
1047
+ React3.useEffect(() => {
1048
+ let live = true;
1049
+ describeSession(props.sessionId).then(
1050
+ (value) => {
1051
+ if (live) setDetail(value);
1052
+ },
1053
+ () => {
1054
+ }
1055
+ );
1056
+ return () => {
1057
+ live = false;
1058
+ };
1059
+ }, [props.sessionId]);
1060
+ if (detail === null) return null;
1061
+ const badges = [];
1062
+ if (detail.trashed) badges.push({ key: "trashed", label: t("hoverTrashed"), danger: true });
1063
+ if (detail.archived) badges.push({ key: "archived", label: t("hoverArchived"), danger: false });
1064
+ if (detail.pinned) badges.push({ key: "pinned", label: t("hoverPinned"), danger: false });
1065
+ const lines = [
1066
+ React3.createElement(
1067
+ "div",
1068
+ { key: "stats", style: S.hoverLine },
1069
+ React3.createElement("span", { style: S.hoverMeta }, text(t, "hoverStats", { size: formatBytes(detail.bytes), n: detail.files }))
1070
+ )
1071
+ ];
1072
+ if (detail.cwd) {
1073
+ lines.push(
1074
+ React3.createElement(
1075
+ "div",
1076
+ { key: "cwd", style: S.hoverLine },
1077
+ React3.createElement("span", { style: S.hoverLabel }, t("hoverCwd")),
1078
+ React3.createElement("span", { style: S.hoverPath, title: detail.cwd }, detail.cwd)
1079
+ )
1080
+ );
1081
+ }
1082
+ if (detail.trashed && detail.deletedAt) {
1083
+ lines.push(
1084
+ React3.createElement(
1085
+ "div",
1086
+ { key: "deletedAt", style: S.hoverLine },
1087
+ React3.createElement("span", { style: S.hoverLabel }, t("hoverDeleted")),
1088
+ React3.createElement("span", { style: S.hoverMeta }, formatTime(detail.deletedAt))
1089
+ )
1090
+ );
1091
+ }
1092
+ if (badges.length > 0) {
1093
+ lines.push(
1094
+ React3.createElement(
1095
+ "div",
1096
+ { key: "badges", style: S.hoverBadges },
1097
+ badges.map(
1098
+ (badge) => React3.createElement(
1099
+ "span",
1100
+ {
1101
+ key: badge.key,
1102
+ style: badge.danger ? S.hoverBadgeDanger : S.hoverBadge
1103
+ },
1104
+ badge.label
1105
+ )
1106
+ )
1107
+ )
1108
+ );
1109
+ }
1110
+ return React3.createElement("div", { style: S.hoverBlock }, lines);
1111
+ }
1112
+
1113
+ // src/client/components/settings-panel.ts
1114
+ var React8 = __toESM(require("react"), 1);
1115
+ var import_dsh_client_ui_primitives6 = require("@deepseek-ai/dsh-client-ui-primitives");
1116
+
1117
+ // src/client/export.ts
1118
+ function openSessionPath(sessionId, reveal) {
1119
+ return call("open", reveal === true ? { ids: [sessionId], target: "reveal" } : { ids: [sessionId], target: "path" });
1120
+ }
1121
+ async function copyText(value) {
1122
+ const clipboard = globalThis.navigator?.clipboard;
1123
+ if (clipboard !== void 0 && typeof clipboard.writeText === "function") {
1124
+ try {
1125
+ await clipboard.writeText(value);
1126
+ return true;
1127
+ } catch {
1128
+ }
1129
+ }
1130
+ try {
1131
+ const document = globalThis.document;
1132
+ if (document === void 0) return false;
1133
+ const area = document.createElement("textarea");
1134
+ area.value = value;
1135
+ area.style.position = "fixed";
1136
+ area.style.top = "-1000px";
1137
+ area.style.opacity = "0";
1138
+ document.body.appendChild(area);
1139
+ area.select();
1140
+ const copied = document.execCommand("copy");
1141
+ document.body.removeChild(area);
1142
+ return copied;
1143
+ } catch {
1144
+ return false;
1145
+ }
1146
+ }
1147
+
1148
+ // src/client/groups.ts
1149
+ function workspaceSummaries(archived, pinned, trashed) {
1150
+ const summaries = /* @__PURE__ */ new Map();
1151
+ const bucketOf = (key) => {
1152
+ let summary = summaries.get(key);
1153
+ if (summary === void 0) {
1154
+ summary = { key, archivableIds: [], trashableIds: [], activeIds: [] };
1155
+ summaries.set(key, summary);
1156
+ }
1157
+ return summary;
1158
+ };
1159
+ for (const rows of [archived, pinned]) {
1160
+ for (const row of rows) {
1161
+ const summary = bucketOf(groupKeyOf(row));
1162
+ summary.archivableIds.push(row.sessionId);
1163
+ summary.trashableIds.push(row.sessionId);
1164
+ if (row.active === true) summary.activeIds.push(row.sessionId);
1165
+ }
1166
+ }
1167
+ for (const row of trashed) bucketOf(groupKeyOf(row));
1168
+ return summaries;
1169
+ }
1170
+ function groupKeyOf(row) {
1171
+ if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
1172
+ return `cwd:${row.cwd.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase()}`;
1173
+ }
1174
+ if (typeof row.workspaceId === "string" && row.workspaceId !== "") return `id:${row.workspaceId}`;
1175
+ return "unknown";
1176
+ }
1177
+ function groupRows(rows, t) {
1178
+ const groups = /* @__PURE__ */ new Map();
1179
+ for (const row of rows) {
1180
+ const key = groupKeyOf(row);
1181
+ let group = groups.get(key);
1182
+ if (group === void 0) {
1183
+ const cwd = typeof row.cwd === "string" && row.cwd !== "" ? row.cwd : null;
1184
+ const title = (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "" ? row.workspaceTitle : null) ?? (cwd === null ? null : baseName(cwd) || cwd) ?? t("noProject");
1185
+ group = { key, title, cwd, bytes: 0, rows: [] };
1186
+ groups.set(key, group);
1187
+ }
1188
+ group.rows.push(row);
1189
+ if (typeof row.bytes === "number" && Number.isFinite(row.bytes)) group.bytes += row.bytes;
1190
+ }
1191
+ const list = [...groups.values()];
1192
+ list.sort((left, right) => {
1193
+ if (left.key === "unknown" || right.key === "unknown") {
1194
+ return left.key === right.key ? 0 : left.key === "unknown" ? 1 : -1;
1195
+ }
1196
+ return left.title.localeCompare(right.title);
1197
+ });
1198
+ return list;
1199
+ }
1200
+ function sortRows(rows, order) {
1201
+ const descending = (pick) => (left, right) => {
1202
+ const a = pick(left);
1203
+ const b = pick(right);
1204
+ const hasA = typeof a === "number" && Number.isFinite(a) && a > 0;
1205
+ const hasB = typeof b === "number" && Number.isFinite(b) && b > 0;
1206
+ if (!hasA && !hasB) return 0;
1207
+ if (!hasA) return 1;
1208
+ if (!hasB) return -1;
1209
+ return b - a;
1210
+ };
1211
+ if (order === "title") {
1212
+ return [...rows].sort((left, right) => String(left.title ?? "").localeCompare(String(right.title ?? "")));
1213
+ }
1214
+ if (order === "bytes") {
1215
+ return [...rows].sort((left, right) => (right.bytes ?? 0) - (left.bytes ?? 0));
1216
+ }
1217
+ if (order === "createdAt") return [...rows].sort(descending((row) => row.createdAt));
1218
+ if (order === "deletedAt") return [...rows].sort(descending((row) => row.deletedAt));
1219
+ return rows;
1220
+ }
1221
+ var RECENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
1222
+ function isTrashRow(row) {
1223
+ return typeof row.deletedAt === "number";
1224
+ }
1225
+ function matchesSearch(row, search) {
1226
+ const needle = search.trim().toLowerCase();
1227
+ if (needle === "") return true;
1228
+ const haystacks = [row.title, row.cwd, row.workspaceTitle];
1229
+ return haystacks.some((value) => typeof value === "string" && value.toLowerCase().includes(needle));
1230
+ }
1231
+ function timeStampOf(row) {
1232
+ if (isTrashRow(row)) return typeof row.deletedAt === "number" && row.deletedAt > 0 ? row.deletedAt : null;
1233
+ if (typeof row.createdAt === "number" && row.createdAt > 0) return row.createdAt;
1234
+ return null;
1235
+ }
1236
+ function matchesFilter(row, filter) {
1237
+ if (!matchesSearch(row, filter.search)) return false;
1238
+ if (filter.timeFilter !== "recent") return true;
1239
+ const stamp = timeStampOf(row);
1240
+ if (stamp === null) return false;
1241
+ return (filter.now ?? Date.now()) - stamp <= RECENT_WINDOW_MS;
1242
+ }
1243
+ function filterRows(rows, filter) {
1244
+ if (filter.search.trim() === "" && filter.timeFilter === "all") return rows;
1245
+ return rows.filter((row) => matchesFilter(row, filter));
1246
+ }
1247
+ function isFilterActive(filter) {
1248
+ return filter.search.trim() !== "" || filter.timeFilter !== "all";
1249
+ }
1250
+ function collapseKey(tab, groupKey) {
1251
+ return `${tab}:${groupKey}`;
1252
+ }
1253
+ function isGroupCollapsed(key, overrides, fallback) {
1254
+ const manual = overrides[key];
1255
+ return manual === void 0 ? fallback === "collapsed" : manual;
1256
+ }
1257
+ function toggledCollapse(key, overrides, fallback) {
1258
+ const next = { ...overrides };
1259
+ const value = !isGroupCollapsed(key, overrides, fallback);
1260
+ if (value === (fallback === "collapsed")) delete next[key];
1261
+ else next[key] = value;
1262
+ return next;
1263
+ }
1264
+
1265
+ // src/client/settings.ts
1266
+ var TRASH_RETENTION_DAYS = [7, 30, 90];
1267
+ var DEFAULT_TRASH_DAYS = 30;
1268
+ var TRASH_DAYS_MIN = 1;
1269
+ var TRASH_DAYS_MAX = 3650;
1270
+ function parseCustomDays(raw) {
1271
+ const trimmed = raw.trim();
1272
+ if (trimmed === "") return null;
1273
+ if (!/^\d+$/.test(trimmed)) return null;
1274
+ const value = Number(trimmed);
1275
+ if (!Number.isSafeInteger(value) || value < TRASH_DAYS_MIN || value > TRASH_DAYS_MAX) return null;
1276
+ return value;
1277
+ }
1278
+ function effectiveTrashDays(settings) {
1279
+ return parseCustomDays(settings.trashDaysCustom) ?? settings.trashDays;
1280
+ }
1281
+ var DEFAULT_SETTINGS = {
1282
+ tab: "archived",
1283
+ grouping: true,
1284
+ // 与 0.1.1 的行为一致:分组默认是**展开**的,老用户升级后界面不变。
1285
+ groupDefault: "expanded",
1286
+ showMeta: true,
1287
+ order: "host",
1288
+ autoUpdate: true,
1289
+ search: "",
1290
+ timeFilter: "all",
1291
+ trashDays: DEFAULT_TRASH_DAYS,
1292
+ trashDaysCustom: ""
1293
+ };
1294
+ var SEARCH_MAX_LENGTH = 200;
1295
+ var SETTINGS_KEY = "dsh-session-curator.settings";
1296
+ function readSettings() {
1297
+ try {
1298
+ const raw = globalThis.localStorage ? globalThis.localStorage.getItem(SETTINGS_KEY) : null;
1299
+ if (typeof raw !== "string" || raw === "") return { ...DEFAULT_SETTINGS };
1300
+ const parsed = JSON.parse(raw);
1301
+ if (parsed === null || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
1302
+ const next = { ...DEFAULT_SETTINGS };
1303
+ const pick = (key, validate) => {
1304
+ const value = validate(parsed[key]);
1305
+ if (value !== void 0) next[key] = value;
1306
+ };
1307
+ pick("tab", (value) => value === "archived" || value === "pinned" || value === "trash" ? value : void 0);
1308
+ pick("grouping", (value) => typeof value === "boolean" ? value : void 0);
1309
+ pick("groupDefault", (value) => value === "expanded" || value === "collapsed" ? value : void 0);
1310
+ pick("showMeta", (value) => typeof value === "boolean" ? value : void 0);
1311
+ pick(
1312
+ "order",
1313
+ (value) => value === "host" || value === "title" || value === "bytes" || value === "createdAt" || value === "deletedAt" ? value : void 0
1314
+ );
1315
+ pick("autoUpdate", (value) => typeof value === "boolean" ? value : void 0);
1316
+ pick("search", (value) => typeof value === "string" ? value.slice(0, SEARCH_MAX_LENGTH) : void 0);
1317
+ pick("timeFilter", (value) => value === "all" || value === "recent" ? value : void 0);
1318
+ pick("trashDays", (value) => typeof value === "number" && TRASH_RETENTION_DAYS.includes(value) ? value : void 0);
1319
+ pick("trashDaysCustom", (value) => typeof value === "string" ? value.slice(0, 8) : void 0);
1320
+ return next;
1321
+ } catch {
1322
+ return { ...DEFAULT_SETTINGS };
1323
+ }
1324
+ }
1325
+ var settingsStore = createStore(readSettings());
1326
+ function updateSettings(patch) {
1327
+ const next = { ...settingsStore.get(), ...patch };
1328
+ if (typeof next.search === "string" && next.search.length > SEARCH_MAX_LENGTH) next.search = next.search.slice(0, SEARCH_MAX_LENGTH);
1329
+ settingsStore.set(next);
1330
+ try {
1331
+ if (globalThis.localStorage) globalThis.localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
1332
+ } catch {
1333
+ }
1334
+ return next;
1335
+ }
1336
+ var groupingStore = createStore(settingsStore.get().grouping);
1337
+ var SIDEBAR_VIEW_KEY = "dsh.workspace.view.v5";
1338
+ function readSidebarArchived() {
1339
+ try {
1340
+ const raw = globalThis.localStorage ? globalThis.localStorage.getItem(SIDEBAR_VIEW_KEY) : null;
1341
+ if (typeof raw !== "string" || raw === "") return "default";
1342
+ const parsed = JSON.parse(raw);
1343
+ const value = parsed !== null && typeof parsed === "object" ? parsed.archivedFilter : null;
1344
+ return value === "show" || value === "only" ? value : "default";
1345
+ } catch {
1346
+ return "default";
1347
+ }
1348
+ }
1349
+ function writeSidebarArchived(show) {
1350
+ try {
1351
+ if (!globalThis.localStorage) return false;
1352
+ const raw = globalThis.localStorage.getItem(SIDEBAR_VIEW_KEY);
1353
+ const parsed = raw === null || raw === "" ? null : JSON.parse(raw);
1354
+ const target2 = parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ? {} : parsed;
1355
+ target2.archivedFilter = show ? "show" : "default";
1356
+ globalThis.localStorage.setItem(SIDEBAR_VIEW_KEY, JSON.stringify(target2));
1357
+ return true;
1358
+ } catch {
1359
+ return false;
1360
+ }
1361
+ }
1362
+
1363
+ // src/client/update.ts
1364
+ var updateStore = createStore({ state: "idle", value: null, error: null });
1365
+ async function checkUpdate(force) {
1366
+ updateStore.set({ state: "checking", value: updateStore.get().value, error: null });
1367
+ try {
1368
+ const value = await call("check-update", force === true ? { force: true } : {});
1369
+ updateStore.set({ state: "done", value, error: null });
1370
+ } catch (error) {
1371
+ updateStore.set({ state: "failed", value: updateStore.get().value, error: reportError(error) });
1372
+ }
1373
+ }
1374
+
1375
+ // src/client/components/dropdown.ts
1376
+ var React4 = __toESM(require("react"), 1);
1377
+ var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
1378
+ function Dropdown(props) {
1379
+ const current2 = props.options.find((option) => option.value === props.value);
1380
+ const display = props.display ?? (current2 === void 0 ? String(props.value) : current2.label);
1381
+ const optionId = (value) => `${props.id}-${String(value)}`;
1382
+ return React4.createElement(import_dsh_client_ui_primitives2.Menu, {
1383
+ open: props.open,
1384
+ portal: true,
1385
+ side: "bottom",
1386
+ align: props.align ?? "end",
1387
+ items: props.options.map((option) => ({
1388
+ id: optionId(option.value),
1389
+ label: option.label,
1390
+ disabled: option.disabled === true
1391
+ })),
1392
+ // 命中当前档位时由 `Menu` 在行**尾**画对勾 —— 这是数据行选中态的唯一来源。
1393
+ selectedId: optionId(props.value),
1394
+ onSelect: (id) => {
1395
+ props.onToggle(false);
1396
+ const picked = props.options.find((option) => optionId(option.value) === id);
1397
+ if (picked !== void 0 && picked.value !== props.value) props.onChange(picked.value);
1398
+ },
1399
+ onClose: () => props.onToggle(false),
1400
+ className: S.dropdownAnchor,
1401
+ anchor: React4.createElement(
1402
+ import_dsh_client_ui_primitives2.Button,
1403
+ {
1404
+ size: "sm",
1405
+ variant: "outline",
1406
+ disabled: props.disabled === true,
1407
+ style: S.dropdownButton,
1408
+ "aria-label": props.label,
1409
+ "aria-haspopup": "menu",
1410
+ "aria-expanded": props.open,
1411
+ icon: React4.createElement(import_dsh_client_ui_primitives2.IconChevronsUpDownOutlineRegular, { size: 14 }),
1412
+ onClick: () => props.onToggle(!props.open)
1413
+ },
1414
+ display
1415
+ )
1416
+ });
1417
+ }
1418
+
1419
+ // src/client/components/group-header.ts
1420
+ var React5 = __toESM(require("react"), 1);
1421
+ var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
1422
+ function GroupHeader(props) {
1423
+ const group = props.group;
1424
+ const t = props.t;
1425
+ const total = group.rows.length;
1426
+ const allSelected = total > 0 && group.rows.every((row) => props.selected.includes(row.sessionId));
1427
+ const action = allSelected ? t("clearShort") : t("selectAllShort");
1428
+ const workspaceActions = props.workspaceActions ?? [];
1429
+ return React5.createElement(
1430
+ "div",
1431
+ { style: props.first ? { ...S.groupHead, borderTop: "none" } : S.groupHead },
1432
+ React5.createElement(
1433
+ "button",
1434
+ {
1435
+ type: "button",
1436
+ style: S.groupToggle,
1437
+ title: props.collapsed ? t("expand") : t("collapse"),
1438
+ onClick: () => props.onToggleCollapse()
1439
+ },
1440
+ React5.createElement(
1441
+ "span",
1442
+ { style: S.groupCaret },
1443
+ props.collapsed ? React5.createElement(import_dsh_client_ui_primitives3.IconChevronRightOutlineRegular, { size: 12 }) : React5.createElement(import_dsh_client_ui_primitives3.IconChevronDownOutlineRegular, { size: 12 })
1444
+ ),
1445
+ React5.createElement(
1446
+ "span",
1447
+ { style: S.groupIcon },
1448
+ props.collapsed ? React5.createElement(import_dsh_client_ui_primitives3.IconFolderCloseRegular, { size: 14 }) : React5.createElement(import_dsh_client_ui_primitives3.IconFolderOpenRegular, { size: 14 })
1449
+ ),
1450
+ React5.createElement("span", { style: S.groupTitle, title: group.cwd || group.title }, group.title),
1451
+ React5.createElement("span", { style: S.groupMeta }, text(t, "sessions", { n: total })),
1452
+ group.bytes > 0 ? React5.createElement("span", { style: S.groupMeta }, formatBytes(group.bytes)) : null,
1453
+ group.cwd !== null ? React5.createElement("span", { style: { ...S.groupPath, ...S.mono }, title: group.cwd }, group.cwd) : null
1454
+ ),
1455
+ // 工作区动作:先勾选(本页签),再「⋯」(跨页签)。两个都不参与 flex 收缩,
1456
+ // 长路径挤的只能是左边的折叠按钮(见 styles.ts 的 groupAction)。
1457
+ React5.createElement(
1458
+ import_dsh_client_ui_primitives3.Button,
1459
+ {
1460
+ size: "sm",
1461
+ variant: "ghost",
1462
+ disabled: props.busy,
1463
+ style: S.groupAction,
1464
+ title: `${group.title} — ${allSelected ? t("clearGroup") : t("selectGroup")}`,
1465
+ onClick: () => props.onToggleGroup()
1466
+ },
1467
+ action
1468
+ ),
1469
+ workspaceActions.length === 0 ? null : React5.createElement(import_dsh_client_ui_primitives3.Menu, {
1470
+ open: props.workspaceMenuOpen === true,
1471
+ portal: true,
1472
+ side: "bottom",
1473
+ align: "end",
1474
+ closeOnPointerLeave: true,
1475
+ items: workspaceActions,
1476
+ onSelect: (id) => props.onRunWorkspaceAction?.(id),
1477
+ onClose: () => props.onCloseWorkspaceMenu?.(),
1478
+ anchor: React5.createElement(import_dsh_client_ui_primitives3.Button, {
1479
+ size: "sm",
1480
+ variant: "ghost",
1481
+ disabled: props.busy,
1482
+ style: S.rowMenuButton,
1483
+ icon: React5.createElement(import_dsh_client_ui_primitives3.IconEllipsisOutlineRegular, {}),
1484
+ "aria-label": `${group.title} — ${t("workspaceActions")}`,
1485
+ onClick: () => props.onToggleWorkspaceMenu?.()
1486
+ })
1487
+ })
1488
+ );
1489
+ }
1490
+ function workspaceActionsOf(t, summary) {
1491
+ return [
1492
+ {
1493
+ id: "archive",
1494
+ label: t("archiveWorkspace"),
1495
+ icon: React5.createElement(import_dsh_client_ui_primitives3.IconArchiveOutlineRegular, { size: 14 }),
1496
+ // 全都归档过了就没什么可归档的;有会话正在跑时也不给点(归档会先停掉它的工作)。
1497
+ disabled: summary.archivableIds.length === 0 || summary.activeIds.length > 0
1498
+ },
1499
+ {
1500
+ id: "trash",
1501
+ label: t("trashWorkspace"),
1502
+ icon: React5.createElement(import_dsh_client_ui_primitives3.IconTrashOutlineRegular, { size: 14 }),
1503
+ danger: true,
1504
+ disabled: summary.trashableIds.length === 0 || summary.activeIds.length > 0
1505
+ }
1506
+ ];
1507
+ }
1508
+
1509
+ // src/client/components/session-row.ts
1510
+ var React6 = __toESM(require("react"), 1);
1511
+ var import_dsh_client_ui_primitives4 = require("@deepseek-ai/dsh-client-ui-primitives");
1512
+ function SessionRow(props) {
1513
+ const row = props.row;
1514
+ const t = props.t;
1515
+ const meta = [];
1516
+ if (props.showCwd) meta.push(row.cwd ? row.cwd : t("noProject"));
1517
+ if (props.showMeta !== false) {
1518
+ if (row.bytes) meta.push(formatBytes(row.bytes));
1519
+ if (row.files) meta.push(text(t, "files", { n: row.files }));
1520
+ }
1521
+ const time = props.showDeletedAt ? formatTime(row.deletedAt) : formatTime(row.createdAt);
1522
+ if (time !== "") meta.push(time);
1523
+ const menu = props.menu;
1524
+ const running = row.active === true;
1525
+ return React6.createElement(
1526
+ "div",
1527
+ { style: props.first ? { ...S.item, borderTop: "none" } : S.item },
1528
+ React6.createElement("input", {
1529
+ type: "checkbox",
1530
+ checked: props.checked,
1531
+ disabled: props.busy,
1532
+ "aria-label": row.title,
1533
+ onChange: () => props.onToggle()
1534
+ }),
1535
+ React6.createElement(
1536
+ "div",
1537
+ { style: S.itemMain },
1538
+ React6.createElement(
1539
+ "div",
1540
+ { style: S.itemTitleRow },
1541
+ React6.createElement("div", { style: S.itemTitle, title: row.title }, row.title),
1542
+ // 胶囊:`flex: 0 0 auto`,所以长标题只会挤标题那一段(它有 minWidth: 0 + 省略号)。
1543
+ running ? React6.createElement("span", { style: S.badgeRunning, title: t("runningHint") }, t("running")) : null
1544
+ ),
1545
+ React6.createElement("div", { style: S.itemMeta, title: row.cwd || "" }, meta.join(" · "))
1546
+ ),
1547
+ React6.createElement(
1548
+ "div",
1549
+ { style: S.itemActions },
1550
+ React6.createElement(import_dsh_client_ui_primitives4.Menu, {
1551
+ open: props.menuOpen,
1552
+ portal: true,
1553
+ side: "bottom",
1554
+ align: "end",
1555
+ closeOnPointerLeave: true,
1556
+ items: menu.items,
1557
+ onSelect: (id) => menu.run(id),
1558
+ onClose: () => props.onCloseMenu(),
1559
+ anchor: React6.createElement(import_dsh_client_ui_primitives4.Button, {
1560
+ size: "sm",
1561
+ variant: "ghost",
1562
+ disabled: props.busy,
1563
+ style: S.rowMenuButton,
1564
+ icon: React6.createElement(import_dsh_client_ui_primitives4.IconEllipsisOutlineRegular, {}),
1565
+ "aria-label": `${row.title} — ${t("rowActions")}`,
1566
+ onClick: (event) => {
1567
+ event.stopPropagation();
1568
+ props.onToggleMenu();
1569
+ }
1570
+ })
1571
+ })
1572
+ )
1573
+ );
1574
+ }
1575
+
1576
+ // src/client/components/usage-panel.ts
1577
+ var React7 = __toESM(require("react"), 1);
1578
+ var import_dsh_client_ui_primitives5 = require("@deepseek-ai/dsh-client-ui-primitives");
1579
+ function usageRow(t, row, busy, onArchive, onUnarchive) {
1580
+ const badges = [];
1581
+ if (row.trashed) badges.push({ key: "trashed", label: t("usageTrashed"), style: S.badgeDanger });
1582
+ else if (row.archived) badges.push({ key: "archived", label: t("usageArchived"), style: S.badge });
1583
+ else if (row.pinned) badges.push({ key: "pinned", label: t("usagePinned"), style: S.badge });
1584
+ if (row.active) badges.push({ key: "running", label: t("running"), style: S.badgeRunning });
1585
+ const action = row.trashed ? null : row.archived ? { label: t("usageUnarchive"), run: () => onUnarchive(row.sessionId) } : row.active ? null : { label: t("usageArchive"), run: () => onArchive(row.sessionId) };
1586
+ const when = row.deletedAt !== null ? formatTime(row.deletedAt) : formatTime(row.createdAt);
1587
+ return React7.createElement(
1588
+ "div",
1589
+ { key: row.sessionId, style: S.usageRow },
1590
+ React7.createElement(
1591
+ "div",
1592
+ { style: S.itemMain },
1593
+ React7.createElement(
1594
+ "div",
1595
+ { style: S.itemTitleRow },
1596
+ React7.createElement("div", { style: S.itemTitle, title: row.title }, row.title),
1597
+ badges.map((badge) => React7.createElement("span", { key: badge.key, style: badge.style }, badge.label))
1598
+ ),
1599
+ React7.createElement(
1600
+ "div",
1601
+ { style: S.itemMeta, title: row.cwd || "" },
1602
+ [formatBytes(row.bytes), text(t, "files", { n: row.files }), row.workspaceTitle ?? row.cwd ?? t("noProject"), when].filter((part) => part !== "").join(" · ")
1603
+ )
1604
+ ),
1605
+ React7.createElement(
1606
+ "div",
1607
+ { style: S.itemActions },
1608
+ action === null ? null : React7.createElement(
1609
+ import_dsh_client_ui_primitives5.Button,
1610
+ {
1611
+ size: "sm",
1612
+ variant: "outline",
1613
+ disabled: busy,
1614
+ onClick: action.run
1615
+ },
1616
+ action.label
1617
+ )
1618
+ )
1619
+ );
1620
+ }
1621
+ function UsagePanel(props) {
1622
+ const t = props.t;
1623
+ const usage = props.usage;
1624
+ const maxBytes = usage === null || usage.groups.length === 0 ? 0 : Math.max(...usage.groups.map((group) => group.bytes), 1);
1625
+ return React7.createElement(
1626
+ "div",
1627
+ { style: S.card },
1628
+ React7.createElement(
1629
+ "div",
1630
+ { style: { ...S.configRow, borderTop: "none" } },
1631
+ React7.createElement(
1632
+ "div",
1633
+ { style: S.configMain },
1634
+ React7.createElement("span", { style: S.configLabel }, t("usageTitle")),
1635
+ React7.createElement(
1636
+ "span",
1637
+ { style: S.configHint },
1638
+ props.loading ? t("usageLoading") : usage === null ? props.error ?? "" : text(t, "usageTotal", {
1639
+ n: usage.totalSessions,
1640
+ size: formatBytes(usage.totalBytes),
1641
+ disk: usage.onDiskSessions
1642
+ })
1643
+ )
1644
+ )
1645
+ ),
1646
+ usage === null || usage.totalSessions === 0 ? React7.createElement("div", { style: S.empty }, props.loading ? t("usageLoading") : t("usageNoData")) : React7.createElement(
1647
+ "div",
1648
+ { style: S.usageBody },
1649
+ // 按工作区:横条 + 名字 + 体积 / 条数。
1650
+ React7.createElement("span", { style: S.usageHeading }, t("usageByWorkspace")),
1651
+ usage.groups.map(
1652
+ (group) => React7.createElement(
1653
+ "div",
1654
+ { key: group.key, style: S.usageBarRow },
1655
+ React7.createElement(
1656
+ "span",
1657
+ { style: S.usageBarTrack },
1658
+ React7.createElement("span", {
1659
+ style: {
1660
+ ...S.usageBarFill,
1661
+ // 占比:按最大组归一,所以最小的组也看得见(下限 2%)。
1662
+ width: `${Math.max(2, Math.round(group.bytes / maxBytes * 100))}%`
1663
+ }
1664
+ })
1665
+ ),
1666
+ React7.createElement(
1667
+ "span",
1668
+ { style: S.usageBarName, title: group.key },
1669
+ group.title === "" ? t("noProject") : group.title
1670
+ ),
1671
+ React7.createElement(
1672
+ "span",
1673
+ { style: S.usageBarMeta },
1674
+ `${formatBytes(group.bytes)} · ${text(t, "sessions", { n: group.count })}`
1675
+ )
1676
+ )
1677
+ ),
1678
+ React7.createElement("span", { style: S.usageHeading }, t("usageLargest")),
1679
+ usage.largest.map((row) => usageRow(t, row, props.busy, props.onArchive, props.onUnarchive)),
1680
+ React7.createElement("span", { style: S.usageHeading }, t("usageOldest")),
1681
+ usage.oldest.map((row) => usageRow(t, row, props.busy, props.onArchive, props.onUnarchive))
1682
+ )
1683
+ );
1684
+ }
1685
+
1686
+ // src/client/components/settings-panel.ts
1687
+ function SessionManagerPanel(props) {
1688
+ const t = props.t;
1689
+ const settings = useStore(settingsStore);
1690
+ const [tab, setTab] = React8.useState(settings.tab);
1691
+ const [snapshot, setSnapshot] = React8.useState(cachedSnapshot);
1692
+ const [error, setError] = React8.useState(null);
1693
+ const [loading, setLoading] = React8.useState(true);
1694
+ const [busy, setBusy] = React8.useState(false);
1695
+ const [selected, setSelected] = React8.useState([]);
1696
+ const [collapsedOverrides, setCollapsedOverrides] = React8.useState({});
1697
+ const [menuFor, setMenuFor] = React8.useState(null);
1698
+ const [workspaceMenuFor, setWorkspaceMenuFor] = React8.useState(null);
1699
+ const [trashStats, setTrashStats] = React8.useState(null);
1700
+ const [trashStatsError, setTrashStatsError] = React8.useState(null);
1701
+ const [trashStatsBusy, setTrashStatsBusy] = React8.useState(false);
1702
+ const [usage, setUsage] = React8.useState(null);
1703
+ const [usageError, setUsageError] = React8.useState(null);
1704
+ const [usageBusy, setUsageBusy] = React8.useState(false);
1705
+ const [usageReload, setUsageReload] = React8.useState(0);
1706
+ const [openDropdown, setOpenDropdown] = React8.useState(null);
1707
+ const grouping = useStore(groupingStore);
1708
+ const revision = useStore(mutationStore);
1709
+ const update = useStore(updateStore);
1710
+ const [sidebarArchived, setSidebarArchived] = React8.useState(readSidebarArchived);
1711
+ const [sidebarAtMount] = React8.useState(readSidebarArchived);
1712
+ const applyResult = (value) => {
1713
+ if (value && value.archived) {
1714
+ rememberSnapshot(value);
1715
+ setSnapshot(value);
1716
+ }
1717
+ };
1718
+ const refresh = React8.useCallback(async () => {
1719
+ setLoading(true);
1720
+ try {
1721
+ applyResult(await call("snapshot", {}));
1722
+ setError(null);
1723
+ } catch (failure) {
1724
+ setError(reportError(failure));
1725
+ } finally {
1726
+ setLoading(false);
1727
+ }
1728
+ }, []);
1729
+ React8.useEffect(() => {
1730
+ void refresh();
1731
+ }, [refresh, revision]);
1732
+ React8.useEffect(() => {
1733
+ if (tab !== "config" || !settings.autoUpdate || update.state !== "idle") return;
1734
+ void checkUpdate(false);
1735
+ }, [tab, settings.autoUpdate, update.state]);
1736
+ React8.useEffect(() => {
1737
+ setSelected([]);
1738
+ setMenuFor(null);
1739
+ setWorkspaceMenuFor(null);
1740
+ }, [tab]);
1741
+ const retentionDays = effectiveTrashDays(settings);
1742
+ React8.useEffect(() => {
1743
+ if (tab !== "config") return;
1744
+ let cancelled = false;
1745
+ setTrashStatsBusy(true);
1746
+ setTrashStatsError(null);
1747
+ void call("trash-stats", { days: retentionDays }).then((value) => {
1748
+ if (!cancelled) setTrashStats(value);
1749
+ }).catch((failure) => {
1750
+ if (!cancelled) {
1751
+ setTrashStats(null);
1752
+ setTrashStatsError(reportError(failure));
1753
+ }
1754
+ }).finally(() => {
1755
+ if (!cancelled) setTrashStatsBusy(false);
1756
+ });
1757
+ return () => {
1758
+ cancelled = true;
1759
+ };
1760
+ }, [tab, retentionDays]);
1761
+ React8.useEffect(() => {
1762
+ if (tab !== "usage") return;
1763
+ let cancelled = false;
1764
+ setUsageBusy(true);
1765
+ setUsageError(null);
1766
+ void call("usage", {}).then((value) => {
1767
+ if (!cancelled) setUsage(value);
1768
+ }).catch((failure) => {
1769
+ if (!cancelled) {
1770
+ setUsage(null);
1771
+ setUsageError(reportError(failure));
1772
+ }
1773
+ }).finally(() => {
1774
+ if (!cancelled) setUsageBusy(false);
1775
+ });
1776
+ return () => {
1777
+ cancelled = true;
1778
+ };
1779
+ }, [tab, revision, usageReload]);
1780
+ const refreshUsage = () => setUsageReload((current2) => current2 + 1);
1781
+ const runUsageAction = (endpoint, sessionId) => {
1782
+ void run(endpoint, [sessionId]).then(() => refreshUsage());
1783
+ };
1784
+ const run = async (endpoint, ids) => {
1785
+ if (ids.length === 0) return;
1786
+ setBusy(true);
1787
+ try {
1788
+ const value = await call(endpoint, { ids });
1789
+ applyResult(value);
1790
+ setError(firstFailure(value.results));
1791
+ setSelected([]);
1792
+ } catch (failure) {
1793
+ setError(reportError(failure));
1794
+ } finally {
1795
+ setBusy(false);
1796
+ }
1797
+ };
1798
+ const toggle = (sessionId) => {
1799
+ setSelected((current2) => current2.includes(sessionId) ? current2.filter((id) => id !== sessionId) : current2.concat([sessionId]));
1800
+ };
1801
+ const toggleGroup = (group) => {
1802
+ const ids = group.rows.map((row) => row.sessionId);
1803
+ const all = ids.every((id) => selected.includes(id));
1804
+ setSelected((current2) => all ? current2.filter((id) => !ids.includes(id)) : [...new Set(current2.concat(ids))]);
1805
+ };
1806
+ const toggleCollapse = (key) => {
1807
+ setCollapsedOverrides((current2) => toggledCollapse(key, current2, settings.groupDefault));
1808
+ };
1809
+ const archived = snapshot ? snapshot.archived : [];
1810
+ const pinned = snapshot ? snapshot.pinned : [];
1811
+ const trashed = snapshot ? snapshot.trashed : [];
1812
+ const rows = sortRows(tab === "archived" ? archived : tab === "pinned" ? pinned : tab === "trash" ? trashed : [], settings.order);
1813
+ const filter = React8.useMemo(
1814
+ () => ({ search: settings.search, timeFilter: settings.timeFilter }),
1815
+ [settings.search, settings.timeFilter]
1816
+ );
1817
+ const filtered = React8.useMemo(() => filterRows(rows, filter), [rows, filter]);
1818
+ const filtering = isFilterActive(filter);
1819
+ const selectedRows = rows.filter((row) => selected.includes(row.sessionId));
1820
+ const groups = grouping ? groupRows(filtered, t) : [];
1821
+ const blockOf = (group) => {
1822
+ const key = collapseKey(tab, group.key);
1823
+ return { key, group, rows: isGroupCollapsed(key, collapsedOverrides, settings.groupDefault) ? [] : group.rows };
1824
+ };
1825
+ const blocks = grouping ? groups.map(blockOf) : [{ key: `${tab}:flat`, group: null, rows: filtered }];
1826
+ const tabs = [
1827
+ { id: "archived", label: t(TAB_LABEL.archived), count: archived.length },
1828
+ { id: "pinned", label: t(TAB_LABEL.pinned), count: pinned.length },
1829
+ { id: "trash", label: t(TAB_LABEL.trash), count: trashed.length },
1830
+ { id: "usage", label: t("titleUsage"), count: 0 },
1831
+ { id: "config", label: t("titleConfig"), count: 0 }
1832
+ ];
1833
+ const orderOptions = [
1834
+ { value: "host", label: t("orderHost") },
1835
+ { value: "title", label: t("orderTitle") },
1836
+ { value: "bytes", label: t("orderBytes") },
1837
+ { value: "createdAt", label: t("orderCreatedAt") },
1838
+ { value: "deletedAt", label: t("orderDeletedAt") }
1839
+ ];
1840
+ const customDays = settings.trashDaysCustom;
1841
+ const customDaysValid = customDays.trim() === "" || parseCustomDays(customDays) !== null;
1842
+ const retentionOptions = TRASH_RETENTION_DAYS.map((days) => ({ value: days, label: text(t, "retentionDays", { n: days }) }));
1843
+ const summaries = React8.useMemo(() => workspaceSummaries(archived, pinned, trashed), [archived, pinned, trashed]);
1844
+ const copyRowPath = async (sessionId) => {
1845
+ try {
1846
+ const value = await openSessionPath(sessionId);
1847
+ const copied = await copyText(value.path);
1848
+ showNotice(copied ? text(t, "pathCopied", { path: value.path }) : text(t, "pathCopyFailed", { path: value.path }));
1849
+ } catch (failure) {
1850
+ showNotice(reportError(failure));
1851
+ }
1852
+ };
1853
+ const revealRow = async (sessionId) => {
1854
+ try {
1855
+ const value = await openSessionPath(sessionId, true);
1856
+ if (!value.revealed) {
1857
+ await copyText(value.path);
1858
+ showNotice(text(t, "revealFailed", { reason: value.reason ?? "?", path: value.path }));
1859
+ }
1860
+ } catch (failure) {
1861
+ showNotice(reportError(failure));
1862
+ }
1863
+ };
1864
+ const rowMenu = (row) => {
1865
+ const running = row.active === true;
1866
+ const moveToTrash = {
1867
+ id: "trash",
1868
+ label: t("moveToTrash"),
1869
+ icon: React8.createElement(import_dsh_client_ui_primitives6.IconTrashOutlineRegular, { size: 14 }),
1870
+ danger: true,
1871
+ disabled: running
1872
+ };
1873
+ const locate = [
1874
+ { id: "copy-path", label: t("copyPath"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconCopyOutlineRegular, { size: 14 }) },
1875
+ { id: "reveal", label: t("revealInFolder"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconFolderOpenRegular, { size: 14 }) }
1876
+ ];
1877
+ const handleLocate = (id) => {
1878
+ if (id === "copy-path") {
1879
+ void copyRowPath(row.sessionId);
1880
+ return true;
1881
+ }
1882
+ if (id === "reveal") {
1883
+ void revealRow(row.sessionId);
1884
+ return true;
1885
+ }
1886
+ return false;
1887
+ };
1888
+ if (tab === "archived") {
1889
+ return {
1890
+ items: [
1891
+ { id: "unarchive", label: t("unarchive"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconUnarchiveOutlineRegular, { size: 14 }) },
1892
+ { id: "pin", label: t("pin"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconPinOutlineRegular, { size: 14 }) },
1893
+ moveToTrash,
1894
+ ...locate
1895
+ ],
1896
+ run: (id) => {
1897
+ setMenuFor(null);
1898
+ if (handleLocate(id)) return;
1899
+ if (id === "trash") requestTrash([row.sessionId], row.title);
1900
+ else void run(id, [row.sessionId]);
1901
+ }
1902
+ };
1903
+ }
1904
+ if (tab === "pinned") {
1905
+ return {
1906
+ items: [
1907
+ { id: "unpin", label: t("unpin"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconPinOutlineRegular, { size: 14 }) },
1908
+ moveToTrash,
1909
+ ...locate
1910
+ ],
1911
+ run: (id) => {
1912
+ setMenuFor(null);
1913
+ if (handleLocate(id)) return;
1914
+ if (id === "trash") requestTrash([row.sessionId], row.title);
1915
+ else void run(id, [row.sessionId]);
1916
+ }
1917
+ };
1918
+ }
1919
+ const restorable = row.restorable !== false;
1920
+ return {
1921
+ items: [
1922
+ {
1923
+ id: "restore",
1924
+ label: t(restorable ? "restore" : "restoreUnavailable"),
1925
+ icon: React8.createElement(import_dsh_client_ui_primitives6.IconRefreshOutlineRegular, { size: 14 }),
1926
+ disabled: !restorable
1927
+ },
1928
+ { id: "purge", label: t("purge"), icon: React8.createElement(import_dsh_client_ui_primitives6.IconTrashOutlineRegular, { size: 14 }), danger: true },
1929
+ ...locate
1930
+ ],
1931
+ run: (id) => {
1932
+ setMenuFor(null);
1933
+ if (handleLocate(id)) return;
1934
+ if (id === "restore" && !restorable) return;
1935
+ if (id === "purge") requestPurge([row.sessionId], row.title);
1936
+ else void run(id, [row.sessionId]);
1937
+ }
1938
+ };
1939
+ };
1940
+ const runWorkspaceAction = (group, id) => {
1941
+ setWorkspaceMenuFor(null);
1942
+ const summary = summaries.get(group.key);
1943
+ if (summary === void 0) return;
1944
+ if (id === "archive") void run("archive", summary.archivableIds);
1945
+ if (id === "trash") requestTrash(summary.trashableIds, group.title);
1946
+ };
1947
+ const batch = [];
1948
+ if (tab === "archived" && selected.length > 0) {
1949
+ batch.push({ label: t("unarchive"), run: () => void run("unarchive", selected) });
1950
+ batch.push({
1951
+ label: t("moveToTrash"),
1952
+ variant: "outline",
1953
+ run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "")
1954
+ });
1955
+ }
1956
+ if (tab === "pinned" && selected.length > 0) {
1957
+ batch.push({ label: t("unpin"), run: () => void run("unpin", selected) });
1958
+ batch.push({
1959
+ label: t("moveToTrash"),
1960
+ variant: "outline",
1961
+ run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "")
1962
+ });
1963
+ }
1964
+ if (tab === "trash" && selected.length > 0) {
1965
+ const canRestore = selectedRows.some((row) => row.restorable !== false);
1966
+ batch.push({ label: t("restore"), disabled: !canRestore, run: () => void run("restore", selected) });
1967
+ batch.push({
1968
+ label: t("purge"),
1969
+ variant: "outline",
1970
+ run: () => requestPurge(selected, selectedRows[0] ? selectedRows[0].title : "")
1971
+ });
1972
+ }
1973
+ const totals = snapshot ? snapshot.totals : null;
1974
+ const totalBytes = tab === "archived" ? totals ? totals.archivedBytes : 0 : tab === "trash" ? totals ? totals.trashBytes : 0 : 0;
1975
+ const nodes = [];
1976
+ let first = true;
1977
+ for (const block of blocks) {
1978
+ if (block.group !== null) {
1979
+ const group = block.group;
1980
+ const key = block.key;
1981
+ const summary = summaries.get(group.key);
1982
+ nodes.push(
1983
+ React8.createElement(GroupHeader, {
1984
+ key: `group:${key}`,
1985
+ group,
1986
+ t,
1987
+ first,
1988
+ busy,
1989
+ selected,
1990
+ collapsed: isGroupCollapsed(key, collapsedOverrides, settings.groupDefault),
1991
+ onToggleGroup: () => toggleGroup(group),
1992
+ onToggleCollapse: () => toggleCollapse(key),
1993
+ // 「整个工作区」:跨页签的目标集合由 `workspaceSummaries()` 算好。
1994
+ // "未知目录"那一组也有 key,但它的目标集合可能只含回收站条目 ——
1995
+ // `workspaceActionsOf` 会把两项都置 `disabled`,菜单仍然画得出来(不是坏形状)。
1996
+ workspaceActions: summary === void 0 ? [] : workspaceActionsOf(t, summary),
1997
+ workspaceMenuOpen: workspaceMenuFor === group.key,
1998
+ onToggleWorkspaceMenu: () => setWorkspaceMenuFor(workspaceMenuFor === group.key ? null : group.key),
1999
+ onCloseWorkspaceMenu: () => setWorkspaceMenuFor(null),
2000
+ onRunWorkspaceAction: (id) => runWorkspaceAction(group, id)
2001
+ })
2002
+ );
2003
+ first = false;
2004
+ }
2005
+ for (const row of block.rows) {
2006
+ nodes.push(
2007
+ React8.createElement(SessionRow, {
2008
+ key: row.sessionId,
2009
+ row,
2010
+ t,
2011
+ first,
2012
+ busy,
2013
+ showCwd: block.group === null,
2014
+ showMeta: settings.showMeta,
2015
+ showDeletedAt: tab === "trash",
2016
+ checked: selected.includes(row.sessionId),
2017
+ onToggle: () => toggle(row.sessionId),
2018
+ menu: rowMenu(row),
2019
+ menuOpen: menuFor === row.sessionId,
2020
+ onToggleMenu: () => setMenuFor(menuFor === row.sessionId ? null : row.sessionId),
2021
+ onCloseMenu: () => setMenuFor(null)
2022
+ })
2023
+ );
2024
+ first = false;
2025
+ }
2026
+ }
2027
+ const isConfig = tab === "config";
2028
+ const isUsage = tab === "usage";
2029
+ const isDataTab = !isConfig && !isUsage;
2030
+ const configRow = (key, label, hint, control, firstRow) => React8.createElement(
2031
+ "div",
2032
+ { key, style: firstRow ? { ...S.configRow, borderTop: "none" } : S.configRow },
2033
+ React8.createElement(
2034
+ "div",
2035
+ { style: S.configMain },
2036
+ React8.createElement("span", { style: S.configLabel }, label),
2037
+ hint === null ? null : React8.createElement("span", { style: S.configHint }, hint)
2038
+ ),
2039
+ React8.createElement("div", { style: S.configControl }, control)
2040
+ );
2041
+ const retentionCount = trashStats === null ? 0 : trashStats.count;
2042
+ const retentionBytes = trashStats === null ? 0 : trashStats.bytes;
2043
+ const retentionFoot = React8.createElement(
2044
+ "div",
2045
+ { key: "config:trash-preview", style: S.configFoot },
2046
+ trashStatsBusy ? React8.createElement("span", { style: S.configHint }, t("trashPreviewLoading")) : trashStatsError !== null ? React8.createElement("span", { style: S.error }, trashStatsError) : React8.createElement(
2047
+ "span",
2048
+ { style: S.configHint },
2049
+ retentionCount > 0 ? text(t, "trashPreview", { n: retentionCount, size: formatBytes(retentionBytes) }) : text(t, "trashPreviewNone", { days: retentionDays })
2050
+ ),
2051
+ React8.createElement(
2052
+ import_dsh_client_ui_primitives6.Button,
2053
+ {
2054
+ size: "sm",
2055
+ variant: "outline",
2056
+ // 自定义天数非法时不给点:这时候 `retentionDays` 回落到档位,点下去删的和看到的
2057
+ // 那行数字对不上(虽然那行也是按档位算的,但用户以为自己填的数生效了)。
2058
+ disabled: busy || trashStatsBusy || retentionCount === 0 || !customDaysValid,
2059
+ onClick: () => requestPurgeOld(retentionDays, retentionCount, retentionBytes)
2060
+ },
2061
+ text(t, "trashCleanup", { n: retentionDays })
2062
+ )
2063
+ );
2064
+ const filterBar = React8.createElement(
2065
+ "div",
2066
+ { style: S.filterBar },
2067
+ React8.createElement(import_dsh_client_ui_primitives6.Input, {
2068
+ value: settings.search,
2069
+ placeholder: t("searchPlaceholder"),
2070
+ "aria-label": t("searchLabel"),
2071
+ icon: React8.createElement(import_dsh_client_ui_primitives6.IconSearchOutlineRegular, {}),
2072
+ style: S.searchInput,
2073
+ onChange: (event) => void updateSettings({ search: event.target.value })
2074
+ }),
2075
+ filtering ? React8.createElement(
2076
+ import_dsh_client_ui_primitives6.Button,
2077
+ {
2078
+ size: "sm",
2079
+ variant: "ghost",
2080
+ onClick: () => void updateSettings({ search: "", timeFilter: "all" })
2081
+ },
2082
+ t("searchClear")
2083
+ ) : null,
2084
+ React8.createElement("div", { style: S.filterSpacer }),
2085
+ React8.createElement(import_dsh_client_ui_primitives6.SegmentedControl, {
2086
+ id: "session-curator-time-filter",
2087
+ value: settings.timeFilter,
2088
+ options: [
2089
+ { value: "all", label: t("timeAll") },
2090
+ { value: "recent", label: t("timeRecent") }
2091
+ ],
2092
+ label: t("timeFilterLabel"),
2093
+ onChange: (next) => void updateSettings({ timeFilter: next })
2094
+ })
2095
+ );
2096
+ const version = snapshot !== null && typeof snapshot.version === "string" ? snapshot.version : update.value !== null && typeof update.value.current === "string" ? update.value.current : null;
2097
+ const outdated = update.state === "done" && update.value !== null && update.value.outdated === true;
2098
+ const updateLine = update.state === "checking" ? React8.createElement("span", { style: S.configHint }, t("updateChecking")) : update.state === "failed" ? React8.createElement(
2099
+ "span",
2100
+ { style: S.error },
2101
+ text(t, "updateFailed", { reason: update.error === null ? "?" : update.error })
2102
+ ) : update.value !== null && typeof update.value.error === "string" ? React8.createElement("span", { style: S.configHint }, text(t, "updateFailed", { reason: update.value.error })) : update.state === "done" && !outdated ? React8.createElement("span", { style: S.configHint }, t("updateLatest")) : null;
2103
+ const configPage = React8.createElement(
2104
+ "div",
2105
+ { style: S.column },
2106
+ React8.createElement(
2107
+ "div",
2108
+ { style: S.card },
2109
+ configRow(
2110
+ "config:tab",
2111
+ t("configTab"),
2112
+ t("configTabHint"),
2113
+ React8.createElement(import_dsh_client_ui_primitives6.SegmentedControl, {
2114
+ id: "session-curator-default-tab",
2115
+ value: settings.tab,
2116
+ options: [
2117
+ { value: "archived", label: t(TAB_LABEL.archived) },
2118
+ { value: "pinned", label: t(TAB_LABEL.pinned) },
2119
+ { value: "trash", label: t(TAB_LABEL.trash) }
2120
+ ],
2121
+ label: t("configTab"),
2122
+ onChange: (next) => void updateSettings({ tab: next })
2123
+ }),
2124
+ true
2125
+ ),
2126
+ configRow(
2127
+ "config:grouping",
2128
+ t("configGrouping"),
2129
+ t("configGroupingHint"),
2130
+ React8.createElement(import_dsh_client_ui_primitives6.Switch, {
2131
+ checked: settings.grouping,
2132
+ label: t("configGrouping"),
2133
+ onChange: (next) => {
2134
+ updateSettings({ grouping: next });
2135
+ groupingStore.set(next);
2136
+ }
2137
+ })
2138
+ ),
2139
+ configRow(
2140
+ "config:group-default",
2141
+ t("configGroupDefault"),
2142
+ t("configGroupDefaultHint"),
2143
+ React8.createElement(import_dsh_client_ui_primitives6.SegmentedControl, {
2144
+ id: "session-curator-group-default",
2145
+ value: settings.groupDefault,
2146
+ options: [
2147
+ { value: "expanded", label: t("groupExpanded") },
2148
+ { value: "collapsed", label: t("groupCollapsed") }
2149
+ ],
2150
+ label: t("configGroupDefault"),
2151
+ onChange: (next) => void updateSettings({ groupDefault: next })
2152
+ })
2153
+ ),
2154
+ configRow(
2155
+ "config:sidebar-archived",
2156
+ t("configSidebarArchived"),
2157
+ t("configSidebarArchivedHint"),
2158
+ React8.createElement(import_dsh_client_ui_primitives6.Switch, {
2159
+ checked: sidebarArchived !== "default",
2160
+ label: t("configSidebarArchived"),
2161
+ onChange: (next) => {
2162
+ writeSidebarArchived(next);
2163
+ setSidebarArchived(next ? "show" : "default");
2164
+ }
2165
+ })
2166
+ ),
2167
+ // 官方 store 只在创建时读一次 localStorage,所以改完必须刷新页面。
2168
+ sidebarArchived === sidebarAtMount ? null : React8.createElement(
2169
+ "div",
2170
+ { key: "config:sidebar-refresh", style: S.configFoot },
2171
+ React8.createElement("span", { style: S.configHint }, t("configSidebarRefreshHint")),
2172
+ React8.createElement(
2173
+ import_dsh_client_ui_primitives6.Button,
2174
+ {
2175
+ size: "sm",
2176
+ variant: "outline",
2177
+ onClick: () => {
2178
+ try {
2179
+ globalThis.location.reload();
2180
+ } catch {
2181
+ }
2182
+ }
2183
+ },
2184
+ t("refreshPage")
2185
+ )
2186
+ ),
2187
+ configRow(
2188
+ "config:meta",
2189
+ t("configMeta"),
2190
+ t("configMetaHint"),
2191
+ React8.createElement(import_dsh_client_ui_primitives6.Switch, {
2192
+ checked: settings.showMeta,
2193
+ label: t("configMeta"),
2194
+ onChange: (next) => void updateSettings({ showMeta: next })
2195
+ })
2196
+ ),
2197
+ // 排序:5 档,所以是下拉(`Dropdown` 见 components/dropdown.ts)。
2198
+ configRow(
2199
+ "config:order",
2200
+ t("configOrder"),
2201
+ t("configOrderHint"),
2202
+ React8.createElement(Dropdown, {
2203
+ id: "session-curator-order",
2204
+ value: settings.order,
2205
+ options: orderOptions,
2206
+ label: t("configOrder"),
2207
+ open: openDropdown === "order",
2208
+ onToggle: (next) => setOpenDropdown(next ? "order" : null),
2209
+ onChange: (next) => void updateSettings({ order: next })
2210
+ })
2211
+ ),
2212
+ // ── 回收站保留策略 ──────────────────────────────────────────────
2213
+ // 这条**故意不做成自动清理**:它是本插件唯一不可撤销的动作。这里只让用户选一个
2214
+ // 天数(档位或自定义)并看到预览,真正的删除必须再点一次、再过一道确认弹窗(`purge-old`)。
2215
+ configRow(
2216
+ "config:trash-days",
2217
+ t("configTrashDays"),
2218
+ t("configTrashDaysHint"),
2219
+ React8.createElement(
2220
+ "div",
2221
+ { style: S.retentionDays },
2222
+ React8.createElement(Dropdown, {
2223
+ id: "session-curator-trash-days",
2224
+ value: settings.trashDays,
2225
+ options: retentionOptions,
2226
+ label: t("configTrashDays"),
2227
+ // 填了自定义天数时锚点显示"自定义",否则显示档位 —— 让"现在到底按哪个算"一眼可见。
2228
+ display: customDaysValid && customDays.trim() !== "" ? t("retentionCustom") : void 0,
2229
+ open: openDropdown === "trash-days",
2230
+ onToggle: (next) => setOpenDropdown(next ? "trash-days" : null),
2231
+ onChange: (next) => void updateSettings({ trashDays: next, trashDaysCustom: "" })
2232
+ }),
2233
+ React8.createElement(import_dsh_client_ui_primitives6.Input, {
2234
+ value: customDays,
2235
+ placeholder: t("customDaysPlaceholder"),
2236
+ "aria-label": t("customDaysLabel"),
2237
+ "aria-invalid": !customDaysValid,
2238
+ title: text(t, "customDaysHint", { min: TRASH_DAYS_MIN, max: TRASH_DAYS_MAX }),
2239
+ style: customDaysValid ? S.customDaysInput : { ...S.customDaysInput, ...S.customDaysInvalid },
2240
+ onChange: (event) => void updateSettings({ trashDaysCustom: event.target.value })
2241
+ })
2242
+ )
2243
+ ),
2244
+ retentionFoot,
2245
+ configRow(
2246
+ "config:auto",
2247
+ t("configAutoUpdate"),
2248
+ t("configAutoUpdateHint"),
2249
+ React8.createElement(import_dsh_client_ui_primitives6.Switch, {
2250
+ checked: settings.autoUpdate,
2251
+ label: t("configAutoUpdate"),
2252
+ onChange: (next) => void updateSettings({ autoUpdate: next })
2253
+ })
2254
+ ),
2255
+ React8.createElement(
2256
+ "div",
2257
+ { key: "config:reset", style: { ...S.configFoot, justifyContent: "flex-end" } },
2258
+ React8.createElement(
2259
+ import_dsh_client_ui_primitives6.Button,
2260
+ {
2261
+ size: "sm",
2262
+ variant: "outline",
2263
+ onClick: () => {
2264
+ updateSettings({ ...DEFAULT_SETTINGS });
2265
+ groupingStore.set(DEFAULT_SETTINGS.grouping);
2266
+ }
2267
+ },
2268
+ t("configReset")
2269
+ )
2270
+ )
2271
+ ),
2272
+ React8.createElement(
2273
+ "div",
2274
+ { style: S.card },
2275
+ React8.createElement(
2276
+ "div",
2277
+ { style: { ...S.configRow, borderTop: "none" } },
2278
+ React8.createElement(
2279
+ "div",
2280
+ { style: S.configMain },
2281
+ React8.createElement("span", { style: S.configLabel }, t("aboutTitle")),
2282
+ React8.createElement("span", { style: S.configHint }, "dsh-session-curator")
2283
+ ),
2284
+ React8.createElement(
2285
+ "div",
2286
+ { style: S.configControl },
2287
+ // 版本号:浅色小字。
2288
+ React8.createElement("span", { style: S.version }, `${t("versionLabel")} ${version === null ? "—" : `v${version}`}`),
2289
+ React8.createElement(
2290
+ import_dsh_client_ui_primitives6.Button,
2291
+ {
2292
+ size: "sm",
2293
+ variant: "outline",
2294
+ disabled: update.state === "checking",
2295
+ onClick: () => void checkUpdate(true)
2296
+ },
2297
+ t("updateCheckNow")
2298
+ )
2299
+ )
2300
+ ),
2301
+ updateLine === null ? null : React8.createElement("div", { key: "config:update", style: S.configFoot }, updateLine),
2302
+ outdated && update.value !== null ? React8.createElement(
2303
+ "div",
2304
+ { key: "config:upgrade", style: S.updateBox },
2305
+ React8.createElement(
2306
+ "span",
2307
+ { style: S.updateTitle },
2308
+ text(t, "updateAvailable", { latest: update.value.latest ?? "?", current: version === null ? "?" : version })
2309
+ ),
2310
+ React8.createElement(
2311
+ "span",
2312
+ { style: S.updateSteps },
2313
+ React8.createElement("span", null, t("updateStepsTitle")),
2314
+ React8.createElement("span", null, t("updateStepOne")),
2315
+ React8.createElement("code", { style: S.code }, "dsh plugin --profile desktop add dsh-session-curator@latest"),
2316
+ React8.createElement("span", null, t("updateStepTwo"))
2317
+ ),
2318
+ React8.createElement(
2319
+ "a",
2320
+ {
2321
+ style: S.link,
2322
+ href: "https://www.npmjs.com/package/dsh-session-curator",
2323
+ target: "_blank",
2324
+ rel: "noreferrer"
2325
+ },
2326
+ t("updateOpen")
2327
+ )
2328
+ ) : null
2329
+ )
2330
+ );
2331
+ const usagePage = React8.createElement(
2332
+ "div",
2333
+ { style: S.column },
2334
+ React8.createElement(
2335
+ "div",
2336
+ { style: S.rowLine },
2337
+ React8.createElement("div", { style: S.filterSpacer }),
2338
+ React8.createElement(
2339
+ import_dsh_client_ui_primitives6.Button,
2340
+ {
2341
+ size: "sm",
2342
+ variant: "outline",
2343
+ disabled: usageBusy,
2344
+ onClick: () => void refreshUsage()
2345
+ },
2346
+ t("refresh")
2347
+ )
2348
+ ),
2349
+ React8.createElement(UsagePanel, {
2350
+ t,
2351
+ usage,
2352
+ loading: usageBusy,
2353
+ error: usageError,
2354
+ busy,
2355
+ // 榜单上的动作走与列表同一个端点(`run()` 会顺手刷新快照),但**完事之后还要
2356
+ // 重拉一次 `usage`**:`run()` 只换快照,`usage` 不在快照里 —— 不重拉的话
2357
+ // 刚点了「取消归档」的那一行仍然顶着「已归档」胶囊,看起来像没生效。
2358
+ onArchive: (sessionId) => runUsageAction("archive", sessionId),
2359
+ onUnarchive: (sessionId) => runUsageAction("unarchive", sessionId)
2360
+ })
2361
+ );
2362
+ return React8.createElement(
2363
+ "div",
2364
+ { style: S.column },
2365
+ React8.createElement(
2366
+ "div",
2367
+ { style: S.header },
2368
+ React8.createElement(
2369
+ "div",
2370
+ { style: S.tabBar },
2371
+ tabs.map(
2372
+ (entry) => React8.createElement(
2373
+ import_dsh_client_ui_primitives6.Button,
2374
+ {
2375
+ key: entry.id,
2376
+ size: "sm",
2377
+ variant: tab === entry.id ? "primary" : "ghost",
2378
+ onClick: () => setTab(entry.id)
2379
+ },
2380
+ `${entry.label}${entry.count > 0 ? ` (${entry.count})` : ""}`
2381
+ )
2382
+ )
2383
+ ),
2384
+ // 分组与刷新推到右边;只有三个**数据**页签上才有它们(配置页与磁盘占用页都没有
2385
+ // 可筛可选的列表,磁盘占用页自带一个只重拉 usage 的刷新按钮)。
2386
+ isDataTab ? React8.createElement(
2387
+ "div",
2388
+ { style: S.headerActions },
2389
+ React8.createElement(
2390
+ import_dsh_client_ui_primitives6.Button,
2391
+ {
2392
+ size: "sm",
2393
+ variant: "outline",
2394
+ title: t("groupHint"),
2395
+ onClick: () => {
2396
+ const next = !grouping;
2397
+ groupingStore.set(next);
2398
+ updateSettings({ grouping: next });
2399
+ }
2400
+ },
2401
+ grouping ? t("groupingOn") : t("groupingOff")
2402
+ ),
2403
+ React8.createElement(
2404
+ import_dsh_client_ui_primitives6.Button,
2405
+ { size: "sm", variant: "outline", disabled: loading || busy, onClick: () => void refresh() },
2406
+ t("refresh")
2407
+ )
2408
+ ) : null
2409
+ ),
2410
+ error ? React8.createElement("div", { style: S.error }, error) : null,
2411
+ // 过滤工具条:只有数据页签上有(配置页没有列表可筛,磁盘占用页也不是列表)。
2412
+ isDataTab ? filterBar : null,
2413
+ isDataTab ? React8.createElement(
2414
+ "div",
2415
+ { style: S.card },
2416
+ loading && !snapshot ? React8.createElement("div", { style: S.empty }, t("loading")) : filtered.length === 0 ? React8.createElement(
2417
+ "div",
2418
+ { style: S.empty },
2419
+ filtering ? t("emptyFiltered") : tab === "archived" ? t("emptyArchived") : tab === "pinned" ? t("emptyPinned") : t("emptyTrash")
2420
+ ) : React8.createElement("div", { style: S.list }, nodes)
2421
+ ) : null,
2422
+ isDataTab ? React8.createElement(
2423
+ "div",
2424
+ { style: S.rowLine },
2425
+ React8.createElement(
2426
+ import_dsh_client_ui_primitives6.Button,
2427
+ {
2428
+ size: "sm",
2429
+ variant: "ghost",
2430
+ disabled: busy || rows.length === 0,
2431
+ onClick: () => setSelected(selected.length === rows.length ? [] : rows.map((row) => row.sessionId))
2432
+ },
2433
+ selected.length === rows.length && rows.length > 0 ? t("selectNone") : t("selectAll")
2434
+ ),
2435
+ batch.map(
2436
+ (action) => React8.createElement(
2437
+ import_dsh_client_ui_primitives6.Button,
2438
+ {
2439
+ key: action.label,
2440
+ size: "sm",
2441
+ variant: action.variant || "ghost",
2442
+ disabled: busy || action.disabled === true,
2443
+ onClick: action.run
2444
+ },
2445
+ action.label
2446
+ )
2447
+ ),
2448
+ tab === "trash" && trashed.length > 0 ? React8.createElement(
2449
+ import_dsh_client_ui_primitives6.Button,
2450
+ {
2451
+ size: "sm",
2452
+ variant: "outline",
2453
+ disabled: busy,
2454
+ onClick: () => requestPurge(
2455
+ trashed.map((row) => row.sessionId),
2456
+ ""
2457
+ )
2458
+ },
2459
+ t("emptyTrashAll")
2460
+ ) : null,
2461
+ busy ? React8.createElement("span", { style: S.footer }, t("working")) : null
2462
+ ) : null,
2463
+ isUsage ? usagePage : null,
2464
+ isConfig ? configPage : null,
2465
+ isDataTab ? React8.createElement(
2466
+ "div",
2467
+ { style: S.footer },
2468
+ React8.createElement("span", null, text(t, "total", { n: rows.length, size: formatBytes(totalBytes) })),
2469
+ // 过滤只改显示:合计那一行报的仍是整个页签的条数,这里再补一句"筛出来几条"。
2470
+ filtering ? React8.createElement("span", null, text(t, "filtered", { n: filtered.length, total: rows.length })) : null,
2471
+ grouping && groups.length > 0 ? React8.createElement("span", null, text(t, "workspaces", { n: groups.length })) : null,
2472
+ selected.length > 0 ? React8.createElement("span", null, text(t, "selected", { n: selected.length })) : null,
2473
+ snapshot ? React8.createElement("span", { style: S.mono, title: snapshot.trashDir }, snapshot.trashDir) : null
2474
+ ) : null
2475
+ );
2476
+ }
2477
+
2478
+ // src/client/components/toast-host.ts
2479
+ var React9 = __toESM(require("react"), 1);
2480
+ var import_dsh_client_ui_primitives7 = require("@deepseek-ai/dsh-client-ui-primitives");
2481
+ var HOLD_MS = 5e3;
2482
+ function ToastHost(props) {
2483
+ const t = props.t;
2484
+ const notice = useStore(toastStore);
2485
+ if (notice === null) return null;
2486
+ if (notice.error !== null) {
2487
+ return React9.createElement(
2488
+ import_dsh_client_ui_primitives7.Toast,
2489
+ {
2490
+ text: text(t, "toastUndoFailed", { reason: notice.error }),
2491
+ icon: React9.createElement(import_dsh_client_ui_primitives7.IconWarningOutlineRegular, {}),
2492
+ onDone: dismissToast
2493
+ },
2494
+ `session-curator-toast-${notice.seq}`
2495
+ );
2496
+ }
2497
+ if (notice.ids.length === 0) {
2498
+ return React9.createElement(
2499
+ import_dsh_client_ui_primitives7.Toast,
2500
+ {
2501
+ text: notice.title,
2502
+ holdMs: HOLD_MS,
2503
+ onDone: dismissToast
2504
+ },
2505
+ `session-curator-toast-${notice.seq}`
2506
+ );
2507
+ }
2508
+ const many = notice.ids.length > 1;
2509
+ return React9.createElement(
2510
+ import_dsh_client_ui_primitives7.Toast,
2511
+ {
2512
+ text: many ? text(t, "toastTrashedMany", { n: notice.ids.length }) : text(t, "toastTrashed", { title: notice.title }),
2513
+ tone: "success",
2514
+ holdMs: HOLD_MS,
2515
+ actions: [{ label: t("toastUndo"), onClick: () => void undoTrash() }],
2516
+ onDone: dismissToast
2517
+ },
2518
+ `session-curator-toast-${notice.seq}`
2519
+ );
2520
+ }
2521
+
2522
+ // src/client/components/trash-row-button.ts
2523
+ var React10 = __toESM(require("react"), 1);
2524
+ var import_dsh_client_ui_primitives8 = require("@deepseek-ai/dsh-client-ui-primitives");
2525
+ function TrashRowButton(props) {
2526
+ const [hovered, setHovered] = React10.useState(false);
2527
+ const title = props.displayTitle || props.sessionId;
2528
+ const label = props.t("moveToTrash");
2529
+ return React10.createElement(import_dsh_client_ui_primitives8.Tooltip, {
2530
+ label,
2531
+ side: "bottom",
2532
+ align: "end",
2533
+ delayMs: 500,
2534
+ children: React10.createElement(
2535
+ "button",
2536
+ {
2537
+ type: "button",
2538
+ style: hovered ? S.rowIconButtonHover : S.rowIconButton,
2539
+ "aria-label": `${title} — ${label}`,
2540
+ onPointerEnter: () => setHovered(true),
2541
+ onPointerLeave: () => setHovered(false),
2542
+ onClick: () => requestTrash([props.sessionId], title)
2543
+ },
2544
+ React10.createElement(import_dsh_client_ui_primitives8.IconTrashOutlineRegular, { size: 14 })
2545
+ )
2546
+ });
2547
+ }
2548
+
2549
+ // src/client/index.ts
2550
+ var MENU_ID = "session-curator.delete";
2551
+ var OVERLAY_ID = "session-curator.dialog";
2552
+ var TOAST_ID = "session-curator.toast";
2553
+ var HOVER_ID = "session-curator.hover";
2554
+ var ROW_ACTION_ID = "session-curator.trash";
2555
+ var SECTION_ID = "session-curator";
2556
+ var NAV_SECTION_ID = "archived-sessions";
2557
+ function sectionIdFor(ctx) {
2558
+ try {
2559
+ const taken = ctx.slots.entries("settings.section").some((entry) => entry?.options?.id === NAV_SECTION_ID);
2560
+ return taken ? SECTION_ID : NAV_SECTION_ID;
2561
+ } catch {
2562
+ return SECTION_ID;
2563
+ }
2564
+ }
2565
+ var inject = ["slots", "locale"];
2566
+ function apply(ctx) {
2567
+ setContext(ctx);
2568
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-session-curator: dictionaries");
2569
+ const t = ctx.locale.bind(NS);
2570
+ const contribute = (slot, options, component, label) => {
2571
+ try {
2572
+ ctx.slots.inject(slot, () => ctx.slots.register(options, component));
2573
+ } catch (error) {
2574
+ reportError(error);
2575
+ if (ctx.logger) ctx.logger.warn(`[session-curator] slot ${slot} 注册失败(${label})`);
2576
+ }
2577
+ };
2578
+ contribute(
2579
+ "sidebar.workspaces.session.menu.item",
2580
+ {
2581
+ name: "sidebar.workspaces.session.menu.item",
2582
+ id: MENU_ID,
2583
+ order: 500,
2584
+ label: () => t("menuDelete"),
2585
+ locale: NS
2586
+ },
2587
+ (props) => {
2588
+ const [, setMenuOpen] = props.useMenuOpenState();
2589
+ return React11.createElement(
2590
+ import_dsh_client_ui_primitives9.MenuItemButton,
2591
+ {
2592
+ danger: true,
2593
+ separatorBefore: true,
2594
+ onSelect: () => {
2595
+ setMenuOpen(false);
2596
+ requestTrash([props.sessionId], props.displayTitle || props.sessionId);
2597
+ }
2598
+ },
2599
+ t("menuDelete")
2600
+ );
2601
+ },
2602
+ "menu item"
2603
+ );
2604
+ contribute(
2605
+ "shell.overlay",
2606
+ {
2607
+ name: "shell.overlay",
2608
+ id: OVERLAY_ID,
2609
+ order: 60,
2610
+ locale: NS
2611
+ },
2612
+ DialogHost,
2613
+ "dialog host"
2614
+ );
2615
+ contribute(
2616
+ "settings.section",
2617
+ {
2618
+ name: "settings.section",
2619
+ id: sectionIdFor(ctx),
2620
+ order: 45,
2621
+ label: () => t("sectionLabel"),
2622
+ locale: NS
2623
+ },
2624
+ SessionManagerPanel,
2625
+ "settings section"
2626
+ );
2627
+ contribute(
2628
+ "sidebar.session.row.hover",
2629
+ {
2630
+ name: "sidebar.session.row.hover",
2631
+ id: HOVER_ID,
2632
+ // 官方 ui-schedule 的任务列表在这个槽位是 order 10;我们排在它后面。
2633
+ order: 50,
2634
+ locale: NS
2635
+ },
2636
+ SessionHover,
2637
+ "hover card"
2638
+ );
2639
+ contribute(
2640
+ "sidebar.workspaces.session.row.action",
2641
+ {
2642
+ name: "sidebar.workspaces.session.row.action",
2643
+ id: ROW_ACTION_ID,
2644
+ order: 500,
2645
+ locale: NS
2646
+ },
2647
+ TrashRowButton,
2648
+ "row action"
2649
+ );
2650
+ contribute(
2651
+ "shell.overlay",
2652
+ {
2653
+ name: "shell.overlay",
2654
+ id: TOAST_ID,
2655
+ order: 70,
2656
+ locale: NS
2657
+ },
2658
+ ToastHost,
2659
+ "toast host"
2660
+ );
2661
+ }
2662
+ })(module, module.exports, require);
2663
+ return module.exports;
1429
2664
  }
1430
2665
  });