@duke-dsh-plugins/dsh-archive-manager 1.3.5 → 1.4.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.
Files changed (5) hide show
  1. package/README.md +19 -1
  2. package/client.js +928 -530
  3. package/index.js +276 -276
  4. package/package.json +4 -3
  5. package/typert.host.js +278 -224
package/client.js CHANGED
@@ -1,530 +1,928 @@
1
- /**
2
- * dsh-archive-manager — Client half (web bundle).
3
- *
4
- * Rendered by the DSH web shell via `window.__ModuleLoader__.load`. Registers:
5
- * - a sidebar footer action button (archived-session entry),
6
- * - a frame-wide archive manager panel (search / group / restore / delete),
7
- * rendered together with the button from the sidebar.footer.action slot —
8
- * mirroring how ui-settings renders its modal from sidebar.settings (see
9
- * the registration comment for the stacking-context rationale).
10
- *
11
- * The panel mirrors the DSH 工作区 (workspace) browsing region: a dialog
12
- * surface using the same design tokens as the workspace browser — folder
13
- * group headers, compact 32px session rows with a leading slot icon and
14
- * relative time, and hover-revealed actions (workspace rowActions pattern).
15
- *
16
- * Host communication goes through the `archiveManager` Remote namespace
17
- * (`ctx.remote.archiveManager.restore/delete/state`), published by the Host
18
- * half in `index.js`.
19
- */
20
- window.__ModuleLoader__.load({
21
- id: "@duke-dsh-plugins/dsh-archive-manager",
22
- factory: (require) => {
23
- var module = { exports: {} };
24
- var exports = module.exports;
25
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
26
- const React = require("react");
27
-
28
- // ---- CSS (package-owned, mirrors the DSH workspace browser + Modal) ----
29
- const CSS = `
30
- [class*="footerActions"]{flex-direction:column;align-items:center;}
31
-
32
- /* Overlay — same mask surface as DSH Modal, same z-index tier as the DSH
33
- settings overlay (1000). This layer MUST be rendered from a slot that sits
34
- outside any stacking context (the sidebar foot, like Settings does), never
35
- from the shell.overlay slot: AppFrame renders that slot inside its
36
- .overlayLayer (position:absolute;z-index:20, a stacking context), which
37
- traps any child z-index below it — dsh-better-sidebar appends a
38
- position:fixed;z-index:25 layer to document.body and would cover the
39
- modal. */
40
- .am-overlay{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.4));backdrop-filter:var(--dsw-mask-blur,blur(2px));display:flex;align-items:center;justify-content:center;padding:24px;}
41
-
42
- /* Panel — same surface & size as the DSH Settings dialog (width 800px) */
43
- .am-panel{position:relative;display:flex;flex-direction:column;width:800px;max-width:calc(100vw - 48px);height:min(800px,100vh - 48px);overflow:hidden;border:1px solid var(--dsw-alias-border-inverted,rgba(128,128,128,.3));border-radius:24px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,inherit);box-shadow:var(--dsw-shadow-lv3,0 12px 40px rgba(0,0,0,.25));}
44
-
45
- /* Header — DSH Modal header */
46
- .am-header{display:flex;align-items:center;gap:10px;padding:18px 14px 10px 24px;}
47
- .am-title{font-weight:500;font-size:16px;line-height:24px;flex:1;color:var(--dsw-alias-label-primary,inherit);}
48
- .am-count{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary,inherit);flex:none;}
49
- .am-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary,inherit);padding:0;}
50
- .am-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));color:var(--dsw-alias-label-primary,inherit);}
51
-
52
- /* Search — workspace expanded search input */
53
- .am-search{padding:0 24px 12px;}
54
- .am-search input{box-sizing:border-box;width:100%;height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:18px;outline:none;}
55
- .am-search input::placeholder{color:var(--dsw-alias-label-tertiary,inherit);}
56
- .am-search input:focus{border-color:var(--dsw-alias-brand-primary,#4a7dff);}
57
-
58
- /* Body — workspace list area */
59
- .am-body{flex:1;overflow-y:auto;padding:4px 8px 16px;overscroll-behavior:contain;}
60
-
61
- /* Group sections, spaced like workspace group sections */
62
- .am-group{margin-top:2px;}
63
- .am-group+.am-group{margin-top:4px;}
64
-
65
- /* Group head — workspace project row (folder + label + count), clickable + collapsible */
66
- .am-group-head{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:34px;width:100%;padding:0 8px;border-radius:8px;border:none;background:transparent;color:inherit;font-family:inherit;font-size:inherit;text-align:left;cursor:pointer;}
67
- .am-group-head:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08));}
68
- .am-chevron{flex:none;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);transition:transform .15s var(--ds-ease-in-out);}
69
- .am-chevron.am-open{transform:rotate(90deg);}
70
- .am-group-icon{flex:none;width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);}
71
- .am-group-label{flex:1;min-width:0;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
72
- .am-group-count{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary,inherit);}
73
-
74
- /* Session row — workspace session row (32px, radius 8, hover bg), indented under its group */
75
- .am-row{display:flex;align-items:center;gap:6px;height:32px;padding:0 8px 0 32px;border-radius:8px;color:var(--dsw-alias-label-primary,inherit);}
76
- .am-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08));}
77
- .am-slot{flex:none;width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);}
78
- .am-title{flex:1;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
79
- .am-time{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary,inherit);}
80
- .am-row-actions{flex:none;display:none;align-items:center;gap:2px;}
81
- .am-row:hover .am-time{display:none;}
82
- .am-row:hover .am-row-actions{display:inline-flex;}
83
-
84
- /* Action buttons — subtle text buttons, hover-revealed like workspace rowActions */
85
- .am-btn{border:none;background:transparent;font-size:12px;line-height:18px;padding:4px 8px;border-radius:8px;cursor:pointer;white-space:nowrap;}
86
- .am-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));}
87
- .am-btn-restore{color:var(--dsw-alias-brand-primary,#4a7dff);}
88
- .am-btn-danger{color:var(--dsw-alias-state-error-primary,#e5484d);}
89
- .am-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.1));}
90
- .am-btn[disabled]{opacity:.5;cursor:default;}
91
-
92
- /* Orphan rows (log gone) */
93
- .am-orphan .am-slot{color:var(--dsw-alias-state-warn-primary,#d99a06);}
94
- .am-orphan .am-title{color:var(--dsw-alias-label-secondary,inherit);}
95
-
96
- /* Empty / error / ghost — workspace empty style */
97
- .am-empty{color:var(--dsw-alias-label-tertiary,inherit);padding:16px 12px;font-size:13px;line-height:20px;text-align:center;}
98
- .am-error{color:var(--dsw-alias-state-error-primary,#e5484d);padding:8px 24px;font-size:12px;line-height:18px;}
99
- .am-ghost{color:var(--dsw-alias-label-secondary,inherit);padding:8px 24px;font-size:12px;line-height:18px;}
100
-
101
- /* Sidebar footer entry (unchanged) */
102
- .am-foot-layer{flex:none;width:100%;margin:8px 0 0;display:flex;align-items:center;}
103
- .am-foot-layer .am-foot{width:100%;height:49px;border-radius:12px;padding:0 8px 0 6px;display:inline-flex;align-items:center;gap:8px;background:transparent;border:none;color:var(--dsw-alias-label-primary,inherit);font-family:inherit;font-size:14px;cursor:pointer;overflow:hidden;}
104
- .am-foot-layer .am-foot:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.1));}
105
- .am-foot-layer .am-foot.am-active{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14));}
106
- .am-foot-layer .am-foot .am-foot-icon{flex:none;display:inline-flex;align-items:center;justify-content:center;}
107
- .am-foot-layer .am-foot .am-foot-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden;}
108
- .am-foot-layer .am-foot .am-badge{color:var(--dsw-alias-label-tertiary,inherit);font-variant-numeric:tabular-nums;flex:none;margin-left:auto;font-size:12px;line-height:16px;}
109
- .am-foot-layer.am-foot-rail{width:36px;height:36px;margin:0;}
110
- .am-foot-layer.am-foot-rail .am-foot{width:36px;height:36px;padding:0;justify-content:center;border-radius:10px;}
111
- `;
112
-
113
- // ---- Client Remote contribution ----------------------------------------
114
- // The browser-side `remote.archiveManager` service only exists after this
115
- // module mounts its namespace via ctx.remote.$mount(): dsh-api-remotes'
116
- // client assembly mounts only the five official namespaces, so a plugin
117
- // must mount its own. Mirrors the invocations in typert.host.js (ids,
118
- // service/namespace/method, wire fields). zod is not requirable in the
119
- // browser module loader, so codecs use passthrough schemas — the runtime
120
- // contract only requires typeSymbol + schema.parse().
121
- const passthrough = () => ({ parse: (v) => v });
122
- const CLIENT_REMOTE = {
123
- package: "dsh-archive-manager",
124
- descriptors: [
125
- {
126
- id: "dsh-archive-manager#archiveManager/restore",
127
- service: "archiveManager",
128
- namespace: "archiveManager",
129
- method: "restore",
130
- invocation: { kind: "direct" },
131
- parameters: [
132
- {
133
- name: "request",
134
- wire: "request",
135
- source: "json",
136
- codec: { mode: "strict", typeSymbol: "dsh-archive-manager#ArchiveManagerRestoreRequest", schema: passthrough() },
137
- },
138
- ],
139
- result: { mode: "strict", typeSymbol: "dsh-archive-manager#ArchiveManagerRestoreResult", schema: passthrough() },
140
- },
141
- {
142
- id: "dsh-archive-manager#archiveManager/delete",
143
- service: "archiveManager",
144
- namespace: "archiveManager",
145
- method: "delete",
146
- invocation: { kind: "direct" },
147
- parameters: [
148
- {
149
- name: "request",
150
- wire: "request",
151
- source: "json",
152
- codec: { mode: "strict", typeSymbol: "dsh-archive-manager#ArchiveManagerDeleteRequest", schema: passthrough() },
153
- },
154
- ],
155
- result: { mode: "strict", typeSymbol: "dsh-archive-manager#ArchiveManagerDeleteResult", schema: passthrough() },
156
- },
157
- {
158
- id: "dsh-archive-manager#archiveManager/state",
159
- service: "archiveManager",
160
- namespace: "archiveManager",
161
- method: "state",
162
- invocation: { kind: "direct" },
163
- parameters: [],
164
- result: { mode: "strict", typeSymbol: "dsh-archive-manager#ArchiveManagerStateResult", schema: passthrough() },
165
- },
166
- ],
167
- };
168
-
169
- async function apply(ctx) {
170
- // Mount the archiveManager namespace before anything touches it; the
171
- // mount's lifetime is bound to this plugin's context by $mount itself.
172
- await ctx.remote.$mount(CLIENT_REMOTE);
173
-
174
- const styleTag = document.createElement("style");
175
- styleTag.textContent = CSS;
176
- document.head.appendChild(styleTag);
177
- ctx.effect(() => () => styleTag.remove());
178
-
179
- // ctx.get() reads the service without the property-accessor inject
180
- // guard; it exists because the $mount above just created it.
181
- const remote = ctx.get("remote.archiveManager");
182
-
183
- // Shared open state between the footer button and the overlay.
184
- let open = false;
185
- const listeners = new Set();
186
- function getOpen() { return open; }
187
- function setOpen(v) { if (open !== v) { open = v; for (const fn of listeners) fn(); } }
188
- function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
189
- function useOpen() { return React.useSyncExternalStore(subscribe, getOpen); }
190
-
191
- // Ghost state (deleted-but-resident archived sessions), shared by both components.
192
- const ghostStore = (() => {
193
- let ids = [];
194
- const listeners = new Set();
195
- return {
196
- getSnapshot: () => ids,
197
- subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
198
- set: (next) => { ids = next; for (const fn of listeners) fn(); },
199
- };
200
- })();
201
- function useGhostIds() { return React.useSyncExternalStore(ghostStore.subscribe, ghostStore.getSnapshot); }
202
- async function refreshState() {
203
- try {
204
- const res = await remote.state();
205
- ghostStore.set(res && res.ok && Array.isArray(res.value.ghostIds) ? res.value.ghostIds : []);
206
- } catch {
207
- ghostStore.set([]);
208
- }
209
- }
210
-
211
- function ArchiveIcon() {
212
- return React.createElement(
213
- "svg",
214
- { width: 16, height: 16, viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: 1.1, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
215
- React.createElement("rect", { x: 1.5, y: 2, width: 11, height: 3.5, rx: 0.8 }),
216
- React.createElement("path", { d: "M2.5 5.5v5a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-5" }),
217
- React.createElement("path", { d: "M5.5 8h3" })
218
- );
219
- }
220
-
221
- function FolderIcon() {
222
- return React.createElement(
223
- "svg",
224
- { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
225
- React.createElement("path", { d: "M1.5 4.5a1 1 0 0 1 1-1h3l1.5 2h6.5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-7Z" })
226
- );
227
- }
228
-
229
- function WarningIcon() {
230
- return React.createElement(
231
- "svg",
232
- { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
233
- React.createElement("path", { d: "M8 2.5 14.5 13.5h-13L8 2.5Z" }),
234
- React.createElement("path", { d: "M8 6.5v3" }),
235
- React.createElement("circle", { cx: 8, cy: 11.5, r: 0.4, fill: "currentColor" })
236
- );
237
- }
238
-
239
- function ChevronIcon() {
240
- return React.createElement(
241
- "svg",
242
- { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
243
- React.createElement("path", { d: "m5 3.5 3.5 3.5L5 10.5" })
244
- );
245
- }
246
-
247
- function fmtTime(ts) {
248
- if (typeof ts !== "number" || !isFinite(ts) || ts <= 0) return "";
249
- const diff = Date.now() - ts;
250
- const min = 60 * 1000, hour = 60 * min, day = 24 * hour, month = 30 * day;
251
- if (diff < min) return "刚刚";
252
- if (diff < hour) return Math.floor(diff / min) + " 分钟前";
253
- if (diff < day) return Math.floor(diff / hour) + " 小时前";
254
- if (diff < month) return Math.floor(diff / day) + " 天前";
255
- const d = new Date(ts);
256
- return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
257
- }
258
-
259
- function deriveGroups(byId, ids, workspaces, archivedIds, query) {
260
- const q = (query || "").trim().toLowerCase();
261
- const archived = new Set(archivedIds);
262
- const groups = [];
263
- const accounted = new Set();
264
- const resolved = new Set();
265
- for (const ws of workspaces) {
266
- const members = [];
267
- for (const sid of ws.sessionIds) {
268
- if (!archived.has(sid)) continue;
269
- resolved.add(sid);
270
- const s = byId[sid];
271
- if (s === undefined) continue;
272
- accounted.add(sid);
273
- if (s.blank) continue;
274
- const label = s.displayTitle || s.title || "未命名会话";
275
- if (q && label.toLowerCase().indexOf(q) === -1 && ws.title.toLowerCase().indexOf(q) === -1 && String(s.cwd || "").toLowerCase().indexOf(q) === -1) continue;
276
- members.push(s);
277
- }
278
- if (members.length === 0) continue;
279
- members.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1));
280
- groups.push({ key: ws.workspaceId, label: ws.title, sessions: members });
281
- }
282
- const stray = [];
283
- for (const sid of ids) {
284
- if (accounted.has(sid)) continue;
285
- if (!archived.has(sid)) continue;
286
- resolved.add(sid);
287
- const s = byId[sid];
288
- if (s === undefined) continue;
289
- if (s.blank) continue;
290
- const label = s.displayTitle || s.title || "未命名会话";
291
- if (q && label.toLowerCase().indexOf(q) === -1 && String(s.cwd || "").toLowerCase().indexOf(q) === -1) continue;
292
- stray.push(s);
293
- }
294
- if (stray.length > 0) {
295
- stray.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1));
296
- groups.push({ key: "__ungrouped__", label: "未分组", sessions: stray });
297
- }
298
- const orphans = [];
299
- for (const sid of archivedIds) {
300
- if (resolved.has(sid)) continue;
301
- const s = byId[sid];
302
- if (s !== undefined && !s.blank) continue;
303
- if (q) {
304
- const sidLower = String(sid).toLowerCase();
305
- if (sidLower.indexOf(q) === -1) continue;
306
- }
307
- orphans.push({ id: sid, orphan: true, displayTitle: "未知会话(日志已不存在)", updatedAt: 0, cwd: undefined });
308
- }
309
- return { groups, orphans };
310
- }
311
-
312
- function ArchiveManager({ slotProps, onClose }) {
313
- const [query, setQuery] = React.useState("");
314
- const [busy, setBusy] = React.useState(null);
315
- const [confirming, setConfirming] = React.useState(null);
316
- const [error, setError] = React.useState(null);
317
- // Groups are collapsed by default so a large archive stays manageable;
318
- // a non-empty search query force-expands every group to show matches.
319
- const [expanded, setExpanded] = React.useState(() => new Set());
320
- const ghostIds = useGhostIds();
321
- const rawArchivedIds = slotProps.useWorkspaces((s) => s.archivedSessionIds);
322
- const byId = slotProps.useSessions((s) => s.byId);
323
- const ids = slotProps.useSessions((s) => s.ids);
324
- const workspaces = slotProps.useWorkspaces((s) => s.items);
325
- React.useEffect(() => { refreshState(); }, []);
326
- const archivedIds = React.useMemo(() => {
327
- if (ghostIds.length === 0) return rawArchivedIds;
328
- const ghost = new Set(ghostIds);
329
- return rawArchivedIds.filter((id) => !ghost.has(id));
330
- }, [rawArchivedIds, ghostIds]);
331
- const derived = React.useMemo(() => {
332
- try {
333
- const d = deriveGroups(byId, ids, workspaces, archivedIds, query);
334
- return { groups: d.groups, orphans: d.orphans, error: null };
335
- } catch (e) {
336
- return { groups: [], orphans: [], error: e instanceof Error ? e.message : String(e) };
337
- }
338
- }, [byId, ids, workspaces, archivedIds, query]);
339
- const runAction = async (action, sessionId) => {
340
- setBusy(action + ":" + sessionId);
341
- setError(null);
342
- try {
343
- const res = await remote[action]({ sessionId });
344
- if (res && res.ok) {
345
- if (action === "delete") {
346
- try {
347
- const sessions = ctx.get("sessions");
348
- if (sessions && typeof sessions.refresh === "function") sessions.refresh();
349
- } catch { /* non-fatal */ }
350
- refreshState();
351
- }
352
- } else {
353
- const err = res && res.error;
354
- setError((err && err.message) || (err && err.code) || "操作失败");
355
- }
356
- } catch (e) {
357
- setError(e instanceof Error ? e.message : String(e));
358
- } finally {
359
- setBusy(null);
360
- }
361
- };
362
- const total = archivedIds.length;
363
- const groups = derived.groups.slice();
364
- if (derived.orphans.length > 0) {
365
- groups.push({ key: "__orphan__", label: "无效的归档记录", orphanGroup: true, sessions: derived.orphans });
366
- }
367
- const searching = query.trim() !== "";
368
- const isExpanded = (key) => searching || expanded.has(key);
369
- const toggleGroup = (key) => {
370
- setExpanded((prev) => {
371
- const next = new Set(prev);
372
- if (next.has(key)) next.delete(key); else next.add(key);
373
- return next;
374
- });
375
- };
376
- const empty = total === 0 && ghostIds.length === 0;
377
- const allGhost = total === 0 && ghostIds.length > 0;
378
- const header = React.createElement(
379
- "div", { className: "am-header" },
380
- React.createElement("span", { className: "am-title" }, "归档管理"),
381
- React.createElement("span", { className: "am-count" }, total + " 个归档"),
382
- typeof onClose === "function"
383
- ? React.createElement("button", { className: "am-close", onClick: onClose, title: "关闭", "aria-label": "关闭" }, "✕")
384
- : null
385
- );
386
- const search = React.createElement(
387
- "div", { className: "am-search" },
388
- React.createElement("input", {
389
- type: "text",
390
- placeholder: "搜索归档标题或项目…",
391
- value: query,
392
- onChange: (e) => { setQuery(e.target.value); setConfirming(null); },
393
- })
394
- );
395
- const body = React.createElement(
396
- "div", { className: "am-body" },
397
- derived.error
398
- ? React.createElement("div", { className: "am-error" }, "渲染出错:" + derived.error)
399
- : empty
400
- ? React.createElement("div", { className: "am-empty" }, "暂无归档会话")
401
- : allGhost
402
- ? React.createElement("div", { className: "am-empty" }, "归档均已删除,重启 DSH 后自动清除")
403
- : groups.map((g) =>
404
- React.createElement(
405
- "div", { className: "am-group", key: g.key },
406
- React.createElement(
407
- "button", {
408
- type: "button",
409
- className: "am-group-head",
410
- onClick: () => toggleGroup(g.key),
411
- "aria-expanded": isExpanded(g.key),
412
- },
413
- React.createElement(
414
- "span", { className: "am-chevron" + (isExpanded(g.key) ? " am-open" : ""), "aria-hidden": true },
415
- React.createElement(ChevronIcon)
416
- ),
417
- React.createElement(
418
- "span", { className: "am-group-icon", "aria-hidden": true },
419
- g.orphanGroup ? React.createElement(WarningIcon) : React.createElement(FolderIcon)
420
- ),
421
- React.createElement("span", { className: "am-group-label" }, g.label),
422
- React.createElement("span", { className: "am-group-count" }, g.sessions.length + " 个")
423
- ),
424
- isExpanded(g.key) ? g.sessions.map((s) => {
425
- const restoring = busy === "restore:" + s.id;
426
- const deleting = busy === "delete:" + s.id;
427
- const isConfirm = confirming === s.id;
428
- const isOrphan = s.orphan === true;
429
- const timeLabel = isOrphan ? s.id : fmtTime(s.updatedAt);
430
- const titleAttr = isOrphan ? s.id : (s.cwd || undefined);
431
- const actions = [];
432
- if (!isOrphan) {
433
- actions.push(React.createElement("button", {
434
- key: "restore",
435
- type: "button",
436
- className: "am-btn am-btn-restore",
437
- disabled: !!busy,
438
- onClick: () => runAction("restore", s.id),
439
- }, restoring ? "…" : "还原"));
440
- }
441
- actions.push(React.createElement("button", {
442
- key: "delete",
443
- type: "button",
444
- className: "am-btn am-btn-danger",
445
- disabled: !!busy,
446
- onClick: () => {
447
- if (isConfirm) { setConfirming(null); runAction("delete", s.id); }
448
- else { setConfirming(s.id); }
449
- },
450
- }, deleting ? "…" : (isConfirm ? "确认删除?" : "删除")));
451
- return React.createElement(
452
- "div", { className: "am-row" + (isOrphan ? " am-orphan" : ""), key: s.id, title: titleAttr },
453
- React.createElement(
454
- "span", { className: "am-slot", "aria-hidden": true },
455
- isOrphan ? React.createElement(WarningIcon) : React.createElement(ArchiveIcon)
456
- ),
457
- React.createElement("span", { className: "am-title" }, isOrphan ? "未知会话(日志已不存在)" : (s.displayTitle || s.title || "未命名会话")),
458
- React.createElement("span", { className: "am-time" }, timeLabel),
459
- React.createElement("span", { className: "am-row-actions" }, actions)
460
- );
461
- }) : null
462
- )
463
- )
464
- );
465
- const ghostNotice = ghostIds.length > 0
466
- ? React.createElement("div", { className: "am-ghost" }, ghostIds.length + " 个已删除会话仍驻留内存(已隐藏),重启 DSH 后自动清除")
467
- : null;
468
- return React.createElement(
469
- "div", { className: "am-panel", onClick: (e) => e.stopPropagation() },
470
- header,
471
- error ? React.createElement("div", { className: "am-error" }, error) : null,
472
- ghostNotice,
473
- search,
474
- body
475
- );
476
- }
477
-
478
- // Sidebar footer action entry (Cordis Plugin 下方、设置上方;纵向排列由注入
479
- // CSS 保证) + frame-wide archive manager panel.
480
- //
481
- // 弹窗为什么从这里渲染而不是 `shell.overlay`:AppFrame 把 `shell.overlay`
482
- // 渲染在 `.overlayLayer`(position:absolute; z-index:20)内,该层形成独立
483
- // stacking context,把子元素 z-index 锁死在其下;而 dsh-better-sidebar 把
484
- // 整层 append 到 document.body(position:fixed; z-index:25),归档弹窗因此
485
- // 会被它盖住。设置弹窗(ui-settings)的做法是把全屏 fixed 遮罩
486
- // (z-index:1000)从 sidebar.settings 槽渲染——侧栏列不构成 stacking
487
- // context,fixed 层直接参与根 stacking context,压过 better-sidebar 的 25。
488
- // 这里照搬同一做法:弹窗与按钮同从 sidebar.footer.action 槽渲染,两者共享
489
- // useOpen 状态,UI 行为不变。
490
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
491
- { name: "sidebar.footer.action", id: "archive-manager", order: 500, label: () => "归档" },
492
- (props) => {
493
- const wide = props.wide === true;
494
- const ghostIds = useGhostIds();
495
- let rawCount = 0;
496
- try { rawCount = props.useWorkspaces((s) => s.archivedSessionIds.length) || 0; } catch { rawCount = 0; }
497
- React.useEffect(() => { refreshState(); }, []);
498
- const count = rawCount - (ghostIds.length > rawCount ? rawCount : ghostIds.length);
499
- const safeCount = count > 0 ? count : 0;
500
- const isOpen = useOpen();
501
- const children = [];
502
- children.push(React.createElement("span", { key: "icon", className: "am-foot-icon", "aria-hidden": true }, React.createElement(ArchiveIcon)));
503
- if (wide) children.push(React.createElement("span", { key: "label", className: "am-foot-label" }, "归档"));
504
- if (wide && safeCount > 0) children.push(React.createElement("span", { key: "cnt", className: "am-badge" }, String(safeCount)));
505
- const button = React.createElement(
506
- "div", { className: "am-foot-layer" + (wide ? "" : " am-foot-rail") },
507
- React.createElement("button", {
508
- type: "button",
509
- onClick: () => { refreshState(); setOpen(!getOpen()); },
510
- title: "归档管理",
511
- "aria-label": "归档管理",
512
- className: "am-foot" + (isOpen ? " am-active" : ""),
513
- }, children)
514
- );
515
- const overlay = isOpen
516
- ? React.createElement(
517
- "div", { className: "am-overlay", onClick: () => setOpen(false) },
518
- React.createElement(ArchiveManager, { slotProps: props, onClose: () => setOpen(false) })
519
- )
520
- : null;
521
- return [button, overlay];
522
- }
523
- ));
524
- }
525
-
526
- exports.apply = apply;
527
- exports.inject = ["slots", "remote"];
528
- return module.exports;
529
- },
530
- });
1
+ /**
2
+ * dsh-archive-manager — Client half (web bundle).
3
+ *
4
+ * Rendered by the DSH web shell via `window.__ModuleLoader__.load`. Two
5
+ * operating modes, chosen by feature detection (never by version string):
6
+ *
7
+ * - DSH < 0.1.7 (legacy): registers a sidebar footer action button
8
+ * (archived-session entry) plus a frame-wide archive manager panel
9
+ * (search / group / restore / delete), rendered together from the
10
+ * sidebar.footer.action slot — mirroring how ui-settings renders its modal
11
+ * from sidebar.settings (see the registration comment for the
12
+ * stacking-context rationale).
13
+ * - DSH >= 0.1.7 (modern): the shell ships its own archive management (the
14
+ * sidebar "仅显示已归档" filter with per-row archive actions), so this
15
+ * plugin adds ONLY the missing delete affordance: a danger row in each
16
+ * archived session's "..." menu (`sidebar.workspaces.session.menu.item`)
17
+ * and a hover trash button at the row end
18
+ * (`sidebar.workspaces.session.row.action`), both opening a confirm dialog
19
+ * rendered from the sidebar foot slot. Detection = whether those slots
20
+ * ever get declared: `ctx.slots.inject()` on an undeclared key never
21
+ * fires, so pre-0.1.7 builds stay on the legacy path automatically, and
22
+ * the footer button hides itself the moment the modern slots appear.
23
+ *
24
+ * The panel/dialog mirrors the DSH 工作区 (workspace) browsing region: a
25
+ * dialog surface using the same design tokens as the workspace browser —
26
+ * folder group headers, compact 32px session rows with a leading slot icon
27
+ * and relative time, and hover-revealed actions (workspace rowActions
28
+ * pattern).
29
+ *
30
+ * Host communication goes through the `archiveManager` Remote namespace
31
+ * (`ctx.remote.archiveManager.restore/delete/state`), published by the Host
32
+ * half in `index.js`.
33
+ */
34
+ window.__ModuleLoader__.load({
35
+ id: "@duke-dsh-plugins/dsh-archive-manager",
36
+ factory: (require) => {
37
+ var module = { exports: {} };
38
+ var exports = module.exports;
39
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
40
+ const React = require("react");
41
+
42
+ // ---- CSS (package-owned, mirrors the DSH workspace browser + Modal) ----
43
+ // CSS_COMMON renders in both modes; CSS_LEGACY is the footer-button
44
+ // dressing, injected only while the legacy panel exists (cleared the
45
+ // moment the 0.1.7+ slots declare, so the modern shell keeps its own
46
+ // footer layout untouched).
47
+ const CSS_COMMON = `
48
+ /* Overlay — same mask surface as DSH Modal, same z-index tier as the DSH
49
+ settings overlay (1000). This layer MUST be rendered from a slot that sits
50
+ outside any stacking context (the sidebar foot, like Settings does), never
51
+ from the shell.overlay slot: AppFrame renders that slot inside its
52
+ .overlayLayer (position:absolute;z-index:20, a stacking context), which
53
+ traps any child z-index below it — dsh-better-sidebar appends a
54
+ position:fixed;z-index:25 layer to document.body and would cover the
55
+ modal. */
56
+ .am-overlay{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.4));backdrop-filter:var(--dsw-mask-blur,blur(2px));display:flex;align-items:center;justify-content:center;padding:24px;}
57
+
58
+ /* Panel — same surface & size as the DSH Settings dialog (width 800px) */
59
+ .am-panel{position:relative;display:flex;flex-direction:column;width:800px;max-width:calc(100vw - 48px);height:min(800px,100vh - 48px);overflow:hidden;border:1px solid var(--dsw-alias-border-inverted,rgba(128,128,128,.3));border-radius:24px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,inherit);box-shadow:var(--dsw-shadow-lv3,0 12px 40px rgba(0,0,0,.25));}
60
+
61
+ /* Header — DSH Modal header */
62
+ .am-header{display:flex;align-items:center;gap:10px;padding:18px 14px 10px 24px;}
63
+ .am-title{font-weight:500;font-size:16px;line-height:24px;flex:1;color:var(--dsw-alias-label-primary,inherit);}
64
+ .am-count{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary,inherit);flex:none;}
65
+ .am-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary,inherit);padding:0;}
66
+ .am-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));color:var(--dsw-alias-label-primary,inherit);}
67
+
68
+ /* Search — workspace expanded search input */
69
+ .am-search{padding:0 24px 12px;}
70
+ .am-search input{box-sizing:border-box;width:100%;height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:18px;outline:none;}
71
+ .am-search input::placeholder{color:var(--dsw-alias-label-tertiary,inherit);}
72
+ .am-search input:focus{border-color:var(--dsw-alias-brand-primary,#4a7dff);}
73
+
74
+ /* Body — workspace list area */
75
+ .am-body{flex:1;overflow-y:auto;padding:4px 8px 16px;overscroll-behavior:contain;}
76
+
77
+ /* Group sections, spaced like workspace group sections */
78
+ .am-group{margin-top:2px;}
79
+ .am-group+.am-group{margin-top:4px;}
80
+
81
+ /* Group head — workspace project row (folder + label + count), clickable + collapsible */
82
+ .am-group-head{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:34px;width:100%;padding:0 8px;border-radius:8px;border:none;background:transparent;color:inherit;font-family:inherit;font-size:inherit;text-align:left;cursor:pointer;}
83
+ .am-group-head:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08));}
84
+ .am-chevron{flex:none;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);transition:transform .15s var(--ds-ease-in-out);}
85
+ .am-chevron.am-open{transform:rotate(90deg);}
86
+ .am-group-icon{flex:none;width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);}
87
+ .am-group-label{flex:1;min-width:0;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
88
+ .am-group-count{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary,inherit);}
89
+
90
+ /* Session row — workspace session row (32px, radius 8, hover bg), indented under its group */
91
+ .am-row{display:flex;align-items:center;gap:6px;height:32px;padding:0 8px 0 32px;border-radius:8px;color:var(--dsw-alias-label-primary,inherit);}
92
+ .am-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08));}
93
+ .am-slot{flex:none;width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary,inherit);}
94
+ .am-title{flex:1;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
95
+ .am-time{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary,inherit);}
96
+ .am-row-actions{flex:none;display:none;align-items:center;gap:2px;}
97
+ .am-row:hover .am-time{display:none;}
98
+ .am-row:hover .am-row-actions{display:inline-flex;}
99
+
100
+ /* Action buttons — subtle text buttons, hover-revealed like workspace rowActions */
101
+ .am-btn{border:none;background:transparent;font-size:12px;line-height:18px;padding:4px 8px;border-radius:8px;cursor:pointer;white-space:nowrap;}
102
+ .am-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));}
103
+ .am-btn-restore{color:var(--dsw-alias-brand-primary,#4a7dff);}
104
+ .am-btn-danger{color:var(--dsw-alias-state-error-primary,#e5484d);}
105
+ .am-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.1));}
106
+ .am-btn[disabled]{opacity:.5;cursor:default;}
107
+
108
+ /* Orphan rows (log gone) */
109
+ .am-orphan .am-slot{color:var(--dsw-alias-state-warn-primary,#d99a06);}
110
+ .am-orphan .am-title{color:var(--dsw-alias-label-secondary,inherit);}
111
+
112
+ /* Empty / error / ghost — workspace empty style */
113
+ .am-empty{color:var(--dsw-alias-label-tertiary,inherit);padding:16px 12px;font-size:13px;line-height:20px;text-align:center;}
114
+ .am-error{color:var(--dsw-alias-state-error-primary,#e5484d);padding:8px 24px;font-size:12px;line-height:18px;}
115
+ .am-ghost{color:var(--dsw-alias-label-secondary,inherit);padding:8px 24px;font-size:12px;line-height:18px;}
116
+
117
+ /* ---- 0.1.7+ modern surfaces (delete affordances on the built-in archive) ---- */
118
+
119
+ /* Danger row in an archived session's "..." menu. Rendered through
120
+ ui-primitives' MenuItemButton when the module table provides it (native
121
+ look); this class styles the defensive plain-button fallback. */
122
+ .am-menu-del{display:flex;align-items:center;gap:8px;width:100%;padding:5px 12px;border:none;background:transparent;color:var(--dsw-alias-state-error-primary,#e5484d);font-family:inherit;font-size:13px;line-height:18px;text-align:left;cursor:pointer;border-radius:var(--dsw-radius-sm,6px);}
123
+ .am-menu-del:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.12));}
124
+ .am-menu-del[disabled]{opacity:.5;cursor:default;}
125
+
126
+ /* Hover button at the row end — same 28px metrics as ui-workspace's own
127
+ row-action iconButton so it sits in the strip without reflow. */
128
+ .am-row-del{border-radius:var(--dsw-radius-sm,6px);cursor:pointer;width:28px;height:28px;color:var(--dsw-alias-label-secondary,inherit);background:0 0;border:none;flex:none;display:inline-flex;justify-content:center;align-items:center;padding:0;}
129
+ .am-row-del:hover{color:var(--dsw-alias-state-error-primary,#e5484d);}
130
+ .am-row-del[disabled]{opacity:.5;cursor:default;}
131
+
132
+ /* Confirm dialog — same surface family as the legacy panel, compact width.
133
+ Rendered from the sidebar foot slot (fixed, root stacking context) so
134
+ dsh-better-sidebar's z-index:25 body layer cannot cover it. */
135
+ .am-confirm{position:relative;display:flex;flex-direction:column;width:440px;max-width:calc(100vw - 48px);overflow:hidden;border:1px solid var(--dsw-alias-border-inverted,rgba(128,128,128,.3));border-radius:24px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,inherit);box-shadow:var(--dsw-shadow-lv3,0 12px 40px rgba(0,0,0,.25));}
136
+ .am-confirm .am-confirm-body{padding:4px 24px 16px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary,inherit);}
137
+ .am-confirm .am-confirm-title{font-weight:500;font-size:16px;line-height:24px;color:var(--dsw-alias-label-primary,inherit);}
138
+ .am-confirm .am-confirm-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 24px 20px;}
139
+ .am-confirm .am-confirm-error{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:12px;line-height:18px;padding:0 24px 12px;}
140
+ .am-confirm .am-confirm-note{color:var(--dsw-alias-label-tertiary,inherit);font-size:12px;line-height:18px;}
141
+ .am-confirm .am-confirm-btn{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));background:transparent;color:var(--dsw-alias-label-primary,inherit);font-family:inherit;font-size:13px;line-height:18px;padding:6px 14px;border-radius:10px;cursor:pointer;}
142
+ .am-confirm .am-confirm-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));}
143
+ .am-confirm .am-confirm-btn.am-confirm-danger{border-color:transparent;background:var(--dsw-alias-state-error-primary,#e5484d);color:var(--dsw-alias-bg-base,#fff);}
144
+ .am-confirm .am-confirm-btn.am-confirm-danger:hover{background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.06);}
145
+ .am-confirm .am-confirm-btn[disabled]{opacity:.5;cursor:default;}
146
+
147
+ /* Sidebar footer entry (legacy mode only) */
148
+ .am-foot-layer{flex:none;width:100%;margin:8px 0 0;display:flex;align-items:center;}
149
+ .am-foot-layer .am-foot{width:100%;height:49px;border-radius:12px;padding:0 8px 0 6px;display:inline-flex;align-items:center;gap:8px;background:transparent;border:none;color:var(--dsw-alias-label-primary,inherit);font-family:inherit;font-size:14px;cursor:pointer;overflow:hidden;}
150
+ .am-foot-layer .am-foot:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.1));}
151
+ .am-foot-layer .am-foot.am-active{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14));}
152
+ .am-foot-layer .am-foot .am-foot-icon{flex:none;display:inline-flex;align-items:center;justify-content:center;}
153
+ .am-foot-layer .am-foot .am-foot-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden;}
154
+ .am-foot-layer .am-foot .am-badge{color:var(--dsw-alias-label-tertiary,inherit);font-variant-numeric:tabular-nums;flex:none;margin-left:auto;font-size:12px;line-height:16px;}
155
+ .am-foot-layer.am-foot-rail{width:36px;height:36px;margin:0;}
156
+ .am-foot-layer.am-foot-rail .am-foot{width:36px;height:36px;padding:0;justify-content:center;border-radius:10px;}
157
+ `;
158
+
159
+ // Footer-column layout: only meaningful while OUR footer button exists.
160
+ // Injected at boot, cleared permanently once the 0.1.7+ slots declare
161
+ // (markModern), so a modern DSH footer keeps its own layout rules.
162
+ const CSS_LEGACY = `
163
+ [class*="footerActions"]{flex-direction:column;align-items:center;}
164
+ `;
165
+
166
+ // ---- Client Remote contribution ----------------------------------------
167
+ // The browser-side `remote.archiveManager` service only exists after this
168
+ // module mounts its namespace via ctx.remote.$mount(): dsh-api-remotes'
169
+ // client assembly mounts only the five official namespaces, so a plugin
170
+ // must mount its own. Mirrors the invocations in typert.host.js (ids,
171
+ // service/namespace/method, wire fields). zod is not requirable in the
172
+ // browser module loader, so codecs use passthrough schemas — the runtime
173
+ // contract only needs typeSymbol plus a parse (DSH < 0.1.7 reads
174
+ // `codec.schema.parse`) or a create() factory (the typert registry on
175
+ // DSH >= 0.1.7 rejects a strict codec with "has no create() factory").
176
+ // Every codec therefore carries BOTH fields, like typert.host.js.
177
+ let passthroughValue;
178
+ const passthroughSchema = () => (passthroughValue ??= { parse: (v) => v });
179
+ const strictClientCodec = (typeSymbol) => ({
180
+ mode: "strict",
181
+ typeSymbol,
182
+ schema: passthroughSchema(),
183
+ create: passthroughSchema,
184
+ });
185
+ const CLIENT_REMOTE = {
186
+ package: "dsh-archive-manager",
187
+ descriptors: [
188
+ {
189
+ id: "dsh-archive-manager#archiveManager/restore",
190
+ service: "archiveManager",
191
+ namespace: "archiveManager",
192
+ method: "restore",
193
+ invocation: { kind: "direct" },
194
+ parameters: [
195
+ {
196
+ name: "request",
197
+ wire: "request",
198
+ source: "json",
199
+ codec: strictClientCodec("dsh-archive-manager#ArchiveManagerRestoreRequest"),
200
+ },
201
+ ],
202
+ result: strictClientCodec("dsh-archive-manager#ArchiveManagerRestoreResult"),
203
+ },
204
+ {
205
+ id: "dsh-archive-manager#archiveManager/delete",
206
+ service: "archiveManager",
207
+ namespace: "archiveManager",
208
+ method: "delete",
209
+ invocation: { kind: "direct" },
210
+ parameters: [
211
+ {
212
+ name: "request",
213
+ wire: "request",
214
+ source: "json",
215
+ codec: strictClientCodec("dsh-archive-manager#ArchiveManagerDeleteRequest"),
216
+ },
217
+ ],
218
+ result: strictClientCodec("dsh-archive-manager#ArchiveManagerDeleteResult"),
219
+ },
220
+ {
221
+ id: "dsh-archive-manager#archiveManager/state",
222
+ service: "archiveManager",
223
+ namespace: "archiveManager",
224
+ method: "state",
225
+ invocation: { kind: "direct" },
226
+ parameters: [],
227
+ result: strictClientCodec("dsh-archive-manager#ArchiveManagerStateResult"),
228
+ },
229
+ ],
230
+ };
231
+
232
+ async function apply(ctx) {
233
+ // Mount the archiveManager namespace before anything touches it; the
234
+ // mount's lifetime is bound to this plugin's context by $mount itself.
235
+ await ctx.remote.$mount(CLIENT_REMOTE);
236
+
237
+ const styleTag = document.createElement("style");
238
+ styleTag.textContent = CSS_COMMON;
239
+ document.head.appendChild(styleTag);
240
+ ctx.effect(() => () => styleTag.remove());
241
+
242
+ // Legacy footer-column override; cleared for good by markModern() once
243
+ // the 0.1.7+ built-in archive slots declare.
244
+ const legacyStyleTag = document.createElement("style");
245
+ legacyStyleTag.textContent = CSS_LEGACY;
246
+ document.head.appendChild(legacyStyleTag);
247
+ ctx.effect(() => () => legacyStyleTag.remove());
248
+
249
+ // ctx.get() reads the service without the property-accessor inject
250
+ // guard; it exists because the $mount above just created it.
251
+ const remote = ctx.get("remote.archiveManager");
252
+
253
+ // ui-primitives (MenuItemButton / Tooltip / IconTrashOutlineRegular) is
254
+ // part of the 0.1.7+ module table — ui-workspace requires it from the
255
+ // same table. Loaded defensively: pre-0.1.7 builds may not ship it, and
256
+ // the modern components (its only consumers) render only when the 0.1.7
257
+ // slots exist. Fallbacks below keep every surface functional without it.
258
+ let ui = null;
259
+ try {
260
+ ui = require("@deepseek-ai/dsh-client-ui-primitives");
261
+ } catch {
262
+ ui = null;
263
+ }
264
+
265
+ // Shared open state between the footer button and the overlay.
266
+ let open = false;
267
+ const listeners = new Set();
268
+ function getOpen() { return open; }
269
+ function setOpen(v) { if (open !== v) { open = v; for (const fn of listeners) fn(); } }
270
+ function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
271
+ function useOpen() { return React.useSyncExternalStore(subscribe, getOpen); }
272
+
273
+ // Ghost state (deleted-but-resident archived sessions), shared by both components.
274
+ const ghostStore = (() => {
275
+ let ids = [];
276
+ const listeners = new Set();
277
+ return {
278
+ getSnapshot: () => ids,
279
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
280
+ set: (next) => { ids = next; for (const fn of listeners) fn(); },
281
+ };
282
+ })();
283
+ function useGhostIds() { return React.useSyncExternalStore(ghostStore.subscribe, ghostStore.getSnapshot); }
284
+ async function refreshState() {
285
+ try {
286
+ const res = await remote.state();
287
+ ghostStore.set(res && res.ok && Array.isArray(res.value.ghostIds) ? res.value.ghostIds : []);
288
+ } catch {
289
+ ghostStore.set([]);
290
+ }
291
+ }
292
+
293
+ // ---- Modern (DSH >= 0.1.7) mode state --------------------------------
294
+ // modernStore flips on exactly when the built-in archive's row slots
295
+ // declare (see the inject registrations at the bottom of apply). Until
296
+ // then the legacy footer button renders; afterwards it hides itself and
297
+ // the footer slot only carries the delete confirm dialog.
298
+ const modernStore = (() => {
299
+ let value = false;
300
+ const listeners = new Set();
301
+ return {
302
+ getSnapshot: () => value,
303
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
304
+ set: (next) => { if (value !== next) { value = next; for (const fn of listeners) fn(); } },
305
+ };
306
+ })();
307
+ function useModern() { return React.useSyncExternalStore(modernStore.subscribe, modernStore.getSnapshot); }
308
+
309
+ // Pending delete request for the modern confirm dialog ({sessionId,
310
+ // displayTitle} | null), set by the menu row / hover button.
311
+ const deleteStore = (() => {
312
+ let value = null;
313
+ const listeners = new Set();
314
+ return {
315
+ getSnapshot: () => value,
316
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
317
+ set: (next) => { if (value !== next) { value = next; for (const fn of listeners) fn(); } },
318
+ };
319
+ })();
320
+ function useDeleteRequest() { return React.useSyncExternalStore(deleteStore.subscribe, deleteStore.getSnapshot); }
321
+
322
+ // Enter modern mode: hide the legacy button (and its footer-layout CSS)
323
+ // for good, then let the caller register its slot entries.
324
+ const markModern = () => {
325
+ modernStore.set(true);
326
+ legacyStyleTag.textContent = "";
327
+ };
328
+
329
+ /**
330
+ * Whether one session is in the registry-global archive set, read from
331
+ * the framework's global `useWorkspaces` standard hook (delivered to
332
+ * every slot component on every supported DSH). Fails closed to false
333
+ * when the hook is unavailable.
334
+ *
335
+ * @param {Function} useWorkspaces - the global workspace snapshot hook.
336
+ * @param {string} sessionId - row session id.
337
+ * @returns {boolean} archived membership.
338
+ */
339
+ function useArchivedMember(useWorkspaces, sessionId) {
340
+ try {
341
+ return useWorkspaces(
342
+ (s) => Array.isArray(s.archivedSessionIds) && s.archivedSessionIds.indexOf(sessionId) !== -1
343
+ ) === true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ function ArchiveIcon() {
350
+ return React.createElement(
351
+ "svg",
352
+ { width: 16, height: 16, viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: 1.1, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
353
+ React.createElement("rect", { x: 1.5, y: 2, width: 11, height: 3.5, rx: 0.8 }),
354
+ React.createElement("path", { d: "M2.5 5.5v5a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-5" }),
355
+ React.createElement("path", { d: "M5.5 8h3" })
356
+ );
357
+ }
358
+
359
+ function FolderIcon() {
360
+ return React.createElement(
361
+ "svg",
362
+ { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
363
+ React.createElement("path", { d: "M1.5 4.5a1 1 0 0 1 1-1h3l1.5 2h6.5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-7Z" })
364
+ );
365
+ }
366
+
367
+ function WarningIcon() {
368
+ return React.createElement(
369
+ "svg",
370
+ { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
371
+ React.createElement("path", { d: "M8 2.5 14.5 13.5h-13L8 2.5Z" }),
372
+ React.createElement("path", { d: "M8 6.5v3" }),
373
+ React.createElement("circle", { cx: 8, cy: 11.5, r: 0.4, fill: "currentColor" })
374
+ );
375
+ }
376
+
377
+ function ChevronIcon() {
378
+ return React.createElement(
379
+ "svg",
380
+ { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
381
+ React.createElement("path", { d: "m5 3.5 3.5 3.5L5 10.5" })
382
+ );
383
+ }
384
+
385
+ // Trash glyph for the modern delete surfaces: ui-primitives' shipped
386
+ // icon when available (identical to the shell's own artwork), a plain
387
+ // outline SVG otherwise.
388
+ function TrashIcon({ size }) {
389
+ const px = size || 14;
390
+ if (ui && ui.IconTrashOutlineRegular) {
391
+ return React.createElement(ui.IconTrashOutlineRegular, { size: px });
392
+ }
393
+ return React.createElement(
394
+ "svg",
395
+ { width: px, height: px, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
396
+ React.createElement("path", { d: "M2.5 4.5h11" }),
397
+ React.createElement("path", { d: "M5.5 4.5v-1a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1" }),
398
+ React.createElement("path", { d: "M4 4.5l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1" }),
399
+ React.createElement("path", { d: "M6.5 7v4M9.5 7v4" })
400
+ );
401
+ }
402
+
403
+ function fmtTime(ts) {
404
+ if (typeof ts !== "number" || !isFinite(ts) || ts <= 0) return "";
405
+ const diff = Date.now() - ts;
406
+ const min = 60 * 1000, hour = 60 * min, day = 24 * hour, month = 30 * day;
407
+ if (diff < min) return "刚刚";
408
+ if (diff < hour) return Math.floor(diff / min) + " 分钟前";
409
+ if (diff < day) return Math.floor(diff / hour) + " 小时前";
410
+ if (diff < month) return Math.floor(diff / day) + " 天前";
411
+ const d = new Date(ts);
412
+ return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
413
+ }
414
+
415
+ function deriveGroups(byId, ids, workspaces, archivedIds, query) {
416
+ const q = (query || "").trim().toLowerCase();
417
+ const archived = new Set(archivedIds);
418
+ const groups = [];
419
+ const accounted = new Set();
420
+ const resolved = new Set();
421
+ for (const ws of workspaces) {
422
+ const members = [];
423
+ for (const sid of ws.sessionIds) {
424
+ if (!archived.has(sid)) continue;
425
+ resolved.add(sid);
426
+ const s = byId[sid];
427
+ if (s === undefined) continue;
428
+ accounted.add(sid);
429
+ if (s.blank) continue;
430
+ const label = s.displayTitle || s.title || "未命名会话";
431
+ if (q && label.toLowerCase().indexOf(q) === -1 && ws.title.toLowerCase().indexOf(q) === -1 && String(s.cwd || "").toLowerCase().indexOf(q) === -1) continue;
432
+ members.push(s);
433
+ }
434
+ if (members.length === 0) continue;
435
+ members.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1));
436
+ groups.push({ key: ws.workspaceId, label: ws.title, sessions: members });
437
+ }
438
+ const stray = [];
439
+ for (const sid of ids) {
440
+ if (accounted.has(sid)) continue;
441
+ if (!archived.has(sid)) continue;
442
+ resolved.add(sid);
443
+ const s = byId[sid];
444
+ if (s === undefined) continue;
445
+ if (s.blank) continue;
446
+ const label = s.displayTitle || s.title || "未命名会话";
447
+ if (q && label.toLowerCase().indexOf(q) === -1 && String(s.cwd || "").toLowerCase().indexOf(q) === -1) continue;
448
+ stray.push(s);
449
+ }
450
+ if (stray.length > 0) {
451
+ stray.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1));
452
+ groups.push({ key: "__ungrouped__", label: "未分组", sessions: stray });
453
+ }
454
+ const orphans = [];
455
+ for (const sid of archivedIds) {
456
+ if (resolved.has(sid)) continue;
457
+ const s = byId[sid];
458
+ if (s !== undefined && !s.blank) continue;
459
+ if (q) {
460
+ const sidLower = String(sid).toLowerCase();
461
+ if (sidLower.indexOf(q) === -1) continue;
462
+ }
463
+ orphans.push({ id: sid, orphan: true, displayTitle: "未知会话(日志已不存在)", updatedAt: 0, cwd: undefined });
464
+ }
465
+ return { groups, orphans };
466
+ }
467
+
468
+ function ArchiveManager({ slotProps, onClose }) {
469
+ const [query, setQuery] = React.useState("");
470
+ const [busy, setBusy] = React.useState(null);
471
+ const [confirming, setConfirming] = React.useState(null);
472
+ const [error, setError] = React.useState(null);
473
+ // Groups are collapsed by default so a large archive stays manageable;
474
+ // a non-empty search query force-expands every group to show matches.
475
+ const [expanded, setExpanded] = React.useState(() => new Set());
476
+ const ghostIds = useGhostIds();
477
+ const rawArchivedIds = slotProps.useWorkspaces((s) => s.archivedSessionIds);
478
+ const byId = slotProps.useSessions((s) => s.byId);
479
+ const ids = slotProps.useSessions((s) => s.ids);
480
+ const workspaces = slotProps.useWorkspaces((s) => s.items);
481
+ React.useEffect(() => { refreshState(); }, []);
482
+ const archivedIds = React.useMemo(() => {
483
+ if (ghostIds.length === 0) return rawArchivedIds;
484
+ const ghost = new Set(ghostIds);
485
+ return rawArchivedIds.filter((id) => !ghost.has(id));
486
+ }, [rawArchivedIds, ghostIds]);
487
+ const derived = React.useMemo(() => {
488
+ try {
489
+ const d = deriveGroups(byId, ids, workspaces, archivedIds, query);
490
+ return { groups: d.groups, orphans: d.orphans, error: null };
491
+ } catch (e) {
492
+ return { groups: [], orphans: [], error: e instanceof Error ? e.message : String(e) };
493
+ }
494
+ }, [byId, ids, workspaces, archivedIds, query]);
495
+ const runAction = async (action, sessionId) => {
496
+ setBusy(action + ":" + sessionId);
497
+ setError(null);
498
+ try {
499
+ const res = await remote[action]({ sessionId });
500
+ if (res && res.ok) {
501
+ if (action === "delete") {
502
+ try {
503
+ const sessions = ctx.get("sessions");
504
+ if (sessions && typeof sessions.refresh === "function") sessions.refresh();
505
+ } catch { /* non-fatal */ }
506
+ refreshState();
507
+ }
508
+ } else {
509
+ const err = res && res.error;
510
+ setError((err && err.message) || (err && err.code) || "操作失败");
511
+ }
512
+ } catch (e) {
513
+ setError(e instanceof Error ? e.message : String(e));
514
+ } finally {
515
+ setBusy(null);
516
+ }
517
+ };
518
+ const total = archivedIds.length;
519
+ const groups = derived.groups.slice();
520
+ if (derived.orphans.length > 0) {
521
+ groups.push({ key: "__orphan__", label: "无效的归档记录", orphanGroup: true, sessions: derived.orphans });
522
+ }
523
+ const searching = query.trim() !== "";
524
+ const isExpanded = (key) => searching || expanded.has(key);
525
+ const toggleGroup = (key) => {
526
+ setExpanded((prev) => {
527
+ const next = new Set(prev);
528
+ if (next.has(key)) next.delete(key); else next.add(key);
529
+ return next;
530
+ });
531
+ };
532
+ const empty = total === 0 && ghostIds.length === 0;
533
+ const allGhost = total === 0 && ghostIds.length > 0;
534
+ const header = React.createElement(
535
+ "div", { className: "am-header" },
536
+ React.createElement("span", { className: "am-title" }, "归档管理"),
537
+ React.createElement("span", { className: "am-count" }, total + " 个归档"),
538
+ typeof onClose === "function"
539
+ ? React.createElement("button", { className: "am-close", onClick: onClose, title: "关闭", "aria-label": "关闭" }, "✕")
540
+ : null
541
+ );
542
+ const search = React.createElement(
543
+ "div", { className: "am-search" },
544
+ React.createElement("input", {
545
+ type: "text",
546
+ placeholder: "搜索归档标题或项目…",
547
+ value: query,
548
+ onChange: (e) => { setQuery(e.target.value); setConfirming(null); },
549
+ })
550
+ );
551
+ const body = React.createElement(
552
+ "div", { className: "am-body" },
553
+ derived.error
554
+ ? React.createElement("div", { className: "am-error" }, "渲染出错:" + derived.error)
555
+ : empty
556
+ ? React.createElement("div", { className: "am-empty" }, "暂无归档会话")
557
+ : allGhost
558
+ ? React.createElement("div", { className: "am-empty" }, "归档均已删除,重启 DSH 后自动清除")
559
+ : groups.map((g) =>
560
+ React.createElement(
561
+ "div", { className: "am-group", key: g.key },
562
+ React.createElement(
563
+ "button", {
564
+ type: "button",
565
+ className: "am-group-head",
566
+ onClick: () => toggleGroup(g.key),
567
+ "aria-expanded": isExpanded(g.key),
568
+ },
569
+ React.createElement(
570
+ "span", { className: "am-chevron" + (isExpanded(g.key) ? " am-open" : ""), "aria-hidden": true },
571
+ React.createElement(ChevronIcon)
572
+ ),
573
+ React.createElement(
574
+ "span", { className: "am-group-icon", "aria-hidden": true },
575
+ g.orphanGroup ? React.createElement(WarningIcon) : React.createElement(FolderIcon)
576
+ ),
577
+ React.createElement("span", { className: "am-group-label" }, g.label),
578
+ React.createElement("span", { className: "am-group-count" }, g.sessions.length + " 个")
579
+ ),
580
+ isExpanded(g.key) ? g.sessions.map((s) => {
581
+ const restoring = busy === "restore:" + s.id;
582
+ const deleting = busy === "delete:" + s.id;
583
+ const isConfirm = confirming === s.id;
584
+ const isOrphan = s.orphan === true;
585
+ const timeLabel = isOrphan ? s.id : fmtTime(s.updatedAt);
586
+ const titleAttr = isOrphan ? s.id : (s.cwd || undefined);
587
+ const actions = [];
588
+ if (!isOrphan) {
589
+ actions.push(React.createElement("button", {
590
+ key: "restore",
591
+ type: "button",
592
+ className: "am-btn am-btn-restore",
593
+ disabled: !!busy,
594
+ onClick: () => runAction("restore", s.id),
595
+ }, restoring ? "…" : "还原"));
596
+ }
597
+ actions.push(React.createElement("button", {
598
+ key: "delete",
599
+ type: "button",
600
+ className: "am-btn am-btn-danger",
601
+ disabled: !!busy,
602
+ onClick: () => {
603
+ if (isConfirm) { setConfirming(null); runAction("delete", s.id); }
604
+ else { setConfirming(s.id); }
605
+ },
606
+ }, deleting ? "…" : (isConfirm ? "确认删除?" : "删除")));
607
+ return React.createElement(
608
+ "div", { className: "am-row" + (isOrphan ? " am-orphan" : ""), key: s.id, title: titleAttr },
609
+ React.createElement(
610
+ "span", { className: "am-slot", "aria-hidden": true },
611
+ isOrphan ? React.createElement(WarningIcon) : React.createElement(ArchiveIcon)
612
+ ),
613
+ React.createElement("span", { className: "am-title" }, isOrphan ? "未知会话(日志已不存在)" : (s.displayTitle || s.title || "未命名会话")),
614
+ React.createElement("span", { className: "am-time" }, timeLabel),
615
+ React.createElement("span", { className: "am-row-actions" }, actions)
616
+ );
617
+ }) : null
618
+ )
619
+ )
620
+ );
621
+ const ghostNotice = ghostIds.length > 0
622
+ ? React.createElement("div", { className: "am-ghost" }, ghostIds.length + " 个已删除会话仍驻留内存(已隐藏),重启 DSH 后自动清除")
623
+ : null;
624
+ return React.createElement(
625
+ "div", { className: "am-panel", onClick: (e) => e.stopPropagation() },
626
+ header,
627
+ error ? React.createElement("div", { className: "am-error" }, error) : null,
628
+ ghostNotice,
629
+ search,
630
+ body
631
+ );
632
+ }
633
+
634
+ // ---- Modern (DSH >= 0.1.7) delete surfaces ---------------------------
635
+ // The built-in archive management owns listing/restore on 0.1.7+; these
636
+ // three components add only DELETION on top of it. Both entry points
637
+ // show for archived, non-ghost rows only and open the shared confirm
638
+ // dialog (rendered from the sidebar foot slot — see the stacking note
639
+ // on the footer registration below).
640
+
641
+ /**
642
+ * Danger row in an archived session's "..." menu
643
+ * (`sidebar.workspaces.session.menu.item`, order 450 — after the
644
+ * shipped pin/rename/fork/archive rows). Dismisses the menu through the
645
+ * slot's `useMenuOpenState` hook before raising the confirm dialog.
646
+ *
647
+ * @param props - owner share (row identity) + the slot's hook seat + the global workspace hook.
648
+ * @returns the menu row, or null when delete does not apply.
649
+ */
650
+ function DeleteMenuEntry({ sessionId, displayTitle, useMenuOpenState, useWorkspaces }) {
651
+ const archived = useArchivedMember(useWorkspaces, sessionId);
652
+ const ghostIds = useGhostIds();
653
+ const ghost = ghostIds.indexOf(sessionId) !== -1;
654
+ const menuState = typeof useMenuOpenState === "function" ? useMenuOpenState() : null;
655
+ const setMenuOpen = menuState ? menuState[1] : () => {};
656
+ // Keep the ghost list fresh while the menu offering delete is open.
657
+ React.useEffect(() => { if (archived) refreshState(); }, [archived, sessionId]);
658
+ if (!archived || ghost) return null;
659
+ const select = () => {
660
+ setMenuOpen(false);
661
+ deleteStore.set({ sessionId, displayTitle });
662
+ };
663
+ if (ui && ui.MenuItemButton) {
664
+ return React.createElement(
665
+ ui.MenuItemButton,
666
+ {
667
+ danger: true,
668
+ separatorBefore: true,
669
+ icon: React.createElement(TrashIcon, { size: 14 }),
670
+ onSelect: select,
671
+ },
672
+ "删除会话…"
673
+ );
674
+ }
675
+ return React.createElement(
676
+ "button",
677
+ { type: "button", role: "menuitem", className: "am-menu-del", onClick: select },
678
+ React.createElement(TrashIcon, { size: 14 }),
679
+ "删除会话…"
680
+ );
681
+ }
682
+
683
+ /**
684
+ * Hover trash button at the archived row's end
685
+ * (`sidebar.workspaces.session.row.action`, order 300 — after the
686
+ * shipped archive/pin buttons). Clicks inside the row-action strip stay
687
+ * in the strip, so no propagation handling is needed.
688
+ *
689
+ * @param props - owner share (row identity) + the global workspace hook.
690
+ * @returns the button, or null when delete does not apply.
691
+ */
692
+ function DeleteRowButton({ sessionId, displayTitle, useWorkspaces }) {
693
+ const archived = useArchivedMember(useWorkspaces, sessionId);
694
+ const ghostIds = useGhostIds();
695
+ const ghost = ghostIds.indexOf(sessionId) !== -1;
696
+ React.useEffect(() => { if (archived) refreshState(); }, [archived, sessionId]);
697
+ if (!archived || ghost) return null;
698
+ const button = React.createElement(
699
+ "button",
700
+ {
701
+ type: "button",
702
+ className: "am-row-del",
703
+ title: "删除会话",
704
+ "aria-label": "删除会话",
705
+ onClick: () => deleteStore.set({ sessionId, displayTitle }),
706
+ },
707
+ React.createElement(TrashIcon, { size: 14 })
708
+ );
709
+ if (ui && ui.Tooltip) {
710
+ return React.createElement(
711
+ ui.Tooltip,
712
+ { label: "删除会话", side: "bottom", align: "end", delayMs: 500 },
713
+ button
714
+ );
715
+ }
716
+ return button;
717
+ }
718
+
719
+ /**
720
+ * The modern confirm dialog: permanent deletion gets the same 二次确认
721
+ * treatment as the legacy panel (per-request state dies with the
722
+ * request; errors stay visible; a live-but-idle target resolves into a
723
+ * ghost explanation instead of closing silently).
724
+ *
725
+ * @param props - the pending request and its dismissal.
726
+ * @returns the fixed overlay + dialog.
727
+ */
728
+ function DeleteConfirmForm({ req, onClose }) {
729
+ const [busy, setBusy] = React.useState(false);
730
+ const [error, setError] = React.useState(null);
731
+ const [ghost, setGhost] = React.useState(false);
732
+ // Esc cancels: the dialog owns z-index:1000, so swallow the key to
733
+ // keep it from reaching the shell's global Escape handlers.
734
+ React.useEffect(() => {
735
+ const onKeyDown = (e) => {
736
+ if (e.key === "Escape") {
737
+ e.stopPropagation();
738
+ if (!busy) onClose();
739
+ }
740
+ };
741
+ document.addEventListener("keydown", onKeyDown, true);
742
+ return () => document.removeEventListener("keydown", onKeyDown, true);
743
+ }, [busy]);
744
+ const close = () => { if (!busy) onClose(); };
745
+ const confirm = async () => {
746
+ if (busy) return;
747
+ setBusy(true);
748
+ setError(null);
749
+ try {
750
+ const res = await remote.delete({ sessionId: req.sessionId });
751
+ if (res && res.ok) {
752
+ try {
753
+ const sessions = ctx.get("sessions");
754
+ if (sessions && typeof sessions.refresh === "function") sessions.refresh();
755
+ } catch { /* non-fatal */ }
756
+ refreshState();
757
+ if (res.value && res.value.live) {
758
+ // Live-but-idle: files are gone, the archive entry stays as a
759
+ // ghost until restart (same semantics as the legacy panel).
760
+ setGhost(true);
761
+ } else {
762
+ onClose();
763
+ }
764
+ } else {
765
+ const err = res && res.error;
766
+ setError((err && err.message) || (err && err.code) || "删除失败");
767
+ }
768
+ } catch (e) {
769
+ setError(e instanceof Error ? e.message : String(e));
770
+ } finally {
771
+ setBusy(false);
772
+ }
773
+ };
774
+ const title = req.displayTitle || req.sessionId;
775
+ return React.createElement(
776
+ "div", { className: "am-overlay", onClick: close },
777
+ React.createElement(
778
+ "div", { className: "am-confirm", onClick: (e) => e.stopPropagation() },
779
+ React.createElement(
780
+ "div", { className: "am-header" },
781
+ React.createElement("span", { className: "am-title" }, ghost ? "已删除会话" : "删除会话"),
782
+ React.createElement(
783
+ "button",
784
+ { className: "am-close", onClick: close, disabled: busy, title: "关闭", "aria-label": "关闭" },
785
+ "✕"
786
+ )
787
+ ),
788
+ React.createElement(
789
+ "div", { className: "am-confirm-body" },
790
+ ghost
791
+ ? "日志文件已删除。该会话仍驻留内存,删除条目将在重启 DSH 后自动清除。"
792
+ : "将永久删除「" + title + "」的会话日志文件,此操作不可恢复。"
793
+ ),
794
+ error && !ghost
795
+ ? React.createElement("div", { className: "am-confirm-error" }, error)
796
+ : null,
797
+ React.createElement(
798
+ "div", { className: "am-confirm-actions" },
799
+ ghost
800
+ ? React.createElement(
801
+ "button",
802
+ { type: "button", className: "am-confirm-btn", onClick: onClose },
803
+ "关闭"
804
+ )
805
+ : [
806
+ React.createElement(
807
+ "button",
808
+ { key: "cancel", type: "button", className: "am-confirm-btn", disabled: busy, onClick: close },
809
+ "取消"
810
+ ),
811
+ React.createElement(
812
+ "button",
813
+ { key: "confirm", type: "button", className: "am-confirm-btn am-confirm-danger", disabled: busy, onClick: confirm },
814
+ busy ? "删除中…" : "永久删除"
815
+ ),
816
+ ]
817
+ )
818
+ )
819
+ );
820
+ }
821
+
822
+ // Sidebar footer action entry (Cordis Plugin 下方、设置上方;纵向排列由注入
823
+ // CSS 保证) + frame-wide archive manager panel.
824
+ //
825
+ // 弹窗为什么从这里渲染而不是 `shell.overlay`:AppFrame 把 `shell.overlay`
826
+ // 渲染在 `.overlayLayer`(position:absolute; z-index:20)内,该层形成独立
827
+ // stacking context,把子元素 z-index 锁死在其下;而 dsh-better-sidebar 把
828
+ // 整层 append 到 document.body(position:fixed; z-index:25),归档弹窗因此
829
+ // 会被它盖住。设置弹窗(ui-settings)的做法是把全屏 fixed 遮罩
830
+ // (z-index:1000)从 sidebar.settings 槽渲染——侧栏列不构成 stacking
831
+ // context,fixed 层直接参与根 stacking context,压过 better-sidebar 的 25。
832
+ // 这里照搬同一做法:弹窗与按钮同从 sidebar.footer.action 槽渲染,两者共享
833
+ // useOpen 状态,UI 行为不变。
834
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
835
+ { name: "sidebar.footer.action", id: "archive-manager", order: 500, label: () => "归档" },
836
+ (props) => {
837
+ const wide = props.wide === true;
838
+ const ghostIds = useGhostIds();
839
+ let rawCount = 0;
840
+ try { rawCount = props.useWorkspaces((s) => s.archivedSessionIds.length) || 0; } catch { rawCount = 0; }
841
+ React.useEffect(() => { refreshState(); }, []);
842
+ const count = rawCount - (ghostIds.length > rawCount ? rawCount : ghostIds.length);
843
+ const safeCount = count > 0 ? count : 0;
844
+ const isOpen = useOpen();
845
+ const modern = useModern();
846
+ const deleteReq = useDeleteRequest();
847
+ // Esc 关闭弹窗:打开期间在 document 捕获阶段监听 keydown,命中
848
+ // Escape 即关闭并 stopPropagation——弹窗位于 z-index:1000 顶层,
849
+ // 此时 Esc 应只作用于它,不穿透到 shell 的全局快捷键或底层输入框。
850
+ React.useEffect(() => {
851
+ if (!isOpen) return undefined;
852
+ const onKeyDown = (e) => {
853
+ if (e.key === "Escape") {
854
+ e.stopPropagation();
855
+ setOpen(false);
856
+ }
857
+ };
858
+ document.addEventListener("keydown", onKeyDown, true);
859
+ return () => document.removeEventListener("keydown", onKeyDown, true);
860
+ }, [isOpen]);
861
+ // DSH >= 0.1.7: the shell's built-in archive management owns the
862
+ // sidebar entry — no button, no panel; this slot carries only the
863
+ // delete confirm dialog that this mode exists to add.
864
+ if (modern) {
865
+ return deleteReq
866
+ ? React.createElement(DeleteConfirmForm, {
867
+ key: deleteReq.sessionId,
868
+ req: deleteReq,
869
+ onClose: () => deleteStore.set(null),
870
+ })
871
+ : null;
872
+ }
873
+ const children = [];
874
+ children.push(React.createElement("span", { key: "icon", className: "am-foot-icon", "aria-hidden": true }, React.createElement(ArchiveIcon)));
875
+ if (wide) children.push(React.createElement("span", { key: "label", className: "am-foot-label" }, "归档"));
876
+ if (wide && safeCount > 0) children.push(React.createElement("span", { key: "cnt", className: "am-badge" }, String(safeCount)));
877
+ const button = React.createElement(
878
+ "div", { className: "am-foot-layer" + (wide ? "" : " am-foot-rail") },
879
+ React.createElement("button", {
880
+ type: "button",
881
+ onClick: () => { refreshState(); setOpen(!getOpen()); },
882
+ title: "归档管理",
883
+ "aria-label": "归档管理",
884
+ className: "am-foot" + (isOpen ? " am-active" : ""),
885
+ }, children)
886
+ );
887
+ const overlay = isOpen
888
+ ? React.createElement(
889
+ "div", { className: "am-overlay", onClick: () => setOpen(false) },
890
+ React.createElement(ArchiveManager, { slotProps: props, onClose: () => setOpen(false) })
891
+ )
892
+ : null;
893
+ return [button, overlay];
894
+ }
895
+ ));
896
+
897
+ // ---- Modern-mode registrations (fired only on DSH >= 0.1.7) ----------
898
+ // ui-workspace declares these two lists when it registers the built-in
899
+ // archive browsing region (the "仅显示已归档" filter world). On earlier
900
+ // DSH builds the keys are never declared: ctx.slots.inject() waits
901
+ // silently, these callbacks never run, no entry is registered, and the
902
+ // legacy footer button stays — that IS the version switch. The first
903
+ // declaration also flips markModern(), which hides the legacy button
904
+ // and drops the footer-column CSS for good.
905
+ ctx.slots.inject("sidebar.workspaces.session.menu.item", () => {
906
+ markModern();
907
+ return ctx.slots.register(
908
+ { name: "sidebar.workspaces.session.menu.item", id: "dsh-archive-manager.delete", order: 450 },
909
+ DeleteMenuEntry
910
+ );
911
+ });
912
+ ctx.slots.inject("sidebar.workspaces.session.row.action", () => {
913
+ markModern();
914
+ return ctx.slots.register(
915
+ { name: "sidebar.workspaces.session.row.action", id: "dsh-archive-manager.delete", order: 300 },
916
+ DeleteRowButton
917
+ );
918
+ });
919
+ }
920
+
921
+ exports.apply = apply;
922
+ exports.inject = ["slots", "remote"];
923
+ // Exposed for verify-typert-compat.mjs (the browser wiring contract has
924
+ // to satisfy both DSH typert registries; nothing else reads this).
925
+ exports.CLIENT_REMOTE = CLIENT_REMOTE;
926
+ return module.exports;
927
+ },
928
+ });