@duke-dsh-plugins/dsh-archive-manager 1.3.5 → 1.3.6

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 (4) hide show
  1. package/client.js +544 -530
  2. package/index.js +276 -276
  3. package/package.json +1 -1
  4. package/typert.host.js +224 -224
package/client.js CHANGED
@@ -1,530 +1,544 @@
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`. 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
+ // Esc 关闭弹窗:打开期间在 document 捕获阶段监听 keydown,命中
502
+ // Escape 即关闭并 stopPropagation——弹窗位于 z-index:1000 顶层,
503
+ // 此时 Esc 应只作用于它,不穿透到 shell 的全局快捷键或底层输入框。
504
+ React.useEffect(() => {
505
+ if (!isOpen) return undefined;
506
+ const onKeyDown = (e) => {
507
+ if (e.key === "Escape") {
508
+ e.stopPropagation();
509
+ setOpen(false);
510
+ }
511
+ };
512
+ document.addEventListener("keydown", onKeyDown, true);
513
+ return () => document.removeEventListener("keydown", onKeyDown, true);
514
+ }, [isOpen]);
515
+ const children = [];
516
+ children.push(React.createElement("span", { key: "icon", className: "am-foot-icon", "aria-hidden": true }, React.createElement(ArchiveIcon)));
517
+ if (wide) children.push(React.createElement("span", { key: "label", className: "am-foot-label" }, "归档"));
518
+ if (wide && safeCount > 0) children.push(React.createElement("span", { key: "cnt", className: "am-badge" }, String(safeCount)));
519
+ const button = React.createElement(
520
+ "div", { className: "am-foot-layer" + (wide ? "" : " am-foot-rail") },
521
+ React.createElement("button", {
522
+ type: "button",
523
+ onClick: () => { refreshState(); setOpen(!getOpen()); },
524
+ title: "归档管理",
525
+ "aria-label": "归档管理",
526
+ className: "am-foot" + (isOpen ? " am-active" : ""),
527
+ }, children)
528
+ );
529
+ const overlay = isOpen
530
+ ? React.createElement(
531
+ "div", { className: "am-overlay", onClick: () => setOpen(false) },
532
+ React.createElement(ArchiveManager, { slotProps: props, onClose: () => setOpen(false) })
533
+ )
534
+ : null;
535
+ return [button, overlay];
536
+ }
537
+ ));
538
+ }
539
+
540
+ exports.apply = apply;
541
+ exports.inject = ["slots", "remote"];
542
+ return module.exports;
543
+ },
544
+ });