dsh-session-groups 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2280 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-session-groups",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ /**
9
+ * dsh-session-tree, browser half.
10
+ *
11
+ * DSH's sidebar renders Workspaces and their Sessions. This plugin adds a
12
+ * user-defined folder tree *inside* each Workspace without touching the
13
+ * official React tree: group headers are ordinary DOM nodes inserted into
14
+ * the Workspace section, and session rows are ordered with CSS `order`.
15
+ * Group membership is metadata (a host JSON file, cached in localStorage)
16
+ * and never changes a Session's cwd, log, archive state, or Workspace.
17
+ *
18
+ * The integration surface is deliberately attribute-based:
19
+ * [data-row-key="workspace:<id>"] Workspace header
20
+ * [data-row-key="session:<id>"] Session row (direct child of section)
21
+ * [data-row-key="overflow:<id>"] the "show more" row
22
+ * Those attributes are part of the official DOM contract (the React row
23
+ * keys); hashed CSS-module class names are not used.
24
+ */
25
+
26
+ const MARK = "dsh-session-tree";
27
+ const DEBUG = false;
28
+ const STORAGE_KEY = "dsh-session-tree.state.v1";
29
+ const HOST_CHANNEL = "/session-tree";
30
+ const BROADCAST_NAME = "dsh-session-tree";
31
+ const HOST_SAVE_DELAY = 250;
32
+ const GROUP_MIME = "application/x-dsh-session-tree-group";
33
+ const UNGROUPED = "__ungrouped__";
34
+ const INDENT_BASE = 12;
35
+ const INDENT_STEP = 14;
36
+ const OVERFLOW_ORDER = 900001;
37
+
38
+ const ICONS = {
39
+ chevron:
40
+ '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>',
41
+ folder:
42
+ '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.2 4.6c0-.7.5-1.2 1.2-1.2h2.1l1.2 1.4h4.9c.7 0 1.2.5 1.2 1.2v6.2c0 .7-.5 1.2-1.2 1.2H3.4c-.7 0-1.2-.5-1.2-1.2V4.6Z"/></svg>',
43
+ plus:
44
+ '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg>',
45
+ pencil:
46
+ '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l.9-3.1 6.7-6.7 2.2 2.2-6.7 6.7L3 13Z"/><path d="M9.8 4l2.2 2.2"/></svg>',
47
+ trash:
48
+ '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 4.5h9M6.2 4.5V3h3.6v1.5M5 4.5l.5 8h5l.5-8"/></svg>',
49
+ };
50
+
51
+ const STRINGS = {
52
+ zh: {
53
+ newGroup: "新建分组",
54
+ newSubgroup: "新建子分组",
55
+ rename: "重命名",
56
+ deleteGroup: "删除分组",
57
+ deleteTitle: "删除分组",
58
+ deleteMessage: "删除「{name}」后,其中的会话和子分组会移到上一级,不会被删除。",
59
+ ungrouped: "未分组",
60
+ moveSession: "移动会话",
61
+ noGroups: "暂无分组",
62
+ orderSessions: "排序",
63
+ orderGroups: "分组排序",
64
+ orderByName: "按名称排序",
65
+ orderTop: "移到最前",
66
+ orderUp: "上移",
67
+ orderDown: "下移",
68
+ orderBottom: "移到最后",
69
+ orderReset: "恢复默认顺序",
70
+ removeFromGroup: "移出分组",
71
+ newGroupAndMove: "新建分组并移入",
72
+ showAll: "显示全部会话",
73
+ expandAllGroups: "展开全部分组",
74
+ collapseAllGroups: "折叠全部分组",
75
+ nameLabel: "分组名称",
76
+ namePlaceholder: "例如:需求分析",
77
+ create: "创建",
78
+ cancel: "取消",
79
+ confirm: "确定",
80
+ delete: "删除",
81
+ expand: "展开",
82
+ collapse: "折叠",
83
+ groupDefault: "新分组",
84
+ shownAll: "已展开全部会话",
85
+ nameRequired: "请输入分组名称",
86
+ moveTop: "移到顶层",
87
+ emptyGroup: "空分组",
88
+ groupMoved: "分组已移动",
89
+ exportData: "导出分组数据",
90
+ importData: "导入分组数据",
91
+ exported: "分组数据已导出",
92
+ importTitle: "导入分组数据",
93
+ importMessage: "将用导入文件替换当前分组:{groups} 个分组、{sessions} 个已归类会话。此操作不会删除任何会话。",
94
+ importConfirm: "替换",
95
+ imported: "分组数据已导入",
96
+ importFailed: "导入失败:文件不是有效的分组数据",
97
+ exportFailed: "导出失败",
98
+ importEmpty: "文件中没有可导入的分组",
99
+ saveFailed: "分组数据保存失败,已保留在浏览器本地存储",
100
+ externalUpdate: "已同步另一个窗口的分组更新",
101
+ },
102
+ en: {
103
+ newGroup: "New group",
104
+ newSubgroup: "New subgroup",
105
+ rename: "Rename",
106
+ deleteGroup: "Delete group",
107
+ deleteTitle: "Delete group",
108
+ deleteMessage: "Deleting “{name}” moves its sessions and subgroups up one level. Nothing is deleted.",
109
+ ungrouped: "Ungrouped",
110
+ moveSession: "Move session",
111
+ noGroups: "No groups yet",
112
+ orderSessions: "Order",
113
+ orderGroups: "Order groups",
114
+ orderByName: "Sort by name",
115
+ orderTop: "Move to first",
116
+ orderUp: "Move up",
117
+ orderDown: "Move down",
118
+ orderBottom: "Move to last",
119
+ orderReset: "Reset order",
120
+ removeFromGroup: "Remove from group",
121
+ newGroupAndMove: "New group and move here",
122
+ showAll: "Show all sessions",
123
+ expandAllGroups: "Expand all groups",
124
+ collapseAllGroups: "Collapse all groups",
125
+ nameLabel: "Group name",
126
+ namePlaceholder: "e.g. Requirements",
127
+ create: "Create",
128
+ cancel: "Cancel",
129
+ confirm: "Confirm",
130
+ delete: "Delete",
131
+ expand: "Expand",
132
+ collapse: "Collapse",
133
+ groupDefault: "New group",
134
+ shownAll: "All sessions are shown",
135
+ nameRequired: "Enter a group name",
136
+ moveTop: "Move to top level",
137
+ emptyGroup: "Empty group",
138
+ groupMoved: "Group moved",
139
+ exportData: "Export group data",
140
+ importData: "Import group data",
141
+ exported: "Group data exported",
142
+ importTitle: "Import group data",
143
+ importMessage: "Replaces the current groups with the imported file: {groups} groups and {sessions} grouped sessions. No session is ever deleted.",
144
+ importConfirm: "Replace",
145
+ imported: "Group data imported",
146
+ importFailed: "Import failed: the file is not valid group data",
147
+ exportFailed: "Export failed",
148
+ importEmpty: "The file contains no groups to import",
149
+ saveFailed: "Could not save group data; it is still kept in browser storage",
150
+ externalUpdate: "Synced group changes from another window",
151
+ },
152
+ };
153
+
154
+ const CSS = `
155
+ [data-dsh-st-section] { display: flex !important; flex-direction: column; }
156
+ [data-dsh-st-container] { display: contents; }
157
+ .dsh-st-groups-hidden { display: none !important; }
158
+ .dsh-st-group {
159
+ box-sizing: border-box;
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 6px;
163
+ min-height: 28px;
164
+ margin-top: 2px;
165
+ padding: 2px 8px 2px calc(var(--dsh-sidebar-row-padding, 12px) - var(--dsh-sidebar-paint-inset, 3px) + var(--dsh-workspace-indent, 0px) + var(--dsh-st-indent, 0px));
166
+ border: 0;
167
+ border-inline: var(--dsh-sidebar-paint-inset, 3px) solid transparent;
168
+ border-radius: var(--dsw-radius-md, 8px);
169
+ background: transparent;
170
+ color: var(--dsw-alias-label-secondary, #5f6368);
171
+ font: 12px/16px var(--dsw-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
172
+ text-align: start;
173
+ cursor: pointer;
174
+ user-select: none;
175
+ -webkit-user-select: none;
176
+ }
177
+ @media (hover: hover) {
178
+ .dsh-st-group:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04)); }
179
+ }
180
+ .dsh-st-group:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: -2px; }
181
+ .dsh-st-group.dsh-st-drop { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #2563eb) 14%, transparent); box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary, #2563eb); }
182
+ .dsh-st-group.dsh-st-editing { cursor: default; }
183
+ .dsh-st-chevron { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; color: var(--dsw-alias-label-tertiary, #8a8f98); transition: transform 0.15s var(--ds-ease-in-out, ease); }
184
+ .dsh-st-group[aria-expanded="true"] .dsh-st-chevron { transform: rotate(90deg); }
185
+ .dsh-st-folder { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; color: var(--dsw-alias-label-tertiary, #8a8f98); }
186
+ .dsh-st-group[data-dsh-st-virtual="1"] .dsh-st-folder { opacity: 0.5; }
187
+ .dsh-st-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188
+ .dsh-st-count { flex: none; color: var(--dsw-alias-label-tertiary, #8a8f98); font-size: 10px; line-height: 16px; }
189
+ .dsh-st-actions { flex: none; display: none; align-items: center; gap: 2px; }
190
+ @media (hover: hover) {
191
+ .dsh-st-group:hover .dsh-st-actions { display: inline-flex; }
192
+ }
193
+ .dsh-st-group:focus-within .dsh-st-actions,
194
+ .dsh-st-group.dsh-st-editing .dsh-st-actions { display: inline-flex; }
195
+ .dsh-st-group[data-dsh-st-virtual="1"] .dsh-st-actions { display: none !important; }
196
+ .dsh-st-action {
197
+ box-sizing: border-box;
198
+ width: 20px;
199
+ height: 20px;
200
+ padding: 0;
201
+ border: 0;
202
+ border-radius: 4px;
203
+ background: transparent;
204
+ color: var(--dsw-alias-label-tertiary, #8a8f98);
205
+ cursor: pointer;
206
+ display: inline-flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ }
210
+ .dsh-st-action:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06)); color: var(--dsw-alias-label-secondary, #5f6368); }
211
+ .dsh-st-action.dsh-st-danger:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #dc2626) 12%, transparent); color: var(--dsw-alias-state-error-primary, #dc2626); }
212
+ .dsh-st-input {
213
+ box-sizing: border-box;
214
+ width: 100%;
215
+ min-width: 40px;
216
+ padding: 0 3px;
217
+ border: 0.5px solid var(--dsw-alias-border-l4, #c9ccd1);
218
+ border-radius: var(--dsw-radius-sm, 4px);
219
+ background: var(--dsw-alias-button-elevated-fill, #fff);
220
+ color: inherit;
221
+ font: inherit;
222
+ line-height: 18px;
223
+ outline: none;
224
+ }
225
+ .dsh-st-input:focus { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
226
+ [data-dsh-st-depth] {
227
+ padding-inline-start: calc(var(--dsh-sidebar-row-padding, 12px) - var(--dsh-sidebar-paint-inset, 3px) + var(--dsh-workspace-indent, 0px) + var(--dsh-st-indent, 0px)) !important;
228
+ }
229
+ [data-dsh-st-hidden="1"] { display: none !important; }
230
+ .dsh-st-backdrop {
231
+ position: fixed;
232
+ inset: 0;
233
+ z-index: 2147483600;
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ padding: 24px;
238
+ background: rgba(15, 18, 22, 0.3);
239
+ animation: dsh-st-fade 0.12s ease-out;
240
+ }
241
+ .dsh-st-dialog {
242
+ box-sizing: border-box;
243
+ width: min(380px, calc(100vw - 48px));
244
+ padding: 18px;
245
+ border-radius: 12px;
246
+ background: var(--dsw-alias-bg-elevated, #fff);
247
+ color: var(--dsw-alias-label-primary, #1f2328);
248
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.24);
249
+ font: 13px/1.55 var(--dsw-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
250
+ }
251
+ .dsh-st-dialog-title { margin: 0 0 8px; font-size: 14px; font-weight: 600; }
252
+ .dsh-st-dialog-message { margin: 0 0 14px; color: var(--dsw-alias-label-secondary, #5f6368); }
253
+ .dsh-st-dialog-label { display: block; margin-bottom: 6px; color: var(--dsw-alias-label-secondary, #5f6368); font-size: 12px; }
254
+ .dsh-st-dialog-field {
255
+ box-sizing: border-box;
256
+ width: 100%;
257
+ height: 32px;
258
+ padding: 0 8px;
259
+ border: 0.5px solid var(--dsw-alias-border-l4, #c9ccd1);
260
+ border-radius: 6px;
261
+ background: var(--dsw-alias-button-elevated-fill, #fff);
262
+ color: var(--dsw-alias-label-primary, #1f2328);
263
+ font: inherit;
264
+ outline: none;
265
+ }
266
+ .dsh-st-dialog-field:focus { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
267
+ .dsh-st-dialog-error { min-height: 18px; margin-top: 6px; color: var(--dsw-alias-state-error-primary, #dc2626); font-size: 12px; }
268
+ .dsh-st-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
269
+ .dsh-st-btn {
270
+ height: 30px;
271
+ padding: 0 12px;
272
+ border: 0.5px solid var(--dsw-alias-border-l4, #c9ccd1);
273
+ border-radius: 6px;
274
+ background: var(--dsw-alias-button-elevated-fill, #fff);
275
+ color: var(--dsw-alias-label-primary, #1f2328);
276
+ font: inherit;
277
+ cursor: pointer;
278
+ }
279
+ .dsh-st-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04)); }
280
+ .dsh-st-btn-primary { border-color: transparent; background: var(--dsw-alias-state-business-primary, #2563eb); color: #fff; }
281
+ .dsh-st-btn-primary:hover { background: var(--dsw-alias-state-business-primary, #2563eb); filter: brightness(1.05); }
282
+ .dsh-st-btn-danger { border-color: transparent; background: var(--dsw-alias-state-error-primary, #dc2626); color: #fff; }
283
+ .dsh-st-btn:disabled { opacity: 0.45; cursor: not-allowed; }
284
+ .dsh-st-toast {
285
+ position: fixed;
286
+ left: 50%;
287
+ bottom: 28px;
288
+ z-index: 2147483700;
289
+ transform: translateX(-50%);
290
+ max-width: min(420px, calc(100vw - 48px));
291
+ padding: 8px 14px;
292
+ border-radius: 8px;
293
+ background: rgba(24, 28, 33, 0.9);
294
+ color: #fff;
295
+ font: 12px/18px var(--dsw-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
296
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
297
+ animation: dsh-st-fade 0.14s ease-out;
298
+ }
299
+ @keyframes dsh-st-fade { from { opacity: 0; } to { opacity: 1; } }
300
+ @media (prefers-reduced-motion: reduce) {
301
+ .dsh-st-chevron, .dsh-st-backdrop, .dsh-st-toast { transition: none; animation: none; }
302
+ }
303
+ `;
304
+
305
+ // ---------------------------------------------------------------------
306
+ // Runtime state
307
+ // ---------------------------------------------------------------------
308
+
309
+ /** @type {{ version: number, workspaces: Record<string, WorkspaceState> }} */
310
+ let state = emptyState();
311
+ let ctxRef = null;
312
+ let observer = null;
313
+ let styleEl = null;
314
+ let scheduled = false;
315
+ let applying = false;
316
+ let disposed = false;
317
+ let storageHandler = null;
318
+ let editing = null;
319
+ let toastEl = null;
320
+ let dialogEls = [];
321
+ let hostRpc = null;
322
+ let hostMode = false;
323
+ let hostSaveTimer = null;
324
+ let hostSaving = false;
325
+ let hostSaveQueued = false;
326
+ let hostSaveWarned = false;
327
+ let hostLoaded = false;
328
+ let hostRevision = 0;
329
+ let dirty = false;
330
+ const expandedAttempted = new Set();
331
+ const expanding = new Set();
332
+ const sectionUis = new Map();
333
+ const instance = { dispose: null };
334
+
335
+ function emptyState() {
336
+ return { version: 1, revision: 0, workspaces: {} };
337
+ }
338
+
339
+ function uid(prefix) {
340
+ return prefix + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 7);
341
+ }
342
+
343
+ function isZh() {
344
+ const lang = (document.documentElement.lang || navigator.language || "").toLowerCase();
345
+ return lang.startsWith("zh");
346
+ }
347
+
348
+ function t(key, vars) {
349
+ const table = isZh() ? STRINGS.zh : STRINGS.en;
350
+ let text = table[key] ?? STRINGS.en[key] ?? key;
351
+ if (vars) {
352
+ for (const [name, value] of Object.entries(vars)) text = text.split("{" + name + "}").join(String(value));
353
+ }
354
+ return text;
355
+ }
356
+
357
+ function icon(name) {
358
+ const span = document.createElement("span");
359
+ span.className = "dsh-st-icon";
360
+ span.innerHTML = ICONS[name] || "";
361
+ return span;
362
+ }
363
+
364
+ function delay(ms) {
365
+ return new Promise((resolve) => setTimeout(resolve, ms));
366
+ }
367
+
368
+ let debugEl = null;
369
+ function debug(text, isError) {
370
+ if (!DEBUG || typeof document === "undefined" || !document.body) return;
371
+ if (!debugEl || !debugEl.isConnected) {
372
+ debugEl = document.createElement("div");
373
+ debugEl.id = MARK + "-debug";
374
+ debugEl.style.cssText =
375
+ "position:fixed;right:10px;bottom:10px;z-index:2147483750;" +
376
+ "max-width:900px;padding:4px 8px;border-radius:6px;" +
377
+ "font:11px/16px ui-monospace,Consolas,monospace;color:#fff;" +
378
+ "background:rgba(24,28,33,.9);pointer-events:none;white-space:pre-wrap";
379
+ document.body.append(debugEl);
380
+ }
381
+ debugEl.textContent = String(text);
382
+ debugEl.style.background = isError ? "rgba(180,28,28,.94)" : "rgba(24,28,33,.9)";
383
+ }
384
+
385
+ function normalizePath(value) {
386
+ return String(value || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
387
+ }
388
+
389
+ function getService(name) {
390
+ try {
391
+ if (!ctxRef) return undefined;
392
+ if (typeof ctxRef.get === "function") {
393
+ const value = ctxRef.get(name);
394
+ if (value !== undefined && value !== null) return value;
395
+ }
396
+ return ctxRef[name];
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ }
401
+
402
+ // ---------------------------------------------------------------------
403
+ // Persistence
404
+ // ---------------------------------------------------------------------
405
+
406
+ function loadState() {
407
+ try {
408
+ const raw = localStorage.getItem(STORAGE_KEY);
409
+ if (!raw) return emptyState();
410
+ return sanitizeState(JSON.parse(raw));
411
+ } catch (error) {
412
+ console.warn("[" + MARK + "] could not read saved groups", error);
413
+ return emptyState();
414
+ }
415
+ }
416
+
417
+ function writeLocalCache() {
418
+ try {
419
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
420
+ } catch (error) {
421
+ console.warn("[" + MARK + "] could not save groups", error);
422
+ }
423
+ }
424
+
425
+ function saveState(force) {
426
+ // Another window may be ahead of this one; never overwrite a newer
427
+ // revision with stale in-memory groups. Import/reset pass `true`
428
+ // because replacing everything is the explicit intent.
429
+ const stored = loadState();
430
+ const storedRevision = Number.isFinite(stored.revision) ? stored.revision : 0;
431
+ const localRevision = Number.isFinite(state.revision) ? state.revision : 0;
432
+ if (!force && storedRevision > localRevision) {
433
+ state = stored;
434
+ scheduleApply();
435
+ toast(t("externalUpdate"));
436
+ return;
437
+ }
438
+ dirty = true;
439
+ const base = Math.max(localRevision, hostRevision, storedRevision);
440
+ state.revision = base + 1;
441
+ writeLocalCache();
442
+ if (hostMode) scheduleHostSave();
443
+ }
444
+
445
+ function hasGroupData(value) {
446
+ if (!value || value.version !== 1 || !value.workspaces || typeof value.workspaces !== "object") {
447
+ return false;
448
+ }
449
+ return Object.values(value.workspaces).some(
450
+ (workspace) => workspace && Array.isArray(workspace.groups) && workspace.groups.length > 0,
451
+ );
452
+ }
453
+
454
+ function scheduleHostSave() {
455
+ if (!hostRpc) return;
456
+ if (hostSaveTimer !== null) clearTimeout(hostSaveTimer);
457
+ hostSaveTimer = setTimeout(() => {
458
+ hostSaveTimer = null;
459
+ void flushHostSave();
460
+ }, HOST_SAVE_DELAY);
461
+ }
462
+
463
+ async function flushHostSave() {
464
+ if (!hostRpc) return;
465
+ if (hostSaving) {
466
+ hostSaveQueued = true;
467
+ return;
468
+ }
469
+ hostSaving = true;
470
+ const snapshot = state;
471
+ const snapshotRevision = Number.isFinite(snapshot.revision) ? snapshot.revision : 0;
472
+ try {
473
+ const result = await hostRpc.call(HOST_CHANNEL, "save", { state: snapshot });
474
+ if (!result || result.ok !== true) {
475
+ throw new Error(result && result.error && result.error.message ? result.error.message : "save failed");
476
+ }
477
+ hostRevision = Math.max(hostRevision, snapshotRevision);
478
+ } catch (error) {
479
+ console.warn("[" + MARK + "] could not save groups to disk", error);
480
+ if (!hostSaveWarned) {
481
+ hostSaveWarned = true;
482
+ toast(t("saveFailed"));
483
+ }
484
+ } finally {
485
+ hostSaving = false;
486
+ if (hostSaveQueued) {
487
+ hostSaveQueued = false;
488
+ scheduleHostSave();
489
+ }
490
+ }
491
+ }
492
+
493
+ async function hydrateFromHost(rpc) {
494
+ hostRpc = rpc;
495
+ try {
496
+ const result = await rpc.call(HOST_CHANNEL, "load", {});
497
+ if (!result || result.ok !== true) {
498
+ throw new Error(result && result.error && result.error.message ? result.error.message : "load failed");
499
+ }
500
+ hostMode = true;
501
+ hostLoaded = true;
502
+ const remote = result.value && result.value.state;
503
+ const localRevision = Number.isFinite(state.revision) ? state.revision : 0;
504
+ const remoteRevision = remote && Number.isFinite(remote.revision) ? remote.revision : 0;
505
+ hostRevision = remoteRevision;
506
+
507
+ if (dirty || localRevision > remoteRevision) {
508
+ // The browser has edits the file has not seen (including a reset to
509
+ // an empty tree); push them instead of adopting the older file.
510
+ scheduleHostSave();
511
+ } else if (hasGroupData(remote)) {
512
+ state = sanitizeState(remote);
513
+ writeLocalCache();
514
+ scheduleApply();
515
+ } else if (hasGroupData(state)) {
516
+ // First run after upgrading: move the browser's tree onto disk.
517
+ scheduleHostSave();
518
+ }
519
+ } catch (error) {
520
+ hostMode = false;
521
+ hostLoaded = false;
522
+ console.warn("[" + MARK + "] host storage unavailable, staying on localStorage", error);
523
+ }
524
+ }
525
+
526
+ function countState(value) {
527
+ let groups = 0;
528
+ let sessions = 0;
529
+ for (const workspace of Object.values(value.workspaces || {})) {
530
+ if (!workspace) continue;
531
+ groups += Array.isArray(workspace.groups) ? workspace.groups.length : 0;
532
+ sessions += workspace.assignments ? Object.keys(workspace.assignments).length : 0;
533
+ }
534
+ return { groups, sessions };
535
+ }
536
+
537
+ function exportPayload() {
538
+ return {
539
+ format: "dsh-session-tree",
540
+ version: 1,
541
+ exportedAt: new Date().toISOString(),
542
+ state,
543
+ };
544
+ }
545
+
546
+ function exportJson() {
547
+ return JSON.stringify(exportPayload(), null, 2);
548
+ }
549
+
550
+ function exportData() {
551
+ try {
552
+ const blob = new Blob([exportJson()], { type: "application/json" });
553
+ const url = URL.createObjectURL(blob);
554
+ const anchor = document.createElement("a");
555
+ anchor.href = url;
556
+ anchor.download = "dsh-session-tree-" + new Date().toISOString().replace(/[:.]/g, "-") + ".json";
557
+ document.body.append(anchor);
558
+ anchor.click();
559
+ anchor.remove();
560
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
561
+ toast(t("exported"));
562
+ } catch (error) {
563
+ console.error("[" + MARK + "] could not export groups", error);
564
+ toast(t("exportFailed"));
565
+ }
566
+ }
567
+
568
+ function importDataFromText(text) {
569
+ let parsed;
570
+ try {
571
+ parsed = JSON.parse(text);
572
+ } catch {
573
+ toast(t("importFailed"));
574
+ return;
575
+ }
576
+ const candidate = parsed && typeof parsed === "object" && parsed.state ? parsed.state : parsed;
577
+ const next = sanitizeState(candidate);
578
+ if (!hasGroupData(next)) {
579
+ toast(t("importEmpty"));
580
+ return;
581
+ }
582
+ openConfirm({
583
+ title: t("importTitle"),
584
+ message: t("importMessage", countState(next)),
585
+ confirmText: t("importConfirm"),
586
+ danger: false,
587
+ onConfirm: () => {
588
+ state = next;
589
+ saveState(true);
590
+ scheduleApply();
591
+ toast(t("imported"));
592
+ },
593
+ });
594
+ }
595
+
596
+ function pickImportFile() {
597
+ const input = document.createElement("input");
598
+ input.type = "file";
599
+ input.accept = "application/json,.json";
600
+ input.style.display = "none";
601
+ input.addEventListener("change", () => {
602
+ const file = input.files && input.files[0];
603
+ if (!file) {
604
+ input.remove();
605
+ return;
606
+ }
607
+ if (file.size > 4 * 1024 * 1024) {
608
+ input.remove();
609
+ toast(t("importFailed"));
610
+ return;
611
+ }
612
+ file.text().then(
613
+ (text) => {
614
+ input.remove();
615
+ importDataFromText(text);
616
+ },
617
+ (error) => {
618
+ input.remove();
619
+ console.warn("[" + MARK + "] could not read import file", error);
620
+ toast(t("importFailed"));
621
+ },
622
+ );
623
+ });
624
+ document.body.append(input);
625
+ input.click();
626
+ }
627
+
628
+ function sanitizeState(raw) {
629
+ if (!raw || typeof raw !== "object" || raw.version !== 1 || !raw.workspaces || typeof raw.workspaces !== "object") {
630
+ return emptyState();
631
+ }
632
+ const next = emptyState();
633
+ next.revision = Number.isFinite(raw.revision) && raw.revision >= 0 ? Math.floor(raw.revision) : 0;
634
+ for (const [wsId, value] of Object.entries(raw.workspaces)) {
635
+ if (!wsId || !value || typeof value !== "object") continue;
636
+ const ws = { groups: [], assignments: {}, sessionOrder: {}, ungroupedCollapsed: value.ungroupedCollapsed === true };
637
+ const ids = new Set();
638
+ for (const group of Array.isArray(value.groups) ? value.groups : []) {
639
+ if (!group || typeof group !== "object") continue;
640
+ const id = typeof group.id === "string" ? group.id : "";
641
+ if (!id || ids.has(id) || id === UNGROUPED) continue;
642
+ ids.add(id);
643
+ ws.groups.push({
644
+ id,
645
+ name: typeof group.name === "string" && group.name.trim() ? group.name.trim().slice(0, 80) : t("groupDefault"),
646
+ parentId: typeof group.parentId === "string" ? group.parentId : null,
647
+ order: Number.isFinite(group.order) ? group.order : 0,
648
+ collapsed: group.collapsed === true,
649
+ });
650
+ }
651
+ for (const group of ws.groups) {
652
+ if (group.parentId && !ids.has(group.parentId)) group.parentId = null;
653
+ }
654
+ // Break accidental parent cycles.
655
+ for (const group of ws.groups) {
656
+ const seen = new Set([group.id]);
657
+ let cursor = group.parentId;
658
+ while (cursor) {
659
+ if (seen.has(cursor)) { group.parentId = null; break; }
660
+ seen.add(cursor);
661
+ const parent = ws.groups.find((candidate) => candidate.id === cursor);
662
+ cursor = parent ? parent.parentId : null;
663
+ }
664
+ }
665
+ if (value.assignments && typeof value.assignments === "object") {
666
+ for (const [sessionId, groupId] of Object.entries(value.assignments)) {
667
+ if (typeof sessionId === "string" && typeof groupId === "string" && ids.has(groupId)) {
668
+ ws.assignments[sessionId] = groupId;
669
+ }
670
+ }
671
+ }
672
+ if (value.sessionOrder && typeof value.sessionOrder === "object") {
673
+ for (const [sessionId, order] of Object.entries(value.sessionOrder)) {
674
+ if (ws.assignments[sessionId] && Number.isFinite(order)) {
675
+ ws.sessionOrder[sessionId] = order;
676
+ }
677
+ }
678
+ }
679
+ next.workspaces[wsId] = ws;
680
+ }
681
+ return next;
682
+ }
683
+
684
+ function getWorkspace(wsId, create) {
685
+ if (!wsId) return undefined;
686
+ let ws = state.workspaces[wsId];
687
+ if (!ws && create) {
688
+ ws = { groups: [], assignments: {}, sessionOrder: {}, ungroupedCollapsed: false };
689
+ state.workspaces[wsId] = ws;
690
+ }
691
+ return ws;
692
+ }
693
+
694
+ function findGroup(ws, groupId) {
695
+ if (!ws || !groupId) return undefined;
696
+ return ws.groups.find((group) => group.id === groupId);
697
+ }
698
+
699
+ function sortedChildren(ws, parentId) {
700
+ const parent = parentId ?? null;
701
+ return ws.groups
702
+ .filter((group) => (group.parentId ?? null) === parent)
703
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
704
+ }
705
+
706
+ function nextOrder(ws, parentId) {
707
+ const children = sortedChildren(ws, parentId);
708
+ let max = 0;
709
+ for (const child of children) max = Math.max(max, child.order ?? 0);
710
+ return max + 1;
711
+ }
712
+
713
+ function collectGroupIds(ws, rootId) {
714
+ const ids = new Set([rootId]);
715
+ let changed = true;
716
+ while (changed) {
717
+ changed = false;
718
+ for (const group of ws.groups) {
719
+ if (group.parentId && ids.has(group.parentId) && !ids.has(group.id)) {
720
+ ids.add(group.id);
721
+ changed = true;
722
+ }
723
+ }
724
+ }
725
+ return ids;
726
+ }
727
+
728
+ function uniqueGroupName(ws, parentId, base) {
729
+ const names = new Set(sortedChildren(ws, parentId).map((group) => group.name));
730
+ if (!names.has(base)) return base;
731
+ for (let index = 2; index < 1000; index++) {
732
+ const candidate = base + " " + index;
733
+ if (!names.has(candidate)) return candidate;
734
+ }
735
+ return base + " " + Date.now().toString(36);
736
+ }
737
+
738
+ // ---------------------------------------------------------------------
739
+ // Section discovery and decoration
740
+ // ---------------------------------------------------------------------
741
+
742
+ function workspaceHeaders() {
743
+ return Array.from(document.querySelectorAll('[data-row-key^="workspace:"]'));
744
+ }
745
+
746
+ function headerWorkspaceId(header) {
747
+ const key = header.getAttribute("data-row-key") || "";
748
+ return key.startsWith("workspace:") ? key.slice("workspace:".length) : "";
749
+ }
750
+
751
+ function sessionIdOf(row) {
752
+ const key = row.getAttribute("data-row-key") || "";
753
+ return key.startsWith("session:") ? key.slice("session:".length) : "";
754
+ }
755
+
756
+ /**
757
+ * Official rows are wrapped by a HoverCard/Tooltip span, so the flex item
758
+ * that carries `order` is the wrapper, while padding and row semantics
759
+ * stay on the row itself. When a row is a direct child, it is its own
760
+ * wrapper.
761
+ */
762
+ function wrapperOf(row) {
763
+ const parent = row.parentElement;
764
+ if (!parent) return row;
765
+ if (parent.hasAttribute("data-dsh-st-section")) return row;
766
+ return parent;
767
+ }
768
+
769
+ /**
770
+ * The Workspace section a row belongs to: the wrapper's parent, i.e. the
771
+ * element whose parent is the `role="tree"` list (or the nested
772
+ * `role="group"` container).
773
+ */
774
+ function sectionForElement(element) {
775
+ let node = element.parentElement;
776
+ if (!node) return null;
777
+ for (let depth = 0; node && depth < 6; depth++) {
778
+ const parent = node.parentElement;
779
+ if (!parent) return node;
780
+ const role = parent.getAttribute("role");
781
+ if (role === "tree" || role === "group") return node;
782
+ node = parent;
783
+ }
784
+ return element.parentElement?.parentElement || element.parentElement;
785
+ }
786
+
787
+ function sectionForHeader(header) {
788
+ return sectionForElement(header);
789
+ }
790
+
791
+ function headerInSection(section) {
792
+ return section.querySelector('[data-row-key^="workspace:"]');
793
+ }
794
+
795
+ function sessionRowsInSection(section) {
796
+ return Array.from(section.querySelectorAll('[data-row-key^="session:"]')).filter(
797
+ (row) => sectionForElement(row) === section,
798
+ );
799
+ }
800
+
801
+ function overflowInSection(section) {
802
+ return Array.from(section.querySelectorAll('[data-row-key^="overflow:"]')).find(
803
+ (row) => sectionForElement(row) === section,
804
+ );
805
+ }
806
+
807
+ function findSectionByWorkspace(wsId) {
808
+ for (const [section, ui] of sectionUis) {
809
+ if (ui.wsId === wsId && section.isConnected) return section;
810
+ }
811
+ for (const header of workspaceHeaders()) {
812
+ if (headerWorkspaceId(header) === wsId) return sectionForHeader(header);
813
+ }
814
+ return null;
815
+ }
816
+
817
+ function ensureSectionUi(section, wsId) {
818
+ let ui = sectionUis.get(section);
819
+ const header = headerInSection(section);
820
+ if (ui && (!section.contains(ui.container) || ui.header !== header)) {
821
+ for (const unsubscribe of ui.listeners) {
822
+ try {
823
+ unsubscribe();
824
+ } catch {
825
+ /* ignore */
826
+ }
827
+ }
828
+ ui.container.remove();
829
+ ui = undefined;
830
+ }
831
+ if (ui) {
832
+ ui.wsId = wsId;
833
+ return ui;
834
+ }
835
+ if (!section.hasAttribute("data-dsh-st-section")) {
836
+ section.setAttribute("data-dsh-st-section", "");
837
+ }
838
+ section.setAttribute("data-dsh-st-ws", wsId);
839
+
840
+ const container = document.createElement("div");
841
+ container.setAttribute("data-dsh-st-container", "");
842
+ container.className = "dsh-st-groups";
843
+ section.append(container);
844
+
845
+ ui = { wsId, section, header, container, headers: new Map(), listeners: [] };
846
+ sectionUis.set(section, ui);
847
+ installWorkspaceDropTarget(ui);
848
+ return ui;
849
+ }
850
+
851
+ function installWorkspaceDropTarget(ui) {
852
+ const header = headerInSection(ui.section);
853
+ if (!header) return;
854
+ const hasGroup = (event) => {
855
+ try {
856
+ return Array.from(event.dataTransfer?.types || []).includes(GROUP_MIME);
857
+ } catch {
858
+ return false;
859
+ }
860
+ };
861
+ const over = (event) => {
862
+ if (!hasGroup(event)) return;
863
+ event.preventDefault();
864
+ event.stopImmediatePropagation();
865
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
866
+ header.classList.add("dsh-st-drop");
867
+ };
868
+ const leave = (event) => {
869
+ if (!hasGroup(event)) return;
870
+ header.classList.remove("dsh-st-drop");
871
+ };
872
+ const drop = (event) => {
873
+ if (!hasGroup(event)) return;
874
+ event.preventDefault();
875
+ event.stopImmediatePropagation();
876
+ header.classList.remove("dsh-st-drop");
877
+ try {
878
+ const payload = JSON.parse(event.dataTransfer.getData(GROUP_MIME) || "{}");
879
+ if (payload.workspaceId === ui.wsId && payload.groupId) {
880
+ moveGroup(ui.wsId, payload.groupId, null);
881
+ }
882
+ } catch {
883
+ /* malformed payload */
884
+ }
885
+ };
886
+ header.addEventListener("dragover", over, true);
887
+ header.addEventListener("dragleave", leave, true);
888
+ header.addEventListener("drop", drop, true);
889
+ ui.listeners.push(() => {
890
+ header.removeEventListener("dragover", over, true);
891
+ header.removeEventListener("dragleave", leave, true);
892
+ header.removeEventListener("drop", drop, true);
893
+ });
894
+ }
895
+
896
+ function resetRow(row) {
897
+ const wrapper = wrapperOf(row);
898
+ wrapper.classList.remove("dsh-st-hidden");
899
+ wrapper.style.removeProperty("order");
900
+ delete wrapper.dataset.dshStHidden;
901
+ row.classList.remove("dsh-st-hidden");
902
+ row.style.removeProperty("order");
903
+ row.style.removeProperty("--dsh-st-indent");
904
+ delete row.dataset.dshStDepth;
905
+ delete row.dataset.dshStHidden;
906
+ }
907
+
908
+ function resetSection(ui) {
909
+ if (ui.container.childElementCount > 0) ui.container.replaceChildren();
910
+ ui.headers.clear();
911
+ ui.container.style.display = "none";
912
+ for (const row of sessionRowsInSection(ui.section)) resetRow(row);
913
+ const overflow = overflowInSection(ui.section);
914
+ if (overflow) resetRow(overflow);
915
+ }
916
+
917
+ // ---------------------------------------------------------------------
918
+ // Group tree plan
919
+ // ---------------------------------------------------------------------
920
+
921
+ function sessionOrderOf(ws, sessionId, fallback) {
922
+ const order = ws && ws.sessionOrder ? ws.sessionOrder[sessionId] : undefined;
923
+ return Number.isFinite(order) ? order : fallback;
924
+ }
925
+
926
+ function buildPlan(ws, rows) {
927
+ const groups = ws ? ws.groups : [];
928
+ const assignments = ws ? ws.assignments : {};
929
+ const rowIds = rows.map(sessionIdOf);
930
+ const groupIds = new Set(groups.map((group) => group.id));
931
+ const byParent = new Map();
932
+ for (const group of groups) {
933
+ const parentId = group.parentId && groupIds.has(group.parentId) ? group.parentId : null;
934
+ const list = byParent.get(parentId) || [];
935
+ list.push(group);
936
+ byParent.set(parentId, list);
937
+ }
938
+ for (const list of byParent.values()) {
939
+ list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
940
+ }
941
+ const directCount = new Map();
942
+ for (const id of rowIds) {
943
+ const groupId = assignments[id];
944
+ if (groupId && groupIds.has(groupId)) directCount.set(groupId, (directCount.get(groupId) || 0) + 1);
945
+ }
946
+ const totals = new Map();
947
+ const totalOf = (groupId) => {
948
+ if (totals.has(groupId)) return totals.get(groupId);
949
+ let total = directCount.get(groupId) || 0;
950
+ for (const child of byParent.get(groupId) || []) total += totalOf(child.id);
951
+ totals.set(groupId, total);
952
+ return total;
953
+ };
954
+
955
+ const plan = [];
956
+ const seen = new Set();
957
+ const walk = (parentId, depth) => {
958
+ for (const group of byParent.get(parentId) || []) {
959
+ plan.push({
960
+ type: "group",
961
+ group,
962
+ depth,
963
+ collapsed: group.collapsed === true,
964
+ count: totalOf(group.id),
965
+ virtual: false,
966
+ });
967
+ if (group.collapsed === true) continue;
968
+ walk(group.id, depth + 1);
969
+ const members = [];
970
+ for (let index = 0; index < rows.length; index++) {
971
+ if (assignments[rowIds[index]] !== group.id) continue;
972
+ members.push({ row: rows[index], id: rowIds[index], index });
973
+ }
974
+ members.sort(
975
+ (a, b) => sessionOrderOf(ws, a.id, a.index) - sessionOrderOf(ws, b.id, b.index) || a.index - b.index,
976
+ );
977
+ for (const member of members) {
978
+ plan.push({ type: "session", row: member.row, id: member.id, depth: depth + 1 });
979
+ seen.add(member.id);
980
+ }
981
+ }
982
+ };
983
+ walk(null, 0);
984
+
985
+ const ungroupedMembers = [];
986
+ for (let index = 0; index < rows.length; index++) {
987
+ const groupId = assignments[rowIds[index]];
988
+ if (groupId && groupIds.has(groupId)) continue;
989
+ ungroupedMembers.push({ row: rows[index], id: rowIds[index], index });
990
+ }
991
+ ungroupedMembers.sort(
992
+ (a, b) => sessionOrderOf(ws, a.id, a.index) - sessionOrderOf(ws, b.id, b.index) || a.index - b.index,
993
+ );
994
+ const ungroupedCollapsed = ws ? ws.ungroupedCollapsed === true : false;
995
+ plan.push({
996
+ type: "group",
997
+ group: { id: UNGROUPED, name: t("ungrouped") },
998
+ depth: 0,
999
+ collapsed: ungroupedCollapsed,
1000
+ count: ungroupedMembers.length,
1001
+ virtual: true,
1002
+ });
1003
+ if (!ungroupedCollapsed) {
1004
+ for (const member of ungroupedMembers) {
1005
+ plan.push({ type: "session", row: member.row, id: member.id, depth: 1 });
1006
+ seen.add(member.id);
1007
+ }
1008
+ }
1009
+
1010
+ return { plan, seen };
1011
+ }
1012
+
1013
+ function ensureGroupHeader(ui, wsId, item) {
1014
+ const group = item.group;
1015
+ let header = ui.headers.get(group.id);
1016
+ if (header && !ui.container.contains(header)) {
1017
+ ui.headers.delete(group.id);
1018
+ header = undefined;
1019
+ }
1020
+ if (!header) {
1021
+ header = createGroupHeader(wsId, group, item.virtual);
1022
+ ui.headers.set(group.id, header);
1023
+ ui.container.append(header);
1024
+ }
1025
+
1026
+ header.style.order = String(item.order);
1027
+ header.dataset.dshStGroup = group.id;
1028
+ header.dataset.dshStWs = wsId;
1029
+ header.dataset.dshStVirtual = item.virtual ? "1" : "0";
1030
+ header.style.setProperty("--dsh-st-indent", INDENT_BASE + item.depth * INDENT_STEP + "px");
1031
+ header.setAttribute("aria-expanded", item.collapsed ? "false" : "true");
1032
+ header.title = group.name;
1033
+
1034
+ const countEl = header.querySelector(".dsh-st-count");
1035
+ if (countEl && countEl.textContent !== String(item.count)) countEl.textContent = String(item.count);
1036
+
1037
+ const nameEl = header.querySelector(".dsh-st-name");
1038
+ const isEditing = editing && editing.wsId === wsId && editing.groupId === group.id;
1039
+ if (nameEl) {
1040
+ if (isEditing) {
1041
+ header.classList.add("dsh-st-editing");
1042
+ renderRenameInput(header, nameEl, editing);
1043
+ } else {
1044
+ header.classList.remove("dsh-st-editing");
1045
+ const input = nameEl.querySelector("input");
1046
+ if (input) input.remove();
1047
+ if (nameEl.textContent !== group.name) nameEl.textContent = group.name;
1048
+ }
1049
+ }
1050
+
1051
+ const actions = header.querySelector(".dsh-st-actions");
1052
+ if (actions) actions.style.display = item.virtual ? "none" : "";
1053
+ return header;
1054
+ }
1055
+
1056
+ function createGroupHeader(wsId, group, virtual) {
1057
+ const header = document.createElement("div");
1058
+ header.className = "dsh-st-group";
1059
+ header.setAttribute("role", "treeitem");
1060
+ header.tabIndex = 0;
1061
+ header.innerHTML =
1062
+ '<span class="dsh-st-chevron" aria-hidden="true">' +
1063
+ ICONS.chevron +
1064
+ "</span>" +
1065
+ '<span class="dsh-st-folder" aria-hidden="true">' +
1066
+ ICONS.folder +
1067
+ "</span>" +
1068
+ '<span class="dsh-st-name"></span>' +
1069
+ '<span class="dsh-st-count"></span>' +
1070
+ '<span class="dsh-st-actions">' +
1071
+ '<button type="button" class="dsh-st-action" data-action="subgroup" title="' +
1072
+ escapeAttr(t("newSubgroup")) +
1073
+ '" aria-label="' +
1074
+ escapeAttr(t("newSubgroup")) +
1075
+ '">' +
1076
+ ICONS.plus +
1077
+ "</button>" +
1078
+ '<button type="button" class="dsh-st-action" data-action="rename" title="' +
1079
+ escapeAttr(t("rename")) +
1080
+ '" aria-label="' +
1081
+ escapeAttr(t("rename")) +
1082
+ '">' +
1083
+ ICONS.pencil +
1084
+ "</button>" +
1085
+ '<button type="button" class="dsh-st-action dsh-st-danger" data-action="delete" title="' +
1086
+ escapeAttr(t("deleteGroup")) +
1087
+ '" aria-label="' +
1088
+ escapeAttr(t("deleteGroup")) +
1089
+ '">' +
1090
+ ICONS.trash +
1091
+ "</button>" +
1092
+ "</span>";
1093
+
1094
+ header.addEventListener("click", (event) => {
1095
+ if (event.target.closest("button, input")) return;
1096
+ if (virtual) toggleGroup(wsId, UNGROUPED);
1097
+ else toggleGroup(wsId, group.id);
1098
+ });
1099
+ header.addEventListener("dblclick", (event) => {
1100
+ if (virtual || event.target.closest("button, input")) return;
1101
+ beginRename(wsId, group.id, false);
1102
+ });
1103
+ header.addEventListener("keydown", (event) => {
1104
+ if (event.target !== header) return;
1105
+ if (event.key === "Enter" || event.key === " ") {
1106
+ event.preventDefault();
1107
+ if (virtual) toggleGroup(wsId, UNGROUPED);
1108
+ else toggleGroup(wsId, group.id);
1109
+ } else if (event.key === "F2" && !virtual) {
1110
+ event.preventDefault();
1111
+ beginRename(wsId, group.id, false);
1112
+ }
1113
+ });
1114
+ header.addEventListener("contextmenu", (event) => {
1115
+ if (virtual) return;
1116
+ event.preventDefault();
1117
+ event.stopPropagation();
1118
+ // The action buttons are the primary surface; right-click toggles the
1119
+ // same actions via the small menu anchored at the pointer.
1120
+ openGroupMiniMenu(wsId, group.id, event.clientX, event.clientY);
1121
+ });
1122
+
1123
+ const actions = header.querySelector(".dsh-st-actions");
1124
+ actions.addEventListener("click", (event) => {
1125
+ const button = event.target.closest("button[data-action]");
1126
+ if (!button) return;
1127
+ event.stopPropagation();
1128
+ if (button.dataset.action === "subgroup") createGroup(wsId, group.id);
1129
+ else if (button.dataset.action === "rename") beginRename(wsId, group.id, false);
1130
+ else if (button.dataset.action === "delete") confirmDeleteGroup(wsId, group.id);
1131
+ });
1132
+
1133
+ if (!virtual) {
1134
+ header.draggable = true;
1135
+ header.addEventListener("dragstart", (event) => {
1136
+ if (editing && editing.groupId === group.id) {
1137
+ event.preventDefault();
1138
+ return;
1139
+ }
1140
+ event.stopPropagation();
1141
+ event.dataTransfer.effectAllowed = "move";
1142
+ event.dataTransfer.setData(GROUP_MIME, JSON.stringify({ workspaceId: wsId, groupId: group.id }));
1143
+ event.dataTransfer.setData("text/plain", "dsh-session-tree-group:" + group.id);
1144
+ });
1145
+ }
1146
+
1147
+ header.addEventListener("dragover", (event) => {
1148
+ let types = [];
1149
+ try {
1150
+ types = Array.from(event.dataTransfer?.types || []);
1151
+ } catch {
1152
+ return;
1153
+ }
1154
+ const isGroup = types.includes(GROUP_MIME);
1155
+ const isSession = types.includes("text/plain");
1156
+ if (!isGroup && !isSession) return;
1157
+ event.preventDefault();
1158
+ event.stopPropagation();
1159
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
1160
+ header.classList.add("dsh-st-drop");
1161
+ });
1162
+ header.addEventListener("dragleave", (event) => {
1163
+ if (event.relatedTarget && header.contains(event.relatedTarget)) return;
1164
+ header.classList.remove("dsh-st-drop");
1165
+ });
1166
+ header.addEventListener("drop", (event) => {
1167
+ let types = [];
1168
+ try {
1169
+ types = Array.from(event.dataTransfer?.types || []);
1170
+ } catch {
1171
+ return;
1172
+ }
1173
+ const isGroup = types.includes(GROUP_MIME);
1174
+ const isSession = types.includes("text/plain");
1175
+ if (!isGroup && !isSession) return;
1176
+ event.preventDefault();
1177
+ event.stopPropagation();
1178
+ header.classList.remove("dsh-st-drop");
1179
+ if (isGroup) {
1180
+ try {
1181
+ const payload = JSON.parse(event.dataTransfer.getData(GROUP_MIME) || "{}");
1182
+ if (payload.workspaceId === wsId && payload.groupId && !virtual) {
1183
+ moveGroup(wsId, payload.groupId, group.id);
1184
+ }
1185
+ } catch {
1186
+ /* malformed payload */
1187
+ }
1188
+ return;
1189
+ }
1190
+ const sessionId = event.dataTransfer.getData("text/plain");
1191
+ if (!sessionId || !sessionId.startsWith("session-")) return;
1192
+ const sessionWorkspace = workspaceForSession(sessionId);
1193
+ if (sessionWorkspace && sessionWorkspace !== wsId) return;
1194
+ if (virtual) moveSession(wsId, sessionId, null);
1195
+ else moveSession(wsId, sessionId, group.id);
1196
+ });
1197
+
1198
+ return header;
1199
+ }
1200
+
1201
+ function renderRenameInput(header, nameEl, draft) {
1202
+ let input = nameEl.querySelector("input");
1203
+ if (!input) {
1204
+ nameEl.textContent = "";
1205
+ input = document.createElement("input");
1206
+ input.className = "dsh-st-input";
1207
+ input.type = "text";
1208
+ input.maxLength = 80;
1209
+ input.value = draft.draft;
1210
+ input.setAttribute("aria-label", t("nameLabel"));
1211
+ input.addEventListener("input", () => {
1212
+ draft.draft = input.value;
1213
+ });
1214
+ input.addEventListener("click", (event) => event.stopPropagation());
1215
+ input.addEventListener("dblclick", (event) => event.stopPropagation());
1216
+ input.addEventListener("keydown", (event) => {
1217
+ event.stopPropagation();
1218
+ if (event.key === "Enter") {
1219
+ event.preventDefault();
1220
+ commitRename();
1221
+ } else if (event.key === "Escape") {
1222
+ event.preventDefault();
1223
+ cancelRename();
1224
+ }
1225
+ });
1226
+ input.addEventListener("blur", () => {
1227
+ if (editing === draft) commitRename();
1228
+ });
1229
+ nameEl.append(input);
1230
+ requestAnimationFrame(() => {
1231
+ if (input.isConnected) {
1232
+ input.focus();
1233
+ input.select();
1234
+ }
1235
+ });
1236
+ } else if (input.value !== draft.draft) {
1237
+ input.value = draft.draft;
1238
+ }
1239
+ }
1240
+
1241
+ function escapeAttr(value) {
1242
+ return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1243
+ }
1244
+
1245
+ // ---------------------------------------------------------------------
1246
+ // Apply layout
1247
+ // ---------------------------------------------------------------------
1248
+
1249
+ function scheduleApply() {
1250
+ if (disposed || scheduled) return;
1251
+ scheduled = true;
1252
+ const run = () => {
1253
+ scheduled = false;
1254
+ applyNow();
1255
+ };
1256
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
1257
+ else setTimeout(run, 16);
1258
+ }
1259
+
1260
+ function applyNow() {
1261
+ if (disposed || applying) return;
1262
+ applying = true;
1263
+ try {
1264
+ const live = new Set();
1265
+ for (const header of workspaceHeaders()) {
1266
+ const section = sectionForHeader(header);
1267
+ if (!section) continue;
1268
+ const wsId = headerWorkspaceId(header);
1269
+ if (!wsId) continue;
1270
+ live.add(section);
1271
+ decorateSection(header, section, wsId);
1272
+ }
1273
+ for (const [section, ui] of Array.from(sectionUis.entries())) {
1274
+ if (live.has(section) && section.isConnected) continue;
1275
+ disposeSectionUi(section, ui);
1276
+ }
1277
+ if (editing) {
1278
+ const ui = sectionUis.get(document.querySelector('[data-dsh-st-ws="' + cssEscape(editing.wsId) + '"]'));
1279
+ const header = ui && ui.headers.get(editing.groupId);
1280
+ if (!header || !header.isConnected) editing = null;
1281
+ }
1282
+ } finally {
1283
+ applying = false;
1284
+ }
1285
+ }
1286
+
1287
+ function decorateSection(header, section, wsId) {
1288
+ const expanded = header.getAttribute("aria-expanded") !== "false";
1289
+ const rows = sessionRowsInSection(section);
1290
+ const ui = ensureSectionUi(section, wsId);
1291
+ const ws = getWorkspace(wsId, false);
1292
+ const hasGroups = !!ws && ws.groups.length > 0;
1293
+ const headerWrapper = wrapperOf(header);
1294
+
1295
+ headerWrapper.style.order = "0";
1296
+ for (const child of Array.from(section.children)) {
1297
+ if (child === headerWrapper || child === ui.container) continue;
1298
+ if (child.getAttribute("role") === "group") {
1299
+ child.style.order = "1";
1300
+ continue;
1301
+ }
1302
+ if (child.querySelector?.('[data-row-key^="overflow:"]')) {
1303
+ child.style.order = String(OVERFLOW_ORDER);
1304
+ continue;
1305
+ }
1306
+ if (child.querySelector?.('[data-row-key^="session:"]')) continue;
1307
+ child.style.order = "5";
1308
+ }
1309
+
1310
+ if (!expanded) {
1311
+ resetSection(ui);
1312
+ return;
1313
+ }
1314
+
1315
+ ui.container.style.display = "contents";
1316
+
1317
+ if (!hasGroups) {
1318
+ if (ui.container.childElementCount > 0) ui.container.replaceChildren();
1319
+ ui.headers.clear();
1320
+ for (const row of rows) resetRow(row);
1321
+ const overflow = overflowInSection(section);
1322
+ if (overflow) wrapperOf(overflow).style.order = String(OVERFLOW_ORDER);
1323
+ return;
1324
+ }
1325
+
1326
+ const { plan, seen } = buildPlan(ws, rows);
1327
+ let order = 10;
1328
+ for (const item of plan) {
1329
+ item.order = order++;
1330
+ if (item.type === "group") {
1331
+ ensureGroupHeader(ui, wsId, item);
1332
+ } else {
1333
+ const row = item.row;
1334
+ const wrapper = wrapperOf(row);
1335
+ wrapper.style.order = String(item.order);
1336
+ wrapper.classList.remove("dsh-st-hidden");
1337
+ delete wrapper.dataset.dshStHidden;
1338
+ row.dataset.dshStDepth = String(item.depth);
1339
+ row.style.setProperty("--dsh-st-indent", INDENT_BASE + item.depth * INDENT_STEP + "px");
1340
+ row.classList.remove("dsh-st-hidden");
1341
+ delete row.dataset.dshStHidden;
1342
+ }
1343
+ }
1344
+
1345
+ const planGroupIds = new Set(plan.filter((item) => item.type === "group").map((item) => item.group.id));
1346
+ for (const [groupId, groupHeader] of Array.from(ui.headers.entries())) {
1347
+ if (planGroupIds.has(groupId)) continue;
1348
+ groupHeader.remove();
1349
+ ui.headers.delete(groupId);
1350
+ }
1351
+
1352
+ for (const row of rows) {
1353
+ const id = sessionIdOf(row);
1354
+ if (!id || seen.has(id)) continue;
1355
+ const wrapper = wrapperOf(row);
1356
+ wrapper.classList.add("dsh-st-hidden");
1357
+ wrapper.dataset.dshStHidden = "1";
1358
+ }
1359
+
1360
+ const overflow = overflowInSection(section);
1361
+ if (overflow) {
1362
+ wrapperOf(overflow).style.order = String(OVERFLOW_ORDER);
1363
+ // The native "show more" row often appears after our first attempt;
1364
+ // retry whenever a grouped workspace still has hidden sessions.
1365
+ maybeExpandAll(wsId);
1366
+ }
1367
+ }
1368
+
1369
+ function disposeSectionUi(section, ui) {
1370
+ for (const unsubscribe of ui.listeners) {
1371
+ try {
1372
+ unsubscribe();
1373
+ } catch {
1374
+ /* ignore */
1375
+ }
1376
+ }
1377
+ ui.container.remove();
1378
+ for (const row of sessionRowsInSection(section)) resetRow(row);
1379
+ const overflow = overflowInSection(section);
1380
+ if (overflow) resetRow(overflow);
1381
+ const header = headerInSection(section);
1382
+ if (header) {
1383
+ wrapperOf(header).style.removeProperty("order");
1384
+ header.style.removeProperty("order");
1385
+ }
1386
+ for (const child of Array.from(section.children)) {
1387
+ if (child === header) continue;
1388
+ if (child.getAttribute("role") === "group") child.style.removeProperty("order");
1389
+ }
1390
+ section.removeAttribute("data-dsh-st-section");
1391
+ section.removeAttribute("data-dsh-st-ws");
1392
+ sectionUis.delete(section);
1393
+ if (editing && editing.wsId === ui.wsId) editing = null;
1394
+ }
1395
+
1396
+ // ---------------------------------------------------------------------
1397
+ // Group mutations
1398
+ // ---------------------------------------------------------------------
1399
+
1400
+ function createGroup(wsId, parentId, options) {
1401
+ const opts = options || {};
1402
+ const ws = getWorkspace(wsId, true);
1403
+ if (!ws) return;
1404
+ ensureWorkspaceExpanded(wsId);
1405
+ let parent = parentId ? findGroup(ws, parentId) : undefined;
1406
+ if (parentId && !parent) parentId = null;
1407
+ const name = uniqueGroupName(ws, parentId ?? null, t("groupDefault"));
1408
+ const group = {
1409
+ id: uid("g"),
1410
+ name,
1411
+ parentId: parentId ?? null,
1412
+ order: nextOrder(ws, parentId ?? null),
1413
+ collapsed: false,
1414
+ };
1415
+ ws.groups.push(group);
1416
+ if (parent) parent.collapsed = false;
1417
+ if (opts.assignSessionId) {
1418
+ ws.assignments[opts.assignSessionId] = group.id;
1419
+ ws.sessionOrder = ws.sessionOrder || {};
1420
+ ws.sessionOrder[opts.assignSessionId] = nextSessionOrder(ws, group.id);
1421
+ }
1422
+ saveState();
1423
+ editing = { wsId, groupId: group.id, draft: name, isNew: true };
1424
+ scheduleApply();
1425
+ if (!opts.silent) maybeExpandAll(wsId);
1426
+ return group;
1427
+ }
1428
+
1429
+ function beginRename(wsId, groupId, isNew) {
1430
+ const ws = getWorkspace(wsId, false);
1431
+ const group = findGroup(ws, groupId);
1432
+ if (!group) return;
1433
+ editing = { wsId, groupId, draft: group.name, isNew: !!isNew };
1434
+ scheduleApply();
1435
+ }
1436
+
1437
+ function commitRename() {
1438
+ if (!editing) return;
1439
+ const draft = editing;
1440
+ editing = null;
1441
+ const ws = getWorkspace(draft.wsId, false);
1442
+ const group = findGroup(ws, draft.groupId);
1443
+ if (!group) return;
1444
+ const name = String(draft.draft || "").trim();
1445
+ if (!name) {
1446
+ if (draft.isNew) removeGroupSilent(draft.wsId, draft.groupId);
1447
+ scheduleApply();
1448
+ return;
1449
+ }
1450
+ group.name = name.slice(0, 80);
1451
+ saveState();
1452
+ scheduleApply();
1453
+ }
1454
+
1455
+ function cancelRename() {
1456
+ if (!editing) return;
1457
+ const draft = editing;
1458
+ editing = null;
1459
+ if (draft.isNew) removeGroupSilent(draft.wsId, draft.groupId);
1460
+ scheduleApply();
1461
+ }
1462
+
1463
+ function toggleGroup(wsId, groupId) {
1464
+ const ws = getWorkspace(wsId, false);
1465
+ if (!ws) return;
1466
+ if (groupId === UNGROUPED) {
1467
+ ws.ungroupedCollapsed = !ws.ungroupedCollapsed;
1468
+ } else {
1469
+ const group = findGroup(ws, groupId);
1470
+ if (!group) return;
1471
+ group.collapsed = !group.collapsed;
1472
+ }
1473
+ saveState();
1474
+ scheduleApply();
1475
+ }
1476
+
1477
+ function removeGroupSilent(wsId, groupId) {
1478
+ const ws = getWorkspace(wsId, false);
1479
+ const group = findGroup(ws, groupId);
1480
+ if (!ws || !group) return;
1481
+ const parentId = group.parentId ?? null;
1482
+ let movedSessions = 0;
1483
+ let movedChildren = 0;
1484
+ for (const child of ws.groups) {
1485
+ if (child.parentId !== groupId) continue;
1486
+ child.parentId = parentId;
1487
+ movedChildren += 1;
1488
+ }
1489
+ let destinationOrder = 0;
1490
+ if (parentId) {
1491
+ normalizeGroupOrder(wsId, parentId);
1492
+ destinationOrder = nextSessionOrder(ws, parentId);
1493
+ }
1494
+ for (const [sessionId, assigned] of Object.entries(ws.assignments)) {
1495
+ if (assigned !== groupId) continue;
1496
+ movedSessions += 1;
1497
+ if (parentId) {
1498
+ ws.assignments[sessionId] = parentId;
1499
+ ws.sessionOrder = ws.sessionOrder || {};
1500
+ ws.sessionOrder[sessionId] = destinationOrder;
1501
+ destinationOrder += 1;
1502
+ } else {
1503
+ delete ws.assignments[sessionId];
1504
+ if (ws.sessionOrder) delete ws.sessionOrder[sessionId];
1505
+ }
1506
+ }
1507
+ ws.groups = ws.groups.filter((candidate) => candidate.id !== groupId);
1508
+ // The destination must be visible, otherwise deleting a group looks
1509
+ // like it deleted its sessions too.
1510
+ if (parentId) {
1511
+ const parent = findGroup(ws, parentId);
1512
+ if (parent && (movedSessions > 0 || movedChildren > 0)) parent.collapsed = false;
1513
+ } else if (movedSessions > 0) {
1514
+ ws.ungroupedCollapsed = false;
1515
+ }
1516
+ if (ws.groups.length === 0) expandedAttempted.delete(wsId);
1517
+ saveState();
1518
+ }
1519
+
1520
+ function confirmDeleteGroup(wsId, groupId) {
1521
+ const ws = getWorkspace(wsId, false);
1522
+ const group = findGroup(ws, groupId);
1523
+ if (!group) return;
1524
+ openConfirm({
1525
+ title: t("deleteTitle"),
1526
+ message: t("deleteMessage", { name: group.name }),
1527
+ confirmText: t("delete"),
1528
+ danger: true,
1529
+ onConfirm: () => {
1530
+ removeGroupSilent(wsId, groupId);
1531
+ scheduleApply();
1532
+ },
1533
+ });
1534
+ }
1535
+
1536
+ function moveGroup(wsId, groupId, newParentId) {
1537
+ const ws = getWorkspace(wsId, false);
1538
+ const group = findGroup(ws, groupId);
1539
+ if (!ws || !group) return;
1540
+ if (newParentId === groupId) return;
1541
+ const descendants = collectGroupIds(ws, groupId);
1542
+ if (newParentId && descendants.has(newParentId)) return;
1543
+ if (newParentId && !findGroup(ws, newParentId)) return;
1544
+ group.parentId = newParentId ?? null;
1545
+ group.order = nextOrder(ws, group.parentId);
1546
+ if (newParentId) {
1547
+ const parent = findGroup(ws, newParentId);
1548
+ if (parent) parent.collapsed = false;
1549
+ }
1550
+ saveState();
1551
+ scheduleApply();
1552
+ toast(t("groupMoved"));
1553
+ }
1554
+
1555
+ function moveGroupOrder(wsId, groupId, action) {
1556
+ const ws = getWorkspace(wsId, false);
1557
+ const group = findGroup(ws, groupId);
1558
+ if (!ws || !group) return;
1559
+ const parentId = group.parentId ?? null;
1560
+ const siblings = sortedChildren(ws, parentId).slice();
1561
+ const index = siblings.findIndex((candidate) => candidate.id === groupId);
1562
+ if (index < 0) return;
1563
+ let target = index;
1564
+ if (action === "up") target = index - 1;
1565
+ else if (action === "down") target = index + 1;
1566
+ else if (action === "top") target = 0;
1567
+ else if (action === "bottom") target = siblings.length - 1;
1568
+ if (target < 0 || target >= siblings.length || target === index) return;
1569
+ siblings.splice(index, 1);
1570
+ siblings.splice(target, 0, group);
1571
+ siblings.forEach((sibling, position) => {
1572
+ sibling.order = position;
1573
+ });
1574
+ saveState();
1575
+ scheduleApply();
1576
+ }
1577
+
1578
+ function sortGroupsByName(wsId, parentId) {
1579
+ const ws = getWorkspace(wsId, false);
1580
+ if (!ws) return;
1581
+ const siblings = sortedChildren(ws, parentId ?? null)
1582
+ .slice()
1583
+ .sort((a, b) => a.name.localeCompare(b.name));
1584
+ siblings.forEach((sibling, position) => {
1585
+ sibling.order = position;
1586
+ });
1587
+ saveState();
1588
+ scheduleApply();
1589
+ }
1590
+
1591
+ function moveSession(wsId, sessionId, groupId) {
1592
+ const ws = getWorkspace(wsId, true);
1593
+ if (!ws || !sessionId) return;
1594
+ if (!groupId) {
1595
+ delete ws.assignments[sessionId];
1596
+ if (ws.sessionOrder) delete ws.sessionOrder[sessionId];
1597
+ } else {
1598
+ const group = findGroup(ws, groupId);
1599
+ if (!group) return;
1600
+ const previous = ws.assignments[sessionId];
1601
+ if (previous !== groupId) normalizeGroupOrder(wsId, groupId);
1602
+ ws.assignments[sessionId] = groupId;
1603
+ if (previous !== groupId) {
1604
+ ws.sessionOrder = ws.sessionOrder || {};
1605
+ ws.sessionOrder[sessionId] = nextSessionOrder(ws, groupId);
1606
+ }
1607
+ group.collapsed = false;
1608
+ let parentId = group.parentId;
1609
+ while (parentId) {
1610
+ const parent = findGroup(ws, parentId);
1611
+ if (!parent) break;
1612
+ parent.collapsed = false;
1613
+ parentId = parent.parentId;
1614
+ }
1615
+ }
1616
+ saveState();
1617
+ scheduleApply();
1618
+ }
1619
+
1620
+ function setAllGroupsCollapsed(wsId, collapsed) {
1621
+ const ws = getWorkspace(wsId, false);
1622
+ if (!ws) return;
1623
+ for (const group of ws.groups) group.collapsed = collapsed;
1624
+ ws.ungroupedCollapsed = collapsed;
1625
+ saveState();
1626
+ scheduleApply();
1627
+ }
1628
+
1629
+ function ensureWorkspaceExpanded(wsId) {
1630
+ const section = findSectionByWorkspace(wsId);
1631
+ if (!section) return;
1632
+ const header = headerInSection(section);
1633
+ if (header && header.getAttribute("aria-expanded") === "false") header.click();
1634
+ }
1635
+
1636
+ function maybeExpandAll(wsId) {
1637
+ if (expandedAttempted.has(wsId) || expanding.has(wsId)) return;
1638
+ const section = findSectionByWorkspace(wsId);
1639
+ if (!section) return;
1640
+ const header = headerInSection(section);
1641
+ if (!header || header.getAttribute("aria-expanded") === "false") return;
1642
+ expandedAttempted.add(wsId);
1643
+ setTimeout(async () => {
1644
+ const clicked = await expandAllSessions(wsId, true);
1645
+ // The overflow row may not be rendered yet when the list is still
1646
+ // loading. Leave the workspace retryable instead of giving up.
1647
+ if (clicked === 0) expandedAttempted.delete(wsId);
1648
+ }, 120);
1649
+ }
1650
+
1651
+ async function expandAllSessions(wsId, silent) {
1652
+ if (expanding.has(wsId)) return 0;
1653
+ expanding.add(wsId);
1654
+ let clicked = 0;
1655
+ try {
1656
+ for (let index = 0; index < 60; index++) {
1657
+ const section = findSectionByWorkspace(wsId);
1658
+ if (!section) break;
1659
+ const overflow = overflowInSection(section);
1660
+ if (!overflow) break;
1661
+ overflow.click();
1662
+ clicked += 1;
1663
+ await delay(70);
1664
+ }
1665
+ if (clicked > 0 && !silent) toast(t("shownAll"));
1666
+ } finally {
1667
+ expanding.delete(wsId);
1668
+ }
1669
+ return clicked;
1670
+ }
1671
+
1672
+ // ---------------------------------------------------------------------
1673
+ // Context menu contributions
1674
+ // ---------------------------------------------------------------------
1675
+
1676
+ function workspaceForSession(sessionId) {
1677
+ try {
1678
+ const row = document.querySelector('[data-row-key="session:' + cssEscape(sessionId) + '"]');
1679
+ if (row) {
1680
+ const section = row.closest("[data-dsh-st-section]") || sectionForElement(row);
1681
+ const wsId = section && section.getAttribute("data-dsh-st-ws");
1682
+ if (wsId) return wsId;
1683
+ const header = section && headerInSection(section);
1684
+ if (header) return headerWorkspaceId(header);
1685
+ }
1686
+ } catch {
1687
+ /* CSS.escape may be unavailable */
1688
+ }
1689
+ try {
1690
+ const workspacesService = getService("workspaces");
1691
+ const items = workspacesService?.list?.getSnapshot?.()?.items || [];
1692
+ // Authoritative ownership, independent of whether the row is rendered.
1693
+ const owner = items.find(
1694
+ (workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId),
1695
+ );
1696
+ if (owner && owner.workspaceId) return owner.workspaceId;
1697
+
1698
+ const sessionsService = getService("sessions");
1699
+ const session = sessionsService?.list?.getSnapshot?.()?.byId?.[sessionId];
1700
+ const cwd = session?.cwd;
1701
+ if (cwd) {
1702
+ const target = normalizePath(cwd);
1703
+ const match = items.find((workspace) => normalizePath(workspace.path) === target);
1704
+ if (match) return match.workspaceId;
1705
+ }
1706
+ } catch {
1707
+ /* optional service */
1708
+ }
1709
+ return undefined;
1710
+ }
1711
+
1712
+ function nextSessionOrder(ws, groupId) {
1713
+ let max = -1;
1714
+ if (!ws || !ws.assignments) return 0;
1715
+ for (const [sessionId, assigned] of Object.entries(ws.assignments)) {
1716
+ if (assigned !== groupId) continue;
1717
+ const order = ws.sessionOrder ? ws.sessionOrder[sessionId] : undefined;
1718
+ if (Number.isFinite(order)) max = Math.max(max, order);
1719
+ }
1720
+ return max + 1;
1721
+ }
1722
+
1723
+ function normalizeGroupOrder(wsId, groupId) {
1724
+ const ws = getWorkspace(wsId, false);
1725
+ if (!ws) return;
1726
+ ws.sessionOrder = ws.sessionOrder || {};
1727
+ const members = orderedGroupMembers(wsId, groupId);
1728
+ if (members.length > 0) {
1729
+ members.forEach((sessionId, index) => {
1730
+ ws.sessionOrder[sessionId] = index;
1731
+ });
1732
+ return;
1733
+ }
1734
+ let index = 0;
1735
+ for (const [sessionId, assigned] of Object.entries(ws.assignments)) {
1736
+ if (assigned !== groupId) continue;
1737
+ ws.sessionOrder[sessionId] = index++;
1738
+ }
1739
+ }
1740
+
1741
+ function orderedGroupMembers(wsId, groupId) {
1742
+ const ws = getWorkspace(wsId, false);
1743
+ const section = findSectionByWorkspace(wsId);
1744
+ if (!ws || !section) return [];
1745
+ const rows = sessionRowsInSection(section);
1746
+ const members = [];
1747
+ for (let index = 0; index < rows.length; index++) {
1748
+ const sessionId = sessionIdOf(rows[index]);
1749
+ if (!sessionId || ws.assignments[sessionId] !== groupId) continue;
1750
+ members.push({ id: sessionId, index });
1751
+ }
1752
+ members.sort(
1753
+ (a, b) => sessionOrderOf(ws, a.id, a.index) - sessionOrderOf(ws, b.id, b.index) || a.index - b.index,
1754
+ );
1755
+ return members.map((member) => member.id);
1756
+ }
1757
+
1758
+ function sessionOrderInfo(wsId, sessionId) {
1759
+ const ws = getWorkspace(wsId, false);
1760
+ const groupId = ws ? ws.assignments[sessionId] : undefined;
1761
+ if (!groupId) return null;
1762
+ const members = orderedGroupMembers(wsId, groupId);
1763
+ const index = members.indexOf(sessionId);
1764
+ if (index < 0) return null;
1765
+ let custom = false;
1766
+ for (const id of members) {
1767
+ const order = ws.sessionOrder ? ws.sessionOrder[id] : undefined;
1768
+ if (Number.isFinite(order)) {
1769
+ custom = true;
1770
+ break;
1771
+ }
1772
+ }
1773
+ return { groupId, index, length: members.length, custom };
1774
+ }
1775
+
1776
+ function moveSessionOrder(wsId, sessionId, action) {
1777
+ const ws = getWorkspace(wsId, false);
1778
+ const groupId = ws ? ws.assignments[sessionId] : undefined;
1779
+ if (!ws || !groupId) return;
1780
+ const members = orderedGroupMembers(wsId, groupId);
1781
+ const index = members.indexOf(sessionId);
1782
+ if (index < 0) return;
1783
+ let target = index;
1784
+ if (action === "up") target = index - 1;
1785
+ else if (action === "down") target = index + 1;
1786
+ else if (action === "top") target = 0;
1787
+ else if (action === "bottom") target = members.length - 1;
1788
+ if (target < 0 || target >= members.length || target === index) return;
1789
+ members.splice(index, 1);
1790
+ members.splice(target, 0, sessionId);
1791
+ ws.sessionOrder = ws.sessionOrder || {};
1792
+ members.forEach((id, position) => {
1793
+ ws.sessionOrder[id] = position;
1794
+ });
1795
+ saveState();
1796
+ scheduleApply();
1797
+ }
1798
+
1799
+ function resetGroupOrder(wsId, groupId) {
1800
+ const ws = getWorkspace(wsId, false);
1801
+ if (!ws || !ws.sessionOrder) return;
1802
+ for (const sessionId of Object.keys(ws.sessionOrder)) {
1803
+ if (ws.assignments[sessionId] === groupId) delete ws.sessionOrder[sessionId];
1804
+ }
1805
+ saveState();
1806
+ scheduleApply();
1807
+ }
1808
+
1809
+ function flattenGroups(ws) {
1810
+ const result = [];
1811
+ const byParent = new Map();
1812
+ for (const group of ws.groups) {
1813
+ const parentId = group.parentId ?? null;
1814
+ const list = byParent.get(parentId) || [];
1815
+ list.push(group);
1816
+ byParent.set(parentId, list);
1817
+ }
1818
+ for (const list of byParent.values()) {
1819
+ list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
1820
+ }
1821
+ const walk = (parentId, depth) => {
1822
+ for (const group of byParent.get(parentId) || []) {
1823
+ result.push({ group, depth });
1824
+ walk(group.id, depth + 1);
1825
+ }
1826
+ };
1827
+ walk(null, 0);
1828
+ return result;
1829
+ }
1830
+
1831
+ function installContextMenus(ctx) {
1832
+ if (!ctx.contextMenus || typeof ctx.contextMenus.register !== "function") return;
1833
+ ctx.effect(
1834
+ () =>
1835
+ ctx.contextMenus.register({
1836
+ id: MARK + ".workspace",
1837
+ target: "workspace",
1838
+ order: 250,
1839
+ resolve: ({ workspaceId }) => {
1840
+ if (!workspaceId) return [];
1841
+ return [
1842
+ { id: "new-group", label: t("newGroup") + "…", run: () => createGroup(workspaceId, null) },
1843
+ { id: "show-all", label: t("showAll"), run: () => expandAllSessions(workspaceId, false) },
1844
+ { id: "expand-groups", label: t("expandAllGroups"), run: () => setAllGroupsCollapsed(workspaceId, false) },
1845
+ { id: "collapse-groups", label: t("collapseAllGroups"), run: () => setAllGroupsCollapsed(workspaceId, true) },
1846
+ { id: "export-data", label: t("exportData"), separatorBefore: true, run: exportData },
1847
+ { id: "import-data", label: t("importData") + "…", run: pickImportFile },
1848
+ ];
1849
+ },
1850
+ }),
1851
+ MARK + ": workspace menu",
1852
+ );
1853
+ ctx.effect(
1854
+ () =>
1855
+ ctx.contextMenus.register({
1856
+ id: MARK + ".group",
1857
+ target: MARK + "-group",
1858
+ order: 100,
1859
+ resolve: ({ workspaceId, groupId }) => {
1860
+ const ws = getWorkspace(workspaceId, false);
1861
+ const group = findGroup(ws, groupId);
1862
+ if (!group) return [];
1863
+ const parentId = group.parentId ?? null;
1864
+ const siblings = sortedChildren(ws, parentId);
1865
+ const orderIndex = siblings.findIndex((candidate) => candidate.id === groupId);
1866
+ const orderLast = siblings.length - 1;
1867
+ return [
1868
+ { id: "subgroup", label: t("newSubgroup") + "…", run: () => createGroup(workspaceId, groupId) },
1869
+ { id: "rename", label: t("rename") + "…", run: () => beginRename(workspaceId, groupId, false) },
1870
+ {
1871
+ id: "toggle",
1872
+ label: group.collapsed ? t("expand") : t("collapse"),
1873
+ run: () => toggleGroup(workspaceId, groupId),
1874
+ },
1875
+ {
1876
+ id: "top",
1877
+ label: t("moveTop"),
1878
+ disabled: !group.parentId,
1879
+ run: () => moveGroup(workspaceId, groupId, null),
1880
+ },
1881
+ {
1882
+ id: "order",
1883
+ label: t("orderGroups"),
1884
+ children: [
1885
+ {
1886
+ id: "order:top",
1887
+ label: t("orderTop"),
1888
+ disabled: orderIndex <= 0,
1889
+ run: () => moveGroupOrder(workspaceId, groupId, "top"),
1890
+ },
1891
+ {
1892
+ id: "order:up",
1893
+ label: t("orderUp"),
1894
+ disabled: orderIndex <= 0,
1895
+ run: () => moveGroupOrder(workspaceId, groupId, "up"),
1896
+ },
1897
+ {
1898
+ id: "order:down",
1899
+ label: t("orderDown"),
1900
+ disabled: orderIndex < 0 || orderIndex >= orderLast,
1901
+ run: () => moveGroupOrder(workspaceId, groupId, "down"),
1902
+ },
1903
+ {
1904
+ id: "order:bottom",
1905
+ label: t("orderBottom"),
1906
+ disabled: orderIndex < 0 || orderIndex >= orderLast,
1907
+ run: () => moveGroupOrder(workspaceId, groupId, "bottom"),
1908
+ },
1909
+ {
1910
+ id: "order:name",
1911
+ label: t("orderByName"),
1912
+ separatorBefore: true,
1913
+ disabled: siblings.length < 2,
1914
+ run: () => sortGroupsByName(workspaceId, parentId),
1915
+ },
1916
+ ],
1917
+ },
1918
+ {
1919
+ id: "delete",
1920
+ label: t("deleteGroup"),
1921
+ danger: true,
1922
+ separatorBefore: true,
1923
+ run: () => confirmDeleteGroup(workspaceId, groupId),
1924
+ },
1925
+ ];
1926
+ },
1927
+ }),
1928
+ MARK + ": group menu",
1929
+ );
1930
+ ctx.effect(
1931
+ () =>
1932
+ ctx.contextMenus.register({
1933
+ id: MARK + ".session",
1934
+ target: "session",
1935
+ order: 350,
1936
+ resolve: ({ sessionId }) => {
1937
+ const wsId = workspaceForSession(sessionId);
1938
+ if (!wsId) return [];
1939
+ const ws = getWorkspace(wsId, false);
1940
+ const groups = ws ? flattenGroups(ws) : [];
1941
+ const current = ws ? ws.assignments[sessionId] : undefined;
1942
+ const moveChildren = groups.map(({ group, depth }) => ({
1943
+ id: "move:" + group.id,
1944
+ label: " ".repeat(Math.min(depth, 6)) + group.name + (current === group.id ? " ✓" : ""),
1945
+ disabled: current === group.id,
1946
+ run: () => moveSession(wsId, sessionId, group.id),
1947
+ }));
1948
+ if (groups.length === 0) {
1949
+ moveChildren.push({ id: "move:empty", label: t("noGroups"), disabled: true });
1950
+ }
1951
+ moveChildren.push({
1952
+ id: "move:new",
1953
+ label: t("newGroupAndMove") + "…",
1954
+ separatorBefore: true,
1955
+ run: () => createGroup(wsId, null, { assignSessionId: sessionId }),
1956
+ });
1957
+ if (current !== undefined) {
1958
+ moveChildren.push({
1959
+ id: "move:none",
1960
+ label: t("removeFromGroup"),
1961
+ separatorBefore: true,
1962
+ run: () => moveSession(wsId, sessionId, null),
1963
+ });
1964
+ }
1965
+ const items = [{ id: "move", label: t("moveSession"), children: moveChildren }];
1966
+ const orderInfo = current ? sessionOrderInfo(wsId, sessionId) : null;
1967
+ if (orderInfo && (orderInfo.length > 1 || orderInfo.custom)) {
1968
+ const last = orderInfo.length - 1;
1969
+ items.push({
1970
+ id: "order",
1971
+ label: t("orderSessions"),
1972
+ children: [
1973
+ {
1974
+ id: "order:top",
1975
+ label: t("orderTop"),
1976
+ disabled: orderInfo.index === 0,
1977
+ run: () => moveSessionOrder(wsId, sessionId, "top"),
1978
+ },
1979
+ {
1980
+ id: "order:up",
1981
+ label: t("orderUp"),
1982
+ disabled: orderInfo.index === 0,
1983
+ run: () => moveSessionOrder(wsId, sessionId, "up"),
1984
+ },
1985
+ {
1986
+ id: "order:down",
1987
+ label: t("orderDown"),
1988
+ disabled: orderInfo.index >= last,
1989
+ run: () => moveSessionOrder(wsId, sessionId, "down"),
1990
+ },
1991
+ {
1992
+ id: "order:bottom",
1993
+ label: t("orderBottom"),
1994
+ disabled: orderInfo.index >= last,
1995
+ run: () => moveSessionOrder(wsId, sessionId, "bottom"),
1996
+ },
1997
+ {
1998
+ id: "order:reset",
1999
+ label: t("orderReset"),
2000
+ separatorBefore: true,
2001
+ disabled: !orderInfo.custom,
2002
+ run: () => resetGroupOrder(wsId, orderInfo.groupId),
2003
+ },
2004
+ ],
2005
+ });
2006
+ }
2007
+ return items;
2008
+ },
2009
+ }),
2010
+ MARK + ": session menu",
2011
+ );
2012
+ }
2013
+
2014
+ function openGroupMiniMenu(wsId, groupId, x, y) {
2015
+ // Reuse the platform context-menu overlay with a plugin-private target;
2016
+ // the group action buttons cover the same verbs for pointer users.
2017
+ try {
2018
+ const menus = getService("contextMenus");
2019
+ if (menus?.open) {
2020
+ menus.open({ kind: MARK + "-group", value: { workspaceId: wsId, groupId } }, { x, y });
2021
+ return;
2022
+ }
2023
+ } catch {
2024
+ /* fall through */
2025
+ }
2026
+ beginRename(wsId, groupId, false);
2027
+ }
2028
+
2029
+ // ---------------------------------------------------------------------
2030
+ // Dialog primitives
2031
+ // ---------------------------------------------------------------------
2032
+
2033
+ function openConfirm({ title, message, confirmText, danger, onConfirm }) {
2034
+ const backdrop = document.createElement("div");
2035
+ backdrop.className = "dsh-st-backdrop";
2036
+ const dialog = document.createElement("div");
2037
+ dialog.className = "dsh-st-dialog";
2038
+ dialog.setAttribute("role", "dialog");
2039
+ dialog.setAttribute("aria-modal", "true");
2040
+ const heading = document.createElement("h2");
2041
+ heading.className = "dsh-st-dialog-title";
2042
+ heading.textContent = title;
2043
+ const body = document.createElement("p");
2044
+ body.className = "dsh-st-dialog-message";
2045
+ body.textContent = message;
2046
+ const actions = document.createElement("div");
2047
+ actions.className = "dsh-st-dialog-actions";
2048
+ const cancel = document.createElement("button");
2049
+ cancel.type = "button";
2050
+ cancel.className = "dsh-st-btn";
2051
+ cancel.textContent = t("cancel");
2052
+ const ok = document.createElement("button");
2053
+ ok.type = "button";
2054
+ ok.className = "dsh-st-btn " + (danger ? "dsh-st-btn-danger" : "dsh-st-btn-primary");
2055
+ ok.textContent = confirmText || t("confirm");
2056
+ actions.append(cancel, ok);
2057
+ dialog.append(heading, body, actions);
2058
+ backdrop.append(dialog);
2059
+
2060
+ let closed = false;
2061
+ const close = () => {
2062
+ if (closed) return;
2063
+ closed = true;
2064
+ document.removeEventListener("keydown", onKey, true);
2065
+ backdrop.remove();
2066
+ dialogEls = dialogEls.filter((item) => item !== backdrop);
2067
+ };
2068
+ const onKey = (event) => {
2069
+ if (event.key === "Escape") {
2070
+ event.preventDefault();
2071
+ close();
2072
+ }
2073
+ };
2074
+ cancel.addEventListener("click", close);
2075
+ ok.addEventListener("click", () => {
2076
+ close();
2077
+ onConfirm();
2078
+ });
2079
+ backdrop.addEventListener("mousedown", (event) => {
2080
+ if (event.target === backdrop) close();
2081
+ });
2082
+ document.addEventListener("keydown", onKey, true);
2083
+ document.body.append(backdrop);
2084
+ dialogEls.push(backdrop);
2085
+ requestAnimationFrame(() => (danger ? cancel : ok).focus());
2086
+ }
2087
+
2088
+ function toast(text) {
2089
+ if (toastEl) toastEl.remove();
2090
+ toastEl = document.createElement("div");
2091
+ toastEl.className = "dsh-st-toast";
2092
+ toastEl.textContent = text;
2093
+ document.body.append(toastEl);
2094
+ setTimeout(() => {
2095
+ if (toastEl) toastEl.remove();
2096
+ toastEl = null;
2097
+ }, 2200);
2098
+ }
2099
+
2100
+ // ---------------------------------------------------------------------
2101
+ // Utilities / lifecycle
2102
+ // ---------------------------------------------------------------------
2103
+
2104
+ function cssEscape(value) {
2105
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
2106
+ return String(value).replace(/["\\]/g, "\\$&");
2107
+ }
2108
+
2109
+ function injectStyle() {
2110
+ if (styleEl) return;
2111
+ styleEl = document.createElement("style");
2112
+ styleEl.id = MARK + "-style";
2113
+ styleEl.textContent = CSS;
2114
+ document.head.append(styleEl);
2115
+ }
2116
+
2117
+ function observe() {
2118
+ if (observer) return;
2119
+ observer = new MutationObserver((records) => {
2120
+ if (applying || disposed) return;
2121
+ for (const record of records) {
2122
+ if (record.type === "childList" && (record.addedNodes.length > 0 || record.removedNodes.length > 0)) {
2123
+ scheduleApply();
2124
+ return;
2125
+ }
2126
+ }
2127
+ });
2128
+ observer.observe(document.body, { childList: true, subtree: true });
2129
+ }
2130
+
2131
+ function cleanup() {
2132
+ disposed = true;
2133
+ if (observer) {
2134
+ observer.disconnect();
2135
+ observer = null;
2136
+ }
2137
+ if (storageHandler) {
2138
+ window.removeEventListener("storage", storageHandler);
2139
+ storageHandler = null;
2140
+ }
2141
+ for (const [section, ui] of Array.from(sectionUis.entries())) disposeSectionUi(section, ui);
2142
+ for (const dialog of dialogEls.splice(0)) dialog.remove();
2143
+ if (toastEl) {
2144
+ toastEl.remove();
2145
+ toastEl = null;
2146
+ }
2147
+ if (styleEl) {
2148
+ styleEl.remove();
2149
+ styleEl = null;
2150
+ }
2151
+ editing = null;
2152
+ expandedAttempted.clear();
2153
+ expanding.clear();
2154
+ sectionUis.clear();
2155
+ if (hostSaveTimer !== null) {
2156
+ clearTimeout(hostSaveTimer);
2157
+ hostSaveTimer = null;
2158
+ }
2159
+ hostRpc = null;
2160
+ hostMode = false;
2161
+ hostSaving = false;
2162
+ hostSaveQueued = false;
2163
+ hostSaveWarned = false;
2164
+ hostLoaded = false;
2165
+ hostRevision = 0;
2166
+ dirty = false;
2167
+ if (window.__DSH_SESSION_TREE_INSTANCE__ === instance) {
2168
+ delete window.__DSH_SESSION_TREE_INSTANCE__;
2169
+ delete window.__DSH_SESSION_TREE__;
2170
+ }
2171
+ }
2172
+
2173
+ function apply(ctx) {
2174
+ debug("ST apply:start");
2175
+ if (window.__DSH_SESSION_TREE_INSTANCE__) {
2176
+ try {
2177
+ window.__DSH_SESSION_TREE_INSTANCE__.dispose();
2178
+ } catch {
2179
+ /* ignore a stale instance */
2180
+ }
2181
+ }
2182
+ if (!document.body) {
2183
+ debug("ST apply: no body", true);
2184
+ return;
2185
+ }
2186
+ ctxRef = ctx;
2187
+ state = loadState();
2188
+ injectStyle();
2189
+ observe();
2190
+
2191
+ // Context menus are optional for the first paint: apply immediately so
2192
+ // the tree renders even if the menu service has not activated yet.
2193
+ try {
2194
+ ctx.inject(["contextMenus"], (scope) => {
2195
+ try {
2196
+ installContextMenus(scope);
2197
+ debug("ST menus:on");
2198
+ } catch (error) {
2199
+ console.error("[" + MARK + "] context menus failed", error);
2200
+ }
2201
+ });
2202
+ } catch (error) {
2203
+ console.warn("[" + MARK + "] context menu service unavailable", error);
2204
+ }
2205
+
2206
+ // Host persistence is optional: the tree keeps working from
2207
+ // localStorage until the authenticated Connection RPC is available.
2208
+ try {
2209
+ ctx.inject(["connection"], (scope) => {
2210
+ try {
2211
+ const connection = scope.connection || (typeof scope.get === "function" ? scope.get("connection") : undefined);
2212
+ const rpc = connection && connection.rpc;
2213
+ if (rpc && typeof rpc.call === "function") void hydrateFromHost(rpc);
2214
+ } catch (error) {
2215
+ console.warn("[" + MARK + "] host connection unavailable", error);
2216
+ }
2217
+ });
2218
+ } catch (error) {
2219
+ console.warn("[" + MARK + "] connection service unavailable", error);
2220
+ }
2221
+
2222
+ storageHandler = (event) => {
2223
+ if (event.key !== STORAGE_KEY) return;
2224
+ state = loadState();
2225
+ // Another tab wrote a newer tree; do not let a late host hydrate
2226
+ // overwrite it with an older snapshot.
2227
+ dirty = true;
2228
+ scheduleApply();
2229
+ };
2230
+ window.addEventListener("storage", storageHandler);
2231
+
2232
+ instance.dispose = cleanup;
2233
+ window.__DSH_SESSION_TREE_INSTANCE__ = instance;
2234
+ window.__DSH_SESSION_TREE__ = {
2235
+ get state() {
2236
+ return state;
2237
+ },
2238
+ get hostMode() {
2239
+ return hostMode;
2240
+ },
2241
+ get hostLoaded() {
2242
+ return hostLoaded;
2243
+ },
2244
+ apply: applyNow,
2245
+ createGroup,
2246
+ moveSession,
2247
+ moveGroup,
2248
+ expandAllSessions,
2249
+ exportData,
2250
+ exportJson,
2251
+ importData: pickImportFile,
2252
+ importJson: importDataFromText,
2253
+ flushHostSave,
2254
+ reset: () => {
2255
+ state = emptyState();
2256
+ saveState(true);
2257
+ scheduleApply();
2258
+ },
2259
+ };
2260
+
2261
+ ctx.effect(() => () => cleanup(), MARK + ": sidebar tree");
2262
+ scheduleApply();
2263
+
2264
+ setTimeout(() => {
2265
+ if (disposed) return;
2266
+ debug("ST ready · apply");
2267
+ for (const wsId of Object.keys(state.workspaces)) {
2268
+ if (state.workspaces[wsId]?.groups?.length) maybeExpandAll(wsId);
2269
+ }
2270
+ }, 300);
2271
+
2272
+ console.info("[" + MARK + "] ready");
2273
+ }
2274
+
2275
+ debug("ST module · waiting for apply");
2276
+ exports.apply = apply;
2277
+ exports.inject = [];
2278
+ return module.exports;
2279
+ },
2280
+ });