dsh-session-curator 0.1.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/LICENSE +21 -0
- package/README.md +216 -0
- package/README.zh-CN.md +171 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/lib/client.js +1011 -0
- package/lib/index.js +556 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +118 -0
- package/scripts/activation.mjs +358 -0
- package/scripts/client.mjs +418 -0
- package/scripts/smoke.mjs +181 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
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
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: "dsh-session-curator",
|
|
17
|
+
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
|
+
IconChevronDownOutlineRegular,
|
|
28
|
+
IconChevronRightOutlineRegular,
|
|
29
|
+
IconEllipsisOutlineRegular,
|
|
30
|
+
IconFolderCloseRegular,
|
|
31
|
+
IconFolderOpenRegular,
|
|
32
|
+
IconPinOutlineRegular,
|
|
33
|
+
IconRefreshOutlineRegular,
|
|
34
|
+
IconTrashOutlineRegular,
|
|
35
|
+
IconUnarchiveOutlineRegular
|
|
36
|
+
} = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
37
|
+
const h = React.createElement;
|
|
38
|
+
|
|
39
|
+
const NS = "session-curator";
|
|
40
|
+
/** 官方共享通道;本插件占 `/api/session-curator/<endpoint>`。 */
|
|
41
|
+
const API_CHANNEL = "/api";
|
|
42
|
+
const ROUTE_PREFIX = "session-curator";
|
|
43
|
+
const MENU_ID = "session-curator.delete";
|
|
44
|
+
const OVERLAY_ID = "session-curator.dialog";
|
|
45
|
+
/** 本插件自己的设置页 id(也是"没得借"时的兜底)。 */
|
|
46
|
+
const SECTION_ID = "session-curator";
|
|
47
|
+
/**
|
|
48
|
+
* 设置页导航栏的图标**不是注册时给的** —— 设置外壳
|
|
49
|
+
* `@deepseek-ai/dsh-client-ui-settings-general` 的 `navIcon(id)` 是按 **section id 查表**:
|
|
50
|
+
*
|
|
51
|
+
* ```js
|
|
52
|
+
* account → 人像 / models → 数据 / agent-presets → 预设 / plugins → 个性化
|
|
53
|
+
* archived-sessions → 归档盒子 / 其它一律 → 齿轮(默认)
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* 我们的 `session-curator` 不在表里,所以一直是"默认齿轮"(`settings.section` 的
|
|
57
|
+
* options 只读 id / order / label,外壳不看 icon 字段,插件没法自带导航图标)。
|
|
58
|
+
* 表里**唯一没人用**的 id 是 `archived-sessions`,它映射到归档盒子图标 —— 语义刚好贴合
|
|
59
|
+
* 本插件(归档 / 回收站管理),所以借用它。
|
|
60
|
+
*/
|
|
61
|
+
const NAV_SECTION_ID = "archived-sessions";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 借 id 之前先看槽位账本:**一旦将来 DSH 自己注册了 `archived-sessions`,
|
|
65
|
+
* 我们就退回自己的 id**(拿默认齿轮),绝不会跟官方撞车。
|
|
66
|
+
*/
|
|
67
|
+
function sectionIdFor(ctx) {
|
|
68
|
+
try {
|
|
69
|
+
const taken = ctx.slots.entries("settings.section").some((entry) => entry?.options?.id === NAV_SECTION_ID);
|
|
70
|
+
return taken ? SECTION_ID : NAV_SECTION_ID;
|
|
71
|
+
} catch {
|
|
72
|
+
// 拿不到账本(没有 entries)就退回自己的 id。
|
|
73
|
+
return SECTION_ID;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 文案 ────────────────────────────────────────────────────────────
|
|
78
|
+
const zh = {
|
|
79
|
+
sectionLabel: "会话管理",
|
|
80
|
+
menuDelete: "删除会话",
|
|
81
|
+
titleArchived: "已归档",
|
|
82
|
+
titlePinned: "已置顶",
|
|
83
|
+
titleTrash: "回收站",
|
|
84
|
+
refresh: "刷新",
|
|
85
|
+
loading: "读取中…",
|
|
86
|
+
retry: "重试",
|
|
87
|
+
emptyArchived: "还没有归档的会话。在会话行的「⋯」菜单里归档后会出现在这里。",
|
|
88
|
+
emptyPinned: "没有置顶的会话。",
|
|
89
|
+
emptyTrash: "回收站是空的。",
|
|
90
|
+
unarchive: "取消归档",
|
|
91
|
+
pin: "置顶",
|
|
92
|
+
unpin: "取消置顶",
|
|
93
|
+
restore: "恢复",
|
|
94
|
+
purge: "永久删除",
|
|
95
|
+
moveToTrash: "移入回收站",
|
|
96
|
+
rowActions: "更多操作",
|
|
97
|
+
selectAll: "全选",
|
|
98
|
+
selectNone: "取消选择",
|
|
99
|
+
selected: "已选 {n} 项",
|
|
100
|
+
emptyTrashAll: "清空回收站",
|
|
101
|
+
total: "合计 {n} 个会话 · {size}",
|
|
102
|
+
working: "处理中…",
|
|
103
|
+
sessionRunning: "会话正在运行,先停止它再删除。",
|
|
104
|
+
confirmTrashTitle: "删除会话",
|
|
105
|
+
confirmTrashBody: "「{title}」会被移到回收站,之后可以在「设置 → 会话管理」里恢复。",
|
|
106
|
+
confirmTrashAction: "移到回收站",
|
|
107
|
+
confirmPurgeTitle: "永久删除",
|
|
108
|
+
confirmPurgeBody: "「{title}」将从回收站永久删除,日志文件会被彻底移除,无法恢复。",
|
|
109
|
+
confirmPurgeManyBody: "选中的 {n} 个会话将从回收站永久删除,无法恢复。",
|
|
110
|
+
confirmPurgeAction: "永久删除",
|
|
111
|
+
ackPurge: "我明白这无法撤销",
|
|
112
|
+
cancel: "取消",
|
|
113
|
+
close: "关闭",
|
|
114
|
+
noProject: "(未知目录)",
|
|
115
|
+
files: "{n} 个文件",
|
|
116
|
+
groupingOn: "分组:工作区",
|
|
117
|
+
groupingOff: "分组:平铺",
|
|
118
|
+
groupHint: "按会话原来的工作区文件夹归类;点分组标题折叠,左边的勾选框选中整个工作区",
|
|
119
|
+
sessions: "{n} 个会话",
|
|
120
|
+
workspaces: "{n} 个工作区",
|
|
121
|
+
selectGroup: "全选该工作区",
|
|
122
|
+
clearGroup: "取消选中该工作区",
|
|
123
|
+
selectAllShort: "全选",
|
|
124
|
+
clearShort: "取消",
|
|
125
|
+
collapse: "折叠",
|
|
126
|
+
expand: "展开"
|
|
127
|
+
};
|
|
128
|
+
const en = {
|
|
129
|
+
sectionLabel: "Session manager",
|
|
130
|
+
menuDelete: "Delete session",
|
|
131
|
+
titleArchived: "Archived",
|
|
132
|
+
titlePinned: "Pinned",
|
|
133
|
+
titleTrash: "Trash",
|
|
134
|
+
refresh: "Refresh",
|
|
135
|
+
loading: "Loading…",
|
|
136
|
+
retry: "Retry",
|
|
137
|
+
emptyArchived: "No archived sessions yet. Archive one from its “⋯” menu and it shows up here.",
|
|
138
|
+
emptyPinned: "No pinned sessions.",
|
|
139
|
+
emptyTrash: "The trash is empty.",
|
|
140
|
+
unarchive: "Unarchive",
|
|
141
|
+
pin: "Pin",
|
|
142
|
+
unpin: "Unpin",
|
|
143
|
+
restore: "Restore",
|
|
144
|
+
purge: "Delete permanently",
|
|
145
|
+
moveToTrash: "Move to trash",
|
|
146
|
+
rowActions: "More actions",
|
|
147
|
+
selectAll: "Select all",
|
|
148
|
+
selectNone: "Clear selection",
|
|
149
|
+
selected: "{n} selected",
|
|
150
|
+
emptyTrashAll: "Empty trash",
|
|
151
|
+
total: "{n} sessions · {size}",
|
|
152
|
+
working: "Working…",
|
|
153
|
+
sessionRunning: "This session is running; stop it before deleting.",
|
|
154
|
+
confirmTrashTitle: "Delete session",
|
|
155
|
+
confirmTrashBody: "“{title}” moves to the trash. You can restore it later under Settings → Session manager.",
|
|
156
|
+
confirmTrashAction: "Move to trash",
|
|
157
|
+
confirmPurgeTitle: "Delete permanently",
|
|
158
|
+
confirmPurgeBody: "“{title}” is removed from the trash for good; its log files are gone and cannot be recovered.",
|
|
159
|
+
confirmPurgeManyBody: "{n} selected sessions are removed from the trash for good and cannot be recovered.",
|
|
160
|
+
confirmPurgeAction: "Delete permanently",
|
|
161
|
+
ackPurge: "I understand this cannot be undone",
|
|
162
|
+
cancel: "Cancel",
|
|
163
|
+
close: "Close",
|
|
164
|
+
noProject: "(unknown directory)",
|
|
165
|
+
files: "{n} files",
|
|
166
|
+
groupingOn: "Grouped: workspace",
|
|
167
|
+
groupingOff: "Grouped: off",
|
|
168
|
+
groupHint: "Group sessions under their original workspace folder; click a header to collapse it, the checkbox selects the whole workspace",
|
|
169
|
+
sessions: "{n} sessions",
|
|
170
|
+
workspaces: "{n} workspaces",
|
|
171
|
+
selectGroup: "Select this workspace",
|
|
172
|
+
clearGroup: "Clear this workspace",
|
|
173
|
+
selectAllShort: "Select all",
|
|
174
|
+
clearShort: "Clear",
|
|
175
|
+
collapse: "Collapse",
|
|
176
|
+
expand: "Expand"
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ── 两个模块级小 store(弹窗宿主与设置面板共享) ─────────────────────
|
|
180
|
+
function createStore(initial) {
|
|
181
|
+
let value = initial;
|
|
182
|
+
const listeners = new Set();
|
|
183
|
+
return {
|
|
184
|
+
get: () => value,
|
|
185
|
+
set: (next) => {
|
|
186
|
+
value = next;
|
|
187
|
+
for (const listener of listeners) listener();
|
|
188
|
+
},
|
|
189
|
+
subscribe: (listener) => {
|
|
190
|
+
listeners.add(listener);
|
|
191
|
+
return () => listeners.delete(listener);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function useStore(store) {
|
|
196
|
+
const [value, setValue] = React.useState(store.get);
|
|
197
|
+
React.useEffect(() => store.subscribe(() => setValue(store.get())), [store]);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 待确认的请求;null 表示没有弹窗。 */
|
|
202
|
+
const dialogStore = createStore(null);
|
|
203
|
+
/** 每次写操作成功后自增,让设置面板重新拉快照。 */
|
|
204
|
+
const mutationStore = createStore(0);
|
|
205
|
+
/** "按工作区分组"开关;模块级,切走设置页再回来仍然记得。 */
|
|
206
|
+
const groupingStore = createStore(true);
|
|
207
|
+
|
|
208
|
+
function requestTrash(ids, title) {
|
|
209
|
+
dialogStore.set({ kind: "trash", ids, title });
|
|
210
|
+
}
|
|
211
|
+
function requestPurge(ids, title) {
|
|
212
|
+
dialogStore.set({ kind: "purge", ids, title });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 宿主调用 ────────────────────────────────────────────────────────
|
|
216
|
+
let ctxRef = null;
|
|
217
|
+
|
|
218
|
+
/** RFC 4122 v4;浏览器在非安全上下文也有 crypto.getRandomValues。 */
|
|
219
|
+
function randomUuid() {
|
|
220
|
+
const bytes = new Uint8Array(16);
|
|
221
|
+
const crypto = globalThis.crypto;
|
|
222
|
+
if (crypto && typeof crypto.getRandomValues === "function") crypto.getRandomValues(bytes);
|
|
223
|
+
else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
|
|
224
|
+
const view = new DataView(bytes.buffer);
|
|
225
|
+
view.setUint8(6, (view.getUint8(6) & 15) | 64);
|
|
226
|
+
view.setUint8(8, (view.getUint8(8) & 63) | 128);
|
|
227
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
228
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 端点相对共享通道的目标:`/api/session-curator/<endpoint>`。 */
|
|
232
|
+
function target(endpoint) {
|
|
233
|
+
return `${ROUTE_PREFIX}/${endpoint}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 手写 Connection 线协议:逐字复刻 `@deepseek-ai/dsh-client-connection/lib/client.js`
|
|
238
|
+
* 的 `createWebConnectionRpc().call` —— POST `${channel}/${endpoint}`(去掉前导 `/`,
|
|
239
|
+
* 即文档相对路径),body 是 `{type:'client-request', rpcId, method, payload}`,
|
|
240
|
+
* 其中 `method` 是**相对通道**的端点(宿主按它核对路由),响应信封是
|
|
241
|
+
* `{type:'server-response', rpcId, result:{ok,value|error}}`。
|
|
242
|
+
* 这样客户端**不依赖任何 Cordis 服务**;这条 HTTP 路由的信任栅栏与会话鉴权
|
|
243
|
+
* 仍然由宿主 Connection 的 `/api` 前缀路由把关。
|
|
244
|
+
*/
|
|
245
|
+
async function rawCall(endpoint, payload) {
|
|
246
|
+
const rpcId = randomUuid();
|
|
247
|
+
const url = `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`;
|
|
248
|
+
const response = await fetch(url.slice(1), {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
body: JSON.stringify({ type: "client-request", rpcId, method: target(endpoint), payload: payload || {} })
|
|
252
|
+
});
|
|
253
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}(${url})`);
|
|
254
|
+
let envelope;
|
|
255
|
+
try {
|
|
256
|
+
envelope = await response.json();
|
|
257
|
+
} catch {
|
|
258
|
+
throw new Error(`响应不是 JSON(${url})`);
|
|
259
|
+
}
|
|
260
|
+
if (!envelope || envelope.type !== "server-response" || typeof envelope.rpcId !== "string") {
|
|
261
|
+
throw new Error("无效的 server-response 信封");
|
|
262
|
+
}
|
|
263
|
+
if (envelope.rpcId !== rpcId) throw new Error(`rpcId 不匹配(${endpoint})`);
|
|
264
|
+
const result = envelope.result;
|
|
265
|
+
if (!result || (result.ok !== true && result.ok !== false)) throw new Error("无效的 server-response 结果");
|
|
266
|
+
if (!result.ok) throw new Error(`${result.error ? result.error.code : "error"}: ${result.error ? result.error.message : "未知错误"}`);
|
|
267
|
+
return result.value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 首选宿主自带的调用器(若客户端真有这个服务),否则退回手写 fetch。 */
|
|
271
|
+
async function call(endpoint, payload) {
|
|
272
|
+
const connection = ctxRef === null ? null : ctxRef.get("connection");
|
|
273
|
+
const rpc = connection ? connection.rpc : null;
|
|
274
|
+
if (rpc && typeof rpc.call === "function") {
|
|
275
|
+
const result = await rpc.call(API_CHANNEL, target(endpoint), payload || {});
|
|
276
|
+
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
277
|
+
return result.value;
|
|
278
|
+
}
|
|
279
|
+
return rawCall(endpoint, payload);
|
|
280
|
+
}
|
|
281
|
+
function reportError(error) {
|
|
282
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
283
|
+
if (ctxRef !== null && ctxRef.logger) ctxRef.logger.warn(`[session-curator] ${message}`);
|
|
284
|
+
return message;
|
|
285
|
+
}
|
|
286
|
+
/** 逐项结果里第一条失败原因,用于把宿主拒绝转成一句人话。 */
|
|
287
|
+
function firstFailure(results) {
|
|
288
|
+
for (const row of results || []) {
|
|
289
|
+
if (!row.ok) return row.reason || "操作失败";
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 展示辅助 ────────────────────────────────────────────────────────
|
|
295
|
+
function formatBytes(bytes) {
|
|
296
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
297
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
298
|
+
let value = bytes;
|
|
299
|
+
let unit = 0;
|
|
300
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
301
|
+
value /= 1024;
|
|
302
|
+
unit += 1;
|
|
303
|
+
}
|
|
304
|
+
return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
305
|
+
}
|
|
306
|
+
function formatTime(ms) {
|
|
307
|
+
if (typeof ms !== "number" || ms <= 0) return "";
|
|
308
|
+
try {
|
|
309
|
+
return new Date(ms).toLocaleString();
|
|
310
|
+
} catch {
|
|
311
|
+
return "";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function text(t, key, params) {
|
|
315
|
+
let value = t(key);
|
|
316
|
+
if (params) {
|
|
317
|
+
for (const name of Object.keys(params)) value = value.split(`{${name}}`).join(String(params[name]));
|
|
318
|
+
}
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
function baseName(path) {
|
|
322
|
+
if (typeof path !== "string" || path === "") return "";
|
|
323
|
+
const parts = path.split(/[\\/]/).filter((part) => part !== "");
|
|
324
|
+
return parts.length > 0 ? parts[parts.length - 1] : path;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 按工作区文件夹分组 ──────────────────────────────────────────────
|
|
328
|
+
/**
|
|
329
|
+
* 分组键 = 会话**原来的工作区文件夹**。
|
|
330
|
+
* 优先 cwd(Windows 大小写不敏感、末尾分隔符无关,所以归一化后再比),
|
|
331
|
+
* 没有 cwd 时退到 workspaceId,两者都没有就落进"未知目录"那一组。
|
|
332
|
+
*/
|
|
333
|
+
function groupKeyOf(row) {
|
|
334
|
+
if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
|
|
335
|
+
return `cwd:${row.cwd.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase()}`;
|
|
336
|
+
}
|
|
337
|
+
if (typeof row.workspaceId === "string" && row.workspaceId !== "") return `id:${row.workspaceId}`;
|
|
338
|
+
return "unknown";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 分成 `[{ key, title, cwd, bytes, rows[] }]`。组内保持宿主给的顺序
|
|
343
|
+
* (回收站本来就是"最近删除在前");组间按文件夹名排序,"未知目录"永远在最后 ——
|
|
344
|
+
* 归档 / 取消归档之后列表不会整片跳位。
|
|
345
|
+
*/
|
|
346
|
+
function groupRows(rows, t) {
|
|
347
|
+
const groups = new Map();
|
|
348
|
+
for (const row of rows) {
|
|
349
|
+
const key = groupKeyOf(row);
|
|
350
|
+
let group = groups.get(key);
|
|
351
|
+
if (group === undefined) {
|
|
352
|
+
const cwd = typeof row.cwd === "string" && row.cwd !== "" ? row.cwd : null;
|
|
353
|
+
const title = (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "" ? row.workspaceTitle : null)
|
|
354
|
+
?? (cwd === null ? null : baseName(cwd) || cwd)
|
|
355
|
+
?? t("noProject");
|
|
356
|
+
group = { key, title, cwd, bytes: 0, rows: [] };
|
|
357
|
+
groups.set(key, group);
|
|
358
|
+
}
|
|
359
|
+
group.rows.push(row);
|
|
360
|
+
if (typeof row.bytes === "number" && Number.isFinite(row.bytes)) group.bytes += row.bytes;
|
|
361
|
+
}
|
|
362
|
+
const list = [...groups.values()];
|
|
363
|
+
list.sort((left, right) => {
|
|
364
|
+
if (left.key === "unknown" || right.key === "unknown") {
|
|
365
|
+
return left.key === right.key ? 0 : left.key === "unknown" ? 1 : -1;
|
|
366
|
+
}
|
|
367
|
+
return left.title.localeCompare(right.title);
|
|
368
|
+
});
|
|
369
|
+
return list;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── 内联样式(全部走主题 token,不依赖 styles 服务) ────────────────
|
|
373
|
+
const S = {
|
|
374
|
+
column: { display: "flex", flexDirection: "column", gap: "10px", padding: "4px 0", minWidth: 0 },
|
|
375
|
+
rowLine: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
|
|
376
|
+
tabBar: { display: "flex", alignItems: "center", gap: "6px" },
|
|
377
|
+
card: {
|
|
378
|
+
border: "1px solid var(--dsw-alias-border-l1)",
|
|
379
|
+
borderRadius: "10px",
|
|
380
|
+
overflow: "hidden"
|
|
381
|
+
},
|
|
382
|
+
list: { display: "flex", flexDirection: "column" },
|
|
383
|
+
item: {
|
|
384
|
+
display: "flex",
|
|
385
|
+
alignItems: "center",
|
|
386
|
+
gap: "10px",
|
|
387
|
+
padding: "8px 10px",
|
|
388
|
+
borderTop: "1px solid var(--dsw-alias-border-l1)"
|
|
389
|
+
},
|
|
390
|
+
itemMain: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0, flex: "1 1 auto" },
|
|
391
|
+
itemTitle: {
|
|
392
|
+
color: "var(--dsw-alias-label-primary)",
|
|
393
|
+
fontSize: "13px",
|
|
394
|
+
overflow: "hidden",
|
|
395
|
+
textOverflow: "ellipsis",
|
|
396
|
+
whiteSpace: "nowrap"
|
|
397
|
+
},
|
|
398
|
+
itemMeta: {
|
|
399
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
400
|
+
fontSize: "11px",
|
|
401
|
+
overflow: "hidden",
|
|
402
|
+
textOverflow: "ellipsis",
|
|
403
|
+
whiteSpace: "nowrap"
|
|
404
|
+
},
|
|
405
|
+
itemActions: { display: "flex", alignItems: "center", gap: "6px", flex: "0 0 auto" },
|
|
406
|
+
/** 「⋯」触发器:图标按钮,弱化成次要前景色(跟侧边栏行的图标按钮一致)。 */
|
|
407
|
+
rowMenuButton: { color: "var(--dsw-alias-label-secondary)", padding: "0 6px" },
|
|
408
|
+
/**
|
|
409
|
+
* 文件夹那一行的底色。**不能用 `--dsw-alias-bg-layer-*`**:浅色主题里
|
|
410
|
+
* base / layer-1 / layer-2 / layer-3 都指向同一个颜色(`neutral-bluish-00`),
|
|
411
|
+
* 铺上去等于没铺(这就是之前"看不出区别"的原因)。
|
|
412
|
+
* `interactive-bg-active` 是带 alpha 的叠加色(#2631481a,约 10%),
|
|
413
|
+
* 深浅主题都有效;比行 hover 用的 `interactive-bg-hover`(约 6%)略重一点,
|
|
414
|
+
* 这样"文件夹条"和"鼠标划过的会话行"不会糊在一起。
|
|
415
|
+
*/
|
|
416
|
+
groupHead: {
|
|
417
|
+
display: "flex",
|
|
418
|
+
alignItems: "center",
|
|
419
|
+
gap: "10px",
|
|
420
|
+
padding: "6px 10px",
|
|
421
|
+
background: "var(--dsw-alias-interactive-bg-active)",
|
|
422
|
+
borderTop: "1px solid var(--dsw-alias-border-l1)"
|
|
423
|
+
},
|
|
424
|
+
groupToggle: {
|
|
425
|
+
display: "flex",
|
|
426
|
+
alignItems: "center",
|
|
427
|
+
gap: "8px",
|
|
428
|
+
minWidth: 0,
|
|
429
|
+
flex: "1 1 auto",
|
|
430
|
+
background: "transparent",
|
|
431
|
+
border: "none",
|
|
432
|
+
padding: 0,
|
|
433
|
+
margin: 0,
|
|
434
|
+
color: "inherit",
|
|
435
|
+
font: "inherit",
|
|
436
|
+
textAlign: "left",
|
|
437
|
+
cursor: "pointer"
|
|
438
|
+
},
|
|
439
|
+
/** 图标只吃 `size` / `className`,不接 `style`:颜色靠外层 span 的 currentColor 传下去。 */
|
|
440
|
+
groupCaret: { color: "var(--dsw-alias-label-tertiary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
|
|
441
|
+
groupIcon: { color: "var(--dsw-alias-label-secondary)", flex: "0 0 auto", display: "flex", alignItems: "center" },
|
|
442
|
+
groupTitle: {
|
|
443
|
+
color: "var(--dsw-alias-label-primary)",
|
|
444
|
+
fontSize: "12px",
|
|
445
|
+
fontWeight: 600,
|
|
446
|
+
overflow: "hidden",
|
|
447
|
+
textOverflow: "ellipsis",
|
|
448
|
+
whiteSpace: "nowrap",
|
|
449
|
+
flex: "0 1 auto"
|
|
450
|
+
},
|
|
451
|
+
groupMeta: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", flex: "0 0 auto" },
|
|
452
|
+
groupPath: {
|
|
453
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
454
|
+
fontSize: "11px",
|
|
455
|
+
overflow: "hidden",
|
|
456
|
+
textOverflow: "ellipsis",
|
|
457
|
+
whiteSpace: "nowrap",
|
|
458
|
+
minWidth: 0,
|
|
459
|
+
flex: "0 1 auto"
|
|
460
|
+
},
|
|
461
|
+
empty: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "14px 10px", textAlign: "center" },
|
|
462
|
+
error: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px" },
|
|
463
|
+
footer: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px", display: "flex", gap: "10px", flexWrap: "wrap" },
|
|
464
|
+
mono: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
465
|
+
ack: { display: "flex", alignItems: "center", gap: "8px", fontSize: "12px", color: "var(--dsw-alias-label-primary)" }
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// ── 会话行 ──────────────────────────────────────────────────────────
|
|
469
|
+
/**
|
|
470
|
+
* 一行会话。分组开着时 `showCwd` 为 false —— 文件夹已经写在组标题上,
|
|
471
|
+
* 行里不必再重复一遍;平铺时才把它显示回来。
|
|
472
|
+
*
|
|
473
|
+
* 右侧原来并排 2–3 个文字按钮("看着有点乱"),现在统一成**一个「⋯」菜单**
|
|
474
|
+
* (`Menu` + `IconEllipsisOutlineRegular`,与侧边栏会话行的 ⋯ 完全同一套写法):
|
|
475
|
+
* 每行的形状一致,动作收进菜单,危险项用 `danger` 标红。
|
|
476
|
+
*/
|
|
477
|
+
function SessionRow(props) {
|
|
478
|
+
const row = props.row;
|
|
479
|
+
const t = props.t;
|
|
480
|
+
const meta = [];
|
|
481
|
+
if (props.showCwd) meta.push(row.cwd ? row.cwd : t("noProject"));
|
|
482
|
+
if (row.bytes) meta.push(formatBytes(row.bytes));
|
|
483
|
+
if (row.files) meta.push(text(t, "files", { n: row.files }));
|
|
484
|
+
const time = props.showDeletedAt ? formatTime(row.deletedAt) : formatTime(row.createdAt);
|
|
485
|
+
if (time !== "") meta.push(time);
|
|
486
|
+
|
|
487
|
+
const menu = props.menu;
|
|
488
|
+
return h(
|
|
489
|
+
"div",
|
|
490
|
+
{ style: props.first ? { ...S.item, borderTop: "none" } : S.item },
|
|
491
|
+
h("input", {
|
|
492
|
+
type: "checkbox",
|
|
493
|
+
checked: props.checked,
|
|
494
|
+
disabled: props.busy,
|
|
495
|
+
"aria-label": row.title,
|
|
496
|
+
onChange: () => props.onToggle()
|
|
497
|
+
}),
|
|
498
|
+
h(
|
|
499
|
+
"div",
|
|
500
|
+
{ style: S.itemMain },
|
|
501
|
+
h("div", { style: S.itemTitle, title: row.title }, row.title),
|
|
502
|
+
h("div", { style: S.itemMeta, title: row.cwd || "" }, meta.join(" · "))
|
|
503
|
+
),
|
|
504
|
+
h(
|
|
505
|
+
"div",
|
|
506
|
+
{ style: S.itemActions },
|
|
507
|
+
h(Menu, {
|
|
508
|
+
open: props.menuOpen,
|
|
509
|
+
portal: true,
|
|
510
|
+
side: "bottom",
|
|
511
|
+
align: "end",
|
|
512
|
+
closeOnPointerLeave: true,
|
|
513
|
+
items: menu.items,
|
|
514
|
+
onSelect: (id) => menu.run(id),
|
|
515
|
+
onClose: () => props.onCloseMenu(),
|
|
516
|
+
anchor: h(Button, {
|
|
517
|
+
size: "sm",
|
|
518
|
+
variant: "ghost",
|
|
519
|
+
disabled: props.busy,
|
|
520
|
+
style: S.rowMenuButton,
|
|
521
|
+
icon: h(IconEllipsisOutlineRegular, {}),
|
|
522
|
+
"aria-label": `${row.title} — ${t("rowActions")}`,
|
|
523
|
+
onClick: (event) => {
|
|
524
|
+
event.stopPropagation();
|
|
525
|
+
props.onToggleMenu();
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
)
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 一个工作区分组的标题行:左边(箭头 + 文件夹图标 + 名字)折叠 / 展开,右边一个小按钮选整组。 */
|
|
534
|
+
function GroupHeader(props) {
|
|
535
|
+
const group = props.group;
|
|
536
|
+
const t = props.t;
|
|
537
|
+
const total = group.rows.length;
|
|
538
|
+
const allSelected = total > 0 && group.rows.every((row) => props.selected.includes(row.sessionId));
|
|
539
|
+
const action = allSelected ? t("clearShort") : t("selectAllShort");
|
|
540
|
+
return h(
|
|
541
|
+
"div",
|
|
542
|
+
{ style: props.first ? { ...S.groupHead, borderTop: "none" } : S.groupHead },
|
|
543
|
+
h(
|
|
544
|
+
"button",
|
|
545
|
+
{
|
|
546
|
+
type: "button",
|
|
547
|
+
style: S.groupToggle,
|
|
548
|
+
title: props.collapsed ? t("expand") : t("collapse"),
|
|
549
|
+
onClick: () => props.onToggleCollapse()
|
|
550
|
+
},
|
|
551
|
+
// 箭头与文件夹图标都用 DSH 自带图标(跟侧边栏的项目行同款,展开 = 打开的文件夹)。
|
|
552
|
+
h("span", { style: S.groupCaret }, props.collapsed
|
|
553
|
+
? h(IconChevronRightOutlineRegular, { size: 12 })
|
|
554
|
+
: h(IconChevronDownOutlineRegular, { size: 12 })),
|
|
555
|
+
h("span", { style: S.groupIcon }, props.collapsed
|
|
556
|
+
? h(IconFolderCloseRegular, { size: 14 })
|
|
557
|
+
: h(IconFolderOpenRegular, { size: 14 })),
|
|
558
|
+
h("span", { style: S.groupTitle, title: group.cwd || group.title }, group.title),
|
|
559
|
+
h("span", { style: S.groupMeta }, text(t, "sessions", { n: total })),
|
|
560
|
+
group.bytes > 0 ? h("span", { style: S.groupMeta }, formatBytes(group.bytes)) : null,
|
|
561
|
+
group.cwd !== null ? h("span", { style: { ...S.groupPath, ...S.mono }, title: group.cwd }, group.cwd) : null
|
|
562
|
+
),
|
|
563
|
+
h(Button, {
|
|
564
|
+
size: "sm",
|
|
565
|
+
variant: "ghost",
|
|
566
|
+
disabled: props.busy,
|
|
567
|
+
title: `${group.title} — ${allSelected ? t("clearGroup") : t("selectGroup")}`,
|
|
568
|
+
onClick: () => props.onToggleGroup()
|
|
569
|
+
}, action)
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── 设置面板 ────────────────────────────────────────────────────────
|
|
574
|
+
function SessionManagerPanel(props) {
|
|
575
|
+
const t = props.t;
|
|
576
|
+
const [tab, setTab] = React.useState("archived");
|
|
577
|
+
const [snapshot, setSnapshot] = React.useState(null);
|
|
578
|
+
const [error, setError] = React.useState(null);
|
|
579
|
+
const [loading, setLoading] = React.useState(true);
|
|
580
|
+
const [busy, setBusy] = React.useState(false);
|
|
581
|
+
const [selected, setSelected] = React.useState([]);
|
|
582
|
+
// 折叠的分组键(`<tab>:<groupKey>`),按页签各记各的。
|
|
583
|
+
const [collapsed, setCollapsed] = React.useState([]);
|
|
584
|
+
// 同一时刻只允许一个行的「⋯」菜单开着:存 sessionId。
|
|
585
|
+
const [menuFor, setMenuFor] = React.useState(null);
|
|
586
|
+
const grouping = useStore(groupingStore);
|
|
587
|
+
const revision = useStore(mutationStore);
|
|
588
|
+
|
|
589
|
+
const applyResult = (value) => {
|
|
590
|
+
if (value && value.archived) setSnapshot(value);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const refresh = React.useCallback(async () => {
|
|
594
|
+
setLoading(true);
|
|
595
|
+
try {
|
|
596
|
+
applyResult(await call("snapshot", {}));
|
|
597
|
+
setError(null);
|
|
598
|
+
} catch (failure) {
|
|
599
|
+
setError(reportError(failure));
|
|
600
|
+
} finally {
|
|
601
|
+
setLoading(false);
|
|
602
|
+
}
|
|
603
|
+
}, []);
|
|
604
|
+
|
|
605
|
+
React.useEffect(() => {
|
|
606
|
+
void refresh();
|
|
607
|
+
}, [refresh, revision]);
|
|
608
|
+
|
|
609
|
+
// 换页签时清空选择,避免把上一个页签的 id 带到下一个页签的操作里。
|
|
610
|
+
React.useEffect(() => {
|
|
611
|
+
setSelected([]);
|
|
612
|
+
setMenuFor(null);
|
|
613
|
+
}, [tab]);
|
|
614
|
+
|
|
615
|
+
const run = async (endpoint, ids, payload) => {
|
|
616
|
+
if (ids.length === 0) return;
|
|
617
|
+
setBusy(true);
|
|
618
|
+
try {
|
|
619
|
+
const value = await call(endpoint, { ids, ...(payload || {}) });
|
|
620
|
+
applyResult(value);
|
|
621
|
+
setError(firstFailure(value.results));
|
|
622
|
+
setSelected([]);
|
|
623
|
+
} catch (failure) {
|
|
624
|
+
setError(reportError(failure));
|
|
625
|
+
} finally {
|
|
626
|
+
setBusy(false);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const toggle = (sessionId) => {
|
|
631
|
+
setSelected((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : current.concat([sessionId]));
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/** 整组一起选 / 取消选:组内全选就整组取消,否则补齐。 */
|
|
635
|
+
const toggleGroup = (group) => {
|
|
636
|
+
const ids = group.rows.map((row) => row.sessionId);
|
|
637
|
+
const all = ids.every((id) => selected.includes(id));
|
|
638
|
+
setSelected((current) => all
|
|
639
|
+
? current.filter((id) => !ids.includes(id))
|
|
640
|
+
: [...new Set(current.concat(ids))]);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const archived = snapshot ? snapshot.archived : [];
|
|
644
|
+
const pinned = snapshot ? snapshot.pinned : [];
|
|
645
|
+
const trashed = snapshot ? snapshot.trashed : [];
|
|
646
|
+
const rows = tab === "archived" ? archived : tab === "pinned" ? pinned : trashed;
|
|
647
|
+
const selectedRows = rows.filter((row) => selected.includes(row.sessionId));
|
|
648
|
+
|
|
649
|
+
// 分组只是显示层:选择、批量操作、计数仍然按整个页签的行来算。
|
|
650
|
+
const groups = grouping ? groupRows(rows, t) : [];
|
|
651
|
+
const blockOf = (group) => {
|
|
652
|
+
const key = `${tab}:${group.key}`;
|
|
653
|
+
return { key, group, rows: collapsed.includes(key) ? [] : group.rows };
|
|
654
|
+
};
|
|
655
|
+
const blocks = grouping ? groups.map(blockOf) : [{ key: `${tab}:flat`, group: null, rows }];
|
|
656
|
+
|
|
657
|
+
const tabs = [
|
|
658
|
+
{ id: "archived", label: t("titleArchived"), count: archived.length },
|
|
659
|
+
{ id: "pinned", label: t("titlePinned"), count: pinned.length },
|
|
660
|
+
{ id: "trash", label: t("titleTrash"), count: trashed.length }
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 一行的「⋯」菜单内容。每个页签的动作本来就不同,但**控件形状统一**:
|
|
665
|
+
* 都是一行会话 + 一个 ⋯。
|
|
666
|
+
*
|
|
667
|
+
* 注意已归档那一组的第三项:以前是"永久删除",可它走的是 `purge`,而 `purge`
|
|
668
|
+
* 只认回收站里的目录 —— 归档会话不在回收站,所以那个按钮**从来只会报
|
|
669
|
+
* "回收站里没有这个会话"**。现在改成真正可用的"移入回收站"(`trash`:先取消归档
|
|
670
|
+
* 再搬走,随时能恢复)。
|
|
671
|
+
*/
|
|
672
|
+
const rowMenu = (row) => {
|
|
673
|
+
const moveToTrash = {
|
|
674
|
+
id: "trash",
|
|
675
|
+
label: t("moveToTrash"),
|
|
676
|
+
icon: h(IconTrashOutlineRegular, { size: 14 }),
|
|
677
|
+
danger: true
|
|
678
|
+
};
|
|
679
|
+
if (tab === "archived") {
|
|
680
|
+
return {
|
|
681
|
+
items: [
|
|
682
|
+
{ id: "unarchive", label: t("unarchive"), icon: h(IconUnarchiveOutlineRegular, { size: 14 }) },
|
|
683
|
+
{ id: "pin", label: t("pin"), icon: h(IconPinOutlineRegular, { size: 14 }) },
|
|
684
|
+
moveToTrash
|
|
685
|
+
],
|
|
686
|
+
run: (id) => {
|
|
687
|
+
setMenuFor(null);
|
|
688
|
+
if (id === "trash") requestTrash([row.sessionId], row.title);
|
|
689
|
+
else void run(id, [row.sessionId]);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (tab === "pinned") {
|
|
694
|
+
return {
|
|
695
|
+
items: [
|
|
696
|
+
{ id: "unpin", label: t("unpin"), icon: h(IconPinOutlineRegular, { size: 14 }) },
|
|
697
|
+
moveToTrash
|
|
698
|
+
],
|
|
699
|
+
run: (id) => {
|
|
700
|
+
setMenuFor(null);
|
|
701
|
+
if (id === "trash") requestTrash([row.sessionId], row.title);
|
|
702
|
+
else void run(id, [row.sessionId]);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
items: [
|
|
708
|
+
{ id: "restore", label: t("restore"), icon: h(IconRefreshOutlineRegular, { size: 14 }) },
|
|
709
|
+
{ id: "purge", label: t("purge"), icon: h(IconTrashOutlineRegular, { size: 14 }), danger: true }
|
|
710
|
+
],
|
|
711
|
+
run: (id) => {
|
|
712
|
+
setMenuFor(null);
|
|
713
|
+
if (id === "purge") requestPurge([row.sessionId], row.title);
|
|
714
|
+
else void run(id, [row.sessionId]);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const batch = [];
|
|
720
|
+
if (tab === "archived" && selected.length > 0) {
|
|
721
|
+
batch.push({ label: t("unarchive"), run: () => void run("unarchive", selected) });
|
|
722
|
+
batch.push({ label: t("moveToTrash"), variant: "outline", run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "") });
|
|
723
|
+
}
|
|
724
|
+
if (tab === "pinned" && selected.length > 0) {
|
|
725
|
+
batch.push({ label: t("unpin"), run: () => void run("unpin", selected) });
|
|
726
|
+
batch.push({ label: t("moveToTrash"), variant: "outline", run: () => requestTrash(selected, selectedRows[0] ? selectedRows[0].title : "") });
|
|
727
|
+
}
|
|
728
|
+
if (tab === "trash" && selected.length > 0) {
|
|
729
|
+
batch.push({ label: t("restore"), run: () => void run("restore", selected) });
|
|
730
|
+
batch.push({ label: t("purge"), variant: "outline", run: () => requestPurge(selected, selectedRows[0] ? selectedRows[0].title : "") });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const totals = snapshot ? snapshot.totals : null;
|
|
734
|
+
const totalBytes = tab === "archived"
|
|
735
|
+
? (totals ? totals.archivedBytes : 0)
|
|
736
|
+
: tab === "trash"
|
|
737
|
+
? (totals ? totals.trashBytes : 0)
|
|
738
|
+
: 0;
|
|
739
|
+
|
|
740
|
+
// 卡片里的显示节点:分组标题 + 行。第一个节点不要上边框(卡片自己有)。
|
|
741
|
+
const nodes = [];
|
|
742
|
+
let first = true;
|
|
743
|
+
for (const block of blocks) {
|
|
744
|
+
if (block.group !== null) {
|
|
745
|
+
const group = block.group;
|
|
746
|
+
const isCollapsed = collapsed.includes(block.key);
|
|
747
|
+
nodes.push(h(GroupHeader, {
|
|
748
|
+
key: `group:${block.key}`,
|
|
749
|
+
group,
|
|
750
|
+
t,
|
|
751
|
+
first,
|
|
752
|
+
busy,
|
|
753
|
+
selected,
|
|
754
|
+
collapsed: isCollapsed,
|
|
755
|
+
onToggleGroup: () => toggleGroup(group),
|
|
756
|
+
onToggleCollapse: () => setCollapsed((current) => current.includes(block.key)
|
|
757
|
+
? current.filter((item) => item !== block.key)
|
|
758
|
+
: current.concat([block.key]))
|
|
759
|
+
}));
|
|
760
|
+
first = false;
|
|
761
|
+
}
|
|
762
|
+
for (const row of block.rows) {
|
|
763
|
+
nodes.push(h(SessionRow, {
|
|
764
|
+
key: row.sessionId,
|
|
765
|
+
row,
|
|
766
|
+
t,
|
|
767
|
+
first,
|
|
768
|
+
busy,
|
|
769
|
+
showCwd: block.group === null,
|
|
770
|
+
showDeletedAt: tab === "trash",
|
|
771
|
+
checked: selected.includes(row.sessionId),
|
|
772
|
+
onToggle: () => toggle(row.sessionId),
|
|
773
|
+
menu: rowMenu(row),
|
|
774
|
+
menuOpen: menuFor === row.sessionId,
|
|
775
|
+
onToggleMenu: () => setMenuFor(menuFor === row.sessionId ? null : row.sessionId),
|
|
776
|
+
onCloseMenu: () => setMenuFor(null)
|
|
777
|
+
}));
|
|
778
|
+
first = false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return h(
|
|
783
|
+
"div",
|
|
784
|
+
{ style: S.column },
|
|
785
|
+
h(
|
|
786
|
+
"div",
|
|
787
|
+
{ style: S.rowLine },
|
|
788
|
+
h(
|
|
789
|
+
"div",
|
|
790
|
+
{ style: S.tabBar },
|
|
791
|
+
tabs.map((entry) => h(Button, {
|
|
792
|
+
key: entry.id,
|
|
793
|
+
size: "sm",
|
|
794
|
+
variant: tab === entry.id ? "primary" : "ghost",
|
|
795
|
+
onClick: () => setTab(entry.id)
|
|
796
|
+
}, `${entry.label}${entry.count > 0 ? ` (${entry.count})` : ""}`))
|
|
797
|
+
),
|
|
798
|
+
h(Button, {
|
|
799
|
+
size: "sm",
|
|
800
|
+
variant: "ghost",
|
|
801
|
+
title: t("groupHint"),
|
|
802
|
+
onClick: () => groupingStore.set(!grouping)
|
|
803
|
+
}, grouping ? t("groupingOn") : t("groupingOff")),
|
|
804
|
+
h(Button, { size: "sm", variant: "ghost", disabled: loading || busy, onClick: () => void refresh() }, t("refresh"))
|
|
805
|
+
),
|
|
806
|
+
|
|
807
|
+
error ? h("div", { style: S.error }, error) : null,
|
|
808
|
+
|
|
809
|
+
h(
|
|
810
|
+
"div",
|
|
811
|
+
{ style: S.card },
|
|
812
|
+
loading && !snapshot
|
|
813
|
+
? h("div", { style: S.empty }, t("loading"))
|
|
814
|
+
: rows.length === 0
|
|
815
|
+
? h("div", { style: S.empty }, tab === "archived" ? t("emptyArchived") : tab === "pinned" ? t("emptyPinned") : t("emptyTrash"))
|
|
816
|
+
: h("div", { style: S.list }, nodes)
|
|
817
|
+
),
|
|
818
|
+
|
|
819
|
+
h(
|
|
820
|
+
"div",
|
|
821
|
+
{ style: S.rowLine },
|
|
822
|
+
h(Button, {
|
|
823
|
+
size: "sm",
|
|
824
|
+
variant: "ghost",
|
|
825
|
+
disabled: busy || rows.length === 0,
|
|
826
|
+
onClick: () => setSelected(selected.length === rows.length ? [] : rows.map((row) => row.sessionId))
|
|
827
|
+
}, selected.length === rows.length && rows.length > 0 ? t("selectNone") : t("selectAll")),
|
|
828
|
+
batch.map((action) => h(Button, {
|
|
829
|
+
key: action.label,
|
|
830
|
+
size: "sm",
|
|
831
|
+
variant: action.variant || "ghost",
|
|
832
|
+
disabled: busy,
|
|
833
|
+
onClick: action.run
|
|
834
|
+
}, action.label)),
|
|
835
|
+
tab === "trash" && trashed.length > 0
|
|
836
|
+
? h(Button, {
|
|
837
|
+
size: "sm",
|
|
838
|
+
variant: "outline",
|
|
839
|
+
disabled: busy,
|
|
840
|
+
onClick: () => requestPurge(trashed.map((row) => row.sessionId), "")
|
|
841
|
+
}, t("emptyTrashAll"))
|
|
842
|
+
: null,
|
|
843
|
+
busy ? h("span", { style: S.footer }, t("working")) : null
|
|
844
|
+
),
|
|
845
|
+
|
|
846
|
+
h(
|
|
847
|
+
"div",
|
|
848
|
+
{ style: S.footer },
|
|
849
|
+
h("span", null, text(t, "total", { n: rows.length, size: formatBytes(totalBytes) })),
|
|
850
|
+
grouping && groups.length > 0 ? h("span", null, text(t, "workspaces", { n: groups.length })) : null,
|
|
851
|
+
selected.length > 0 ? h("span", null, text(t, "selected", { n: selected.length })) : null,
|
|
852
|
+
snapshot ? h("span", { style: S.mono, title: snapshot.trashDir }, snapshot.trashDir) : null
|
|
853
|
+
)
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ── 全局确认弹窗宿主(挂在 shell.overlay 上) ────────────────────────
|
|
858
|
+
function DialogHost(props) {
|
|
859
|
+
const t = props.t;
|
|
860
|
+
const request = useStore(dialogStore);
|
|
861
|
+
const [busy, setBusy] = React.useState(false);
|
|
862
|
+
const [ack, setAck] = React.useState(false);
|
|
863
|
+
const [error, setError] = React.useState(null);
|
|
864
|
+
|
|
865
|
+
// 每次新请求都重置勾选与错误(hooks 先于条件渲染,保持调用顺序稳定)。
|
|
866
|
+
const requestKey = request === null ? "" : `${request.kind}:${request.ids.join(",")}:${request.title}`;
|
|
867
|
+
React.useEffect(() => {
|
|
868
|
+
setAck(false);
|
|
869
|
+
setError(null);
|
|
870
|
+
}, [requestKey]);
|
|
871
|
+
|
|
872
|
+
if (request === null) return null;
|
|
873
|
+
|
|
874
|
+
const purge = request.kind === "purge";
|
|
875
|
+
const many = request.ids.length > 1;
|
|
876
|
+
const title = purge ? t("confirmPurgeTitle") : t("confirmTrashTitle");
|
|
877
|
+
const body = purge
|
|
878
|
+
? (many ? text(t, "confirmPurgeManyBody", { n: request.ids.length }) : text(t, "confirmPurgeBody", { title: request.title }))
|
|
879
|
+
: text(t, "confirmTrashBody", { title: request.title });
|
|
880
|
+
|
|
881
|
+
const close = () => {
|
|
882
|
+
if (busy) return;
|
|
883
|
+
dialogStore.set(null);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const confirm = async () => {
|
|
887
|
+
setBusy(true);
|
|
888
|
+
try {
|
|
889
|
+
const value = await call(purge ? "purge" : "trash", { ids: request.ids });
|
|
890
|
+
const failure = firstFailure(value.results);
|
|
891
|
+
if (failure === null) {
|
|
892
|
+
dialogStore.set(null);
|
|
893
|
+
mutationStore.set(mutationStore.get() + 1);
|
|
894
|
+
} else {
|
|
895
|
+
setError(failure);
|
|
896
|
+
}
|
|
897
|
+
} catch (thrown) {
|
|
898
|
+
setError(reportError(thrown));
|
|
899
|
+
} finally {
|
|
900
|
+
setBusy(false);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
return h(
|
|
905
|
+
Modal,
|
|
906
|
+
{
|
|
907
|
+
open: true,
|
|
908
|
+
onClose: close,
|
|
909
|
+
title,
|
|
910
|
+
closeLabel: t("close"),
|
|
911
|
+
description: body,
|
|
912
|
+
footer: h(
|
|
913
|
+
"div",
|
|
914
|
+
{ style: S.rowLine },
|
|
915
|
+
h(Button, { size: "md", variant: "ghost", disabled: busy, onClick: close }, t("cancel")),
|
|
916
|
+
h(Button, {
|
|
917
|
+
size: "md",
|
|
918
|
+
variant: "primary",
|
|
919
|
+
disabled: busy || (purge && !ack),
|
|
920
|
+
onClick: () => void confirm()
|
|
921
|
+
}, purge ? t("confirmPurgeAction") : t("confirmTrashAction"))
|
|
922
|
+
)
|
|
923
|
+
},
|
|
924
|
+
purge
|
|
925
|
+
? h(
|
|
926
|
+
"div",
|
|
927
|
+
{ style: S.column },
|
|
928
|
+
h(
|
|
929
|
+
"label",
|
|
930
|
+
{ style: S.ack },
|
|
931
|
+
h("input", {
|
|
932
|
+
type: "checkbox",
|
|
933
|
+
checked: ack,
|
|
934
|
+
disabled: busy,
|
|
935
|
+
onChange: (event) => setAck(Boolean(event.target.checked))
|
|
936
|
+
}),
|
|
937
|
+
t("ackPurge")
|
|
938
|
+
),
|
|
939
|
+
error ? h("div", { style: S.error }, error) : null
|
|
940
|
+
)
|
|
941
|
+
: (error ? h("div", { style: S.error }, error) : null)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── 插件装配 ────────────────────────────────────────────────────────
|
|
946
|
+
const inject = ["slots", "locale"];
|
|
947
|
+
|
|
948
|
+
function apply(ctx) {
|
|
949
|
+
ctxRef = ctx;
|
|
950
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-session-curator: dictionaries");
|
|
951
|
+
const t = ctx.locale.bind(NS);
|
|
952
|
+
|
|
953
|
+
// 逐个贡献:某个槽位声明缺失时只丢这一个贡献,不连累其余的。
|
|
954
|
+
const contribute = (slot, options, component, label) => {
|
|
955
|
+
try {
|
|
956
|
+
ctx.slots.inject(slot, () => ctx.slots.register(options, component));
|
|
957
|
+
} catch (error) {
|
|
958
|
+
reportError(error);
|
|
959
|
+
if (ctx.logger) ctx.logger.warn(`[session-curator] slot ${slot} 注册失败(${label})`);
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// 1) 会话行「⋯」菜单里的删除项,排在官方 archive(400)之后。
|
|
964
|
+
contribute("sidebar.workspaces.session.menu.item", {
|
|
965
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
966
|
+
id: MENU_ID,
|
|
967
|
+
order: 500,
|
|
968
|
+
label: () => t("menuDelete"),
|
|
969
|
+
locale: NS
|
|
970
|
+
}, ({ sessionId, displayTitle, useMenuOpenState }) => {
|
|
971
|
+
const [, setMenuOpen] = useMenuOpenState();
|
|
972
|
+
return h(MenuItemButton, {
|
|
973
|
+
danger: true,
|
|
974
|
+
separatorBefore: true,
|
|
975
|
+
onSelect: () => {
|
|
976
|
+
setMenuOpen(false);
|
|
977
|
+
requestTrash([sessionId], displayTitle || sessionId);
|
|
978
|
+
}
|
|
979
|
+
}, t("menuDelete"));
|
|
980
|
+
}, "menu item");
|
|
981
|
+
|
|
982
|
+
// 2) 全局确认弹窗宿主。
|
|
983
|
+
contribute("shell.overlay", {
|
|
984
|
+
name: "shell.overlay",
|
|
985
|
+
id: OVERLAY_ID,
|
|
986
|
+
order: 60,
|
|
987
|
+
locale: NS
|
|
988
|
+
}, DialogHost, "dialog host");
|
|
989
|
+
|
|
990
|
+
// 3) 设置页:已归档 / 已置顶 / 回收站(id 见 sectionIdFor:为了导航栏的归档图标)。
|
|
991
|
+
contribute("settings.section", {
|
|
992
|
+
name: "settings.section",
|
|
993
|
+
id: sectionIdFor(ctx),
|
|
994
|
+
order: 45,
|
|
995
|
+
label: () => t("sectionLabel"),
|
|
996
|
+
locale: NS
|
|
997
|
+
}, SessionManagerPanel, "settings section");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
bundleModule.exports.NS = NS;
|
|
1001
|
+
bundleModule.exports.apply = apply;
|
|
1002
|
+
bundleModule.exports.inject = inject;
|
|
1003
|
+
// 纯函数 / 纯组件,给 scripts/client.mjs 直接单测(浏览器里用不到这几个导出)。
|
|
1004
|
+
bundleModule.exports.groupRows = groupRows;
|
|
1005
|
+
bundleModule.exports.groupKeyOf = groupKeyOf;
|
|
1006
|
+
bundleModule.exports.SessionRow = SessionRow;
|
|
1007
|
+
bundleModule.exports.GroupHeader = GroupHeader;
|
|
1008
|
+
bundleModule.exports.sectionIdFor = sectionIdFor;
|
|
1009
|
+
return bundleModule.exports;
|
|
1010
|
+
}
|
|
1011
|
+
});
|