clay-server 5.0.0-beta.2 → 5.0.0-beta.3

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 (50) hide show
  1. package/lib/daemon.js +9 -0
  2. package/lib/new-session-default.js +45 -0
  3. package/lib/project-connection.js +4 -2
  4. package/lib/project-session-creation.js +107 -0
  5. package/lib/project-session-delete.js +21 -3
  6. package/lib/project-session-folder-delete.js +169 -0
  7. package/lib/project-session-folders.js +272 -0
  8. package/lib/project-sessions.js +46 -26
  9. package/lib/project.js +2 -0
  10. package/lib/public/app.js +10 -0
  11. package/lib/public/css/filebrowser.css +3 -3
  12. package/lib/public/css/mates.css +1 -1
  13. package/lib/public/css/mobile-nav.css +0 -128
  14. package/lib/public/css/session-folders.css +383 -0
  15. package/lib/public/css/sidebar.css +28 -545
  16. package/lib/public/index.html +3 -21
  17. package/lib/public/modules/app-favicon.js +5 -4
  18. package/lib/public/modules/app-messages.js +13 -0
  19. package/lib/public/modules/input.js +3 -0
  20. package/lib/public/modules/project-switcher.js +4 -4
  21. package/lib/public/modules/session-create-form.js +428 -0
  22. package/lib/public/modules/session-create.js +338 -0
  23. package/lib/public/modules/session-folder-collapse.js +192 -0
  24. package/lib/public/modules/session-folder-context.js +215 -0
  25. package/lib/public/modules/session-folder-delete.js +313 -0
  26. package/lib/public/modules/session-folder-dialogs.js +173 -0
  27. package/lib/public/modules/session-folder-dnd.js +214 -0
  28. package/lib/public/modules/session-folder-draft.js +184 -0
  29. package/lib/public/modules/session-folder-layout.js +133 -0
  30. package/lib/public/modules/session-folder-toolbar.js +185 -0
  31. package/lib/public/modules/session-folder-view-menu.js +187 -0
  32. package/lib/public/modules/session-folders.js +95 -0
  33. package/lib/public/modules/session-list-search.js +108 -0
  34. package/lib/public/modules/session-presence-ui.js +44 -0
  35. package/lib/public/modules/sidebar-mobile.js +38 -182
  36. package/lib/public/modules/sidebar-session-folders-ui.js +382 -0
  37. package/lib/public/modules/sidebar-sessions.js +141 -718
  38. package/lib/public/modules/sidebar.js +10 -2
  39. package/lib/public/modules/terminal.js +2 -2
  40. package/lib/public/modules/tool-palette.js +37 -219
  41. package/lib/public/style.css +1 -0
  42. package/lib/server.js +4 -0
  43. package/lib/session-folders.js +414 -0
  44. package/lib/sessions.js +7 -5
  45. package/lib/users-session-folder-preferences.js +69 -0
  46. package/lib/users.js +6 -0
  47. package/lib/ws-schema.js +11 -0
  48. package/package.json +1 -1
  49. package/lib/public/modules/session-creation-cta.js +0 -46
  50. package/lib/public/modules/tool-palette-overlays.js +0 -159
@@ -0,0 +1,215 @@
1
+ // Actions menu for custom folders, opened from the folder header itself: right
2
+ // click (at the pointer), the ContextMenu key or Shift+F10 (below the focused
3
+ // header), or a long press on touch. There are no visible dots or reserved
4
+ // slots; Favorites and Unfiled never get this menu. Menu, press and click
5
+ // suppression state live in the store; this module only owns the functions.
6
+
7
+ import { store } from './store.js';
8
+ import { iconHtml, refreshIcons } from './icons.js';
9
+ import { escapeHtml } from './utils.js';
10
+
11
+ var LONG_PRESS_MS = 500;
12
+ var MOVE_TOLERANCE = 10;
13
+ var POST_RELEASE_MS = 400;
14
+ var HOLD_MAX_MS = 15000;
15
+ var EDGE = 8;
16
+
17
+ function contextState() {
18
+ return store.get('sessionFolderContext') || { press: null, held: null, suppressUntil: 0 };
19
+ }
20
+
21
+ function patchContext(change) {
22
+ store.set({ sessionFolderContext: Object.assign({}, contextState(), change) });
23
+ }
24
+
25
+ // --- Menu ---
26
+
27
+ export function closeFolderMenu(restoreFocus) {
28
+ var menu = store.get('sessionFolderMenu');
29
+ if (!menu) return;
30
+ store.set({ sessionFolderMenu: null });
31
+ document.removeEventListener("pointerdown", onMenuOutside, true);
32
+ document.removeEventListener("mousedown", onMenuOutside, true);
33
+ document.removeEventListener("touchstart", onMenuOutside, true);
34
+ document.removeEventListener("keydown", onMenuKey, true);
35
+ window.removeEventListener("blur", onMenuBlur);
36
+ menu.el.remove();
37
+ if (restoreFocus !== false && menu.anchor && document.contains(menu.anchor)) menu.anchor.focus({ preventScroll: true });
38
+ }
39
+
40
+ // Any press that starts outside the menu dismisses it. pointerdown is the
41
+ // primary signal: surfaces that call preventDefault() on pointerdown make the
42
+ // browser skip the compatibility mousedown/touchstart, which left the menu
43
+ // open. Those two stay as fallbacks. The press is never cancelled, and focus is
44
+ // left to the control that was pressed (the anchor only regains it when the
45
+ // menu closes by Escape or by choosing an item).
46
+ function onMenuOutside(event) {
47
+ var menu = store.get('sessionFolderMenu');
48
+ if (!menu) return;
49
+ var target = event.target;
50
+ if (target && target.nodeType === 1 && menu.el.contains(target)) return;
51
+ closeFolderMenu(false);
52
+ }
53
+
54
+ // Focus moving into an iframe or another window never reaches this
55
+ // document's pointer events.
56
+ function onMenuBlur() {
57
+ if (store.get('sessionFolderMenu')) closeFolderMenu(false);
58
+ }
59
+
60
+ function onMenuKey(event) {
61
+ var menu = store.get('sessionFolderMenu');
62
+ if (!menu) return;
63
+ var items = Array.prototype.filter.call(menu.el.querySelectorAll("button"), function (b) { return !b.disabled; });
64
+ var at = items.indexOf(document.activeElement);
65
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeFolderMenu(); }
66
+ else if (event.key === "ArrowDown") { event.preventDefault(); items[(at + 1) % items.length].focus(); }
67
+ else if (event.key === "ArrowUp") { event.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
68
+ else if (event.key === "Tab") { event.preventDefault(); items[(at + (event.shiftKey ? -1 : 1) + items.length) % items.length].focus(); }
69
+ else if (event.key === "Home") { event.preventDefault(); items[0].focus(); }
70
+ else if (event.key === "End") { event.preventDefault(); items[items.length - 1].focus(); }
71
+ }
72
+
73
+ // Opens the menu with its top-left corner at the point, kept inside the viewport.
74
+ export function openFolderMenuAt(anchor, entries, point) {
75
+ closeFolderMenu();
76
+ var menu = document.createElement("div");
77
+ menu.className = "session-ctx-menu session-folder-menu";
78
+ menu.setAttribute("role", "menu");
79
+ entries.forEach(function (entry) {
80
+ var item = document.createElement("button");
81
+ item.type = "button";
82
+ item.setAttribute("role", "menuitem");
83
+ item.className = "session-ctx-item" + (entry.danger ? " session-ctx-delete" : "");
84
+ item.disabled = entry.disabled === true;
85
+ item.innerHTML = iconHtml(entry.icon) + " <span>" + escapeHtml(entry.label) + "</span>";
86
+ item.addEventListener("click", function (event) {
87
+ event.stopPropagation();
88
+ // The finger that opened the menu by long press lifts right over it; that tap is not a choice.
89
+ if (consumeFolderClickSuppress()) return;
90
+ closeFolderMenu();
91
+ entry.run();
92
+ });
93
+ menu.appendChild(item);
94
+ });
95
+ menu.style.visibility = "hidden";
96
+ document.body.appendChild(menu);
97
+ var box = menu.getBoundingClientRect();
98
+ var left = Math.min(Math.max(EDGE, point.x), Math.max(EDGE, window.innerWidth - box.width - EDGE));
99
+ var top = point.y;
100
+ if (top + box.height > window.innerHeight - EDGE) top = Math.min(point.y - box.height, window.innerHeight - box.height - EDGE);
101
+ top = Math.max(EDGE, top);
102
+ menu.style.left = left + "px";
103
+ menu.style.top = top + "px";
104
+ menu.style.right = "auto";
105
+ menu.style.visibility = "";
106
+ store.set({ sessionFolderMenu: { el: menu, anchor: anchor } });
107
+ document.addEventListener("pointerdown", onMenuOutside, true);
108
+ document.addEventListener("mousedown", onMenuOutside, true);
109
+ document.addEventListener("touchstart", onMenuOutside, true);
110
+ document.addEventListener("keydown", onMenuKey, true);
111
+ window.addEventListener("blur", onMenuBlur);
112
+ refreshIcons();
113
+ var first = menu.querySelector("button:not([disabled])");
114
+ if (first) first.focus({ preventScroll: true });
115
+ }
116
+
117
+ // --- Long press (touch and pen) ---
118
+
119
+ function onScrollCancel() {
120
+ cancelFolderPress();
121
+ }
122
+
123
+ // A press that has not fired yet: cancelled by movement, release, scroll, drag
124
+ // and anything that replaces the DOM it was started on.
125
+ export function cancelFolderPress() {
126
+ var press = contextState().press;
127
+ if (!press) return;
128
+ clearTimeout(press.timer);
129
+ document.removeEventListener("scroll", onScrollCancel, true);
130
+ patchContext({ press: null });
131
+ }
132
+
133
+ // The finger that opened the menu is still down. Its release, however late, must
134
+ // not toggle the folder or choose an item; a short window after it covers the
135
+ // compatibility click that follows the release.
136
+ function onHoldEnd(event) {
137
+ var held = contextState().held;
138
+ if (!held) return;
139
+ if (held.pointerId !== undefined && event.pointerId !== undefined && event.pointerId !== held.pointerId) return;
140
+ releaseFolderHold(true);
141
+ }
142
+
143
+ function releaseFolderHold(keepWindow) {
144
+ document.removeEventListener("pointerup", onHoldEnd, true);
145
+ document.removeEventListener("pointercancel", onHoldEnd, true);
146
+ if (!contextState().held && !keepWindow) return;
147
+ patchContext({ held: null, suppressUntil: keepWindow ? Date.now() + POST_RELEASE_MS : 0 });
148
+ }
149
+
150
+ // Cleanup for project and DM transitions.
151
+ export function resetFolderGestures() {
152
+ cancelFolderPress();
153
+ releaseFolderHold(false);
154
+ }
155
+
156
+ // True while the opening finger is still down, and briefly after it lifts.
157
+ export function consumeFolderClickSuppress() {
158
+ var state = contextState();
159
+ if (state.held && Date.now() - state.held.at < HOLD_MAX_MS) return true;
160
+ return Date.now() < state.suppressUntil;
161
+ }
162
+
163
+ // Wires the custom-folder gestures onto a header. entriesFor() builds the menu
164
+ // entries when it opens, so they always reflect the current folder order.
165
+ export function wireFolderContext(header, anchor, entriesFor) {
166
+ function open(x, y) {
167
+ if (store.get('sessionFolderMenu')) return;
168
+ openFolderMenuAt(anchor, entriesFor(), { x: x, y: y });
169
+ }
170
+
171
+ header.addEventListener("contextmenu", function (event) {
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ open(event.clientX, event.clientY);
175
+ });
176
+
177
+ header.addEventListener("keydown", function (event) {
178
+ if (event.key !== "ContextMenu" && !(event.key === "F10" && event.shiftKey)) return;
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+ var rect = anchor.getBoundingClientRect();
182
+ open(rect.left + 16, rect.bottom);
183
+ });
184
+
185
+ header.addEventListener("pointerdown", function (event) {
186
+ if (event.pointerType === "mouse" || event.isPrimary === false) return;
187
+ cancelFolderPress();
188
+ releaseFolderHold(false);
189
+ var x = event.clientX;
190
+ var y = event.clientY;
191
+ var pointerId = event.pointerId;
192
+ var timer = setTimeout(function () {
193
+ var live = contextState().press;
194
+ if (!live || live.timer !== timer) return;
195
+ document.removeEventListener("scroll", onScrollCancel, true);
196
+ // A rerender may have replaced the header while the press was pending.
197
+ if (!header.isConnected || !anchor.isConnected) { patchContext({ press: null }); return; }
198
+ patchContext({ press: null, held: { pointerId: pointerId, at: Date.now() }, suppressUntil: 0 });
199
+ document.addEventListener("pointerup", onHoldEnd, true);
200
+ document.addEventListener("pointercancel", onHoldEnd, true);
201
+ open(x, y);
202
+ }, LONG_PRESS_MS);
203
+ document.addEventListener("scroll", onScrollCancel, true);
204
+ patchContext({ press: { timer: timer, x: x, y: y } });
205
+ });
206
+ header.addEventListener("pointermove", function (event) {
207
+ var press = contextState().press;
208
+ if (!press) return;
209
+ if (Math.abs(event.clientX - press.x) > MOVE_TOLERANCE || Math.abs(event.clientY - press.y) > MOVE_TOLERANCE) cancelFolderPress();
210
+ });
211
+ header.addEventListener("pointerup", cancelFolderPress);
212
+ header.addEventListener("pointercancel", cancelFolderPress);
213
+ header.addEventListener("pointerleave", cancelFolderPress);
214
+ header.addEventListener("dragstart", cancelFolderPress);
215
+ }
@@ -0,0 +1,313 @@
1
+ // Delete-folder dialog. The user chooses what happens to the folder's
2
+ // sessions: move them to Unfiled (the default), move them to another folder, or
3
+ // delete them together with the folder. Counts and the snapshot token come from
4
+ // the server's authoritative membership (never from the possibly search
5
+ // filtered sidebar), and the delete request carries that token so the server
6
+ // refuses it if the folder changed while the dialog was open. All dialog state
7
+ // lives in the store (sessionFolderDelete); the DOM is repainted from it.
8
+
9
+ import { store } from './store.js';
10
+ import { getWs } from './ws-ref.js';
11
+ import { escapeHtml } from './utils.js';
12
+ import { currentFolderState } from './session-folders.js';
13
+ import { createModal, button, closeFolderModal } from './session-folder-dialogs.js';
14
+
15
+ // The dialog's elements are found through the modal recorded in the store, so
16
+ // nothing here outlives the dialog: closing it drops the store state and the
17
+ // only reference to its DOM.
18
+ function parts() {
19
+ var modal = store.get('sessionFolderModal');
20
+ if (!modal || modal.kind !== "delete-folder") return null;
21
+ var buttons = modal.root.querySelectorAll(".confirm-actions .confirm-btn");
22
+ return { body: modal.root.querySelector(".session-folder-delete"), cancel: buttons[0], primary: buttons[buttons.length - 1] };
23
+ }
24
+
25
+ function state() {
26
+ return store.get('sessionFolderDelete');
27
+ }
28
+
29
+ function patch(next) {
30
+ var current = state();
31
+ if (current) store.set({ sessionFolderDelete: Object.assign({}, current, next) });
32
+ }
33
+
34
+ function newRequestId() {
35
+ return "fdel-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
36
+ }
37
+
38
+ function send(message) {
39
+ var ws = getWs();
40
+ if (!ws || !store.get('connected')) return false;
41
+ // A socket that closed between the check and the send throws; treat it like
42
+ // any other failed send so the caller's recovery state runs.
43
+ try {
44
+ ws.send(JSON.stringify(Object.assign({ slug: store.get('currentSlug') }, message)));
45
+ } catch (e) {
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function phrase(count, noun) {
52
+ return count + " " + noun + (count === 1 ? "" : "s");
53
+ }
54
+
55
+ // Other folders a move can target: every custom folder except the source, and
56
+ // Unfiled is the first choice, not a destination here, and Favorites is a tag,
57
+ // never a destination.
58
+ export function moveDestinations(sourceId) {
59
+ var out = [];
60
+ var folders = currentFolderState().folders;
61
+ for (var i = 0; i < folders.length; i++) if (folders[i].id !== sourceId) out.push({ id: folders[i].id, label: folders[i].name });
62
+ return out;
63
+ }
64
+
65
+ export function openFolderDelete(folderId, label) {
66
+ // Replace any dialog that is already open first: closing it clears the
67
+ // deletion state, so the new state must be set afterwards.
68
+ closeFolderModal();
69
+ store.set({ sessionFolderDelete: {
70
+ folderId: folderId, label: label, phase: "loading", requestId: "", preview: null,
71
+ mode: "unfiled", destinationId: "", confirmDelete: false, error: "", focused: false,
72
+ } });
73
+ buildDialog();
74
+ requestPreview();
75
+ }
76
+
77
+ // Asks the server for the folder's membership. A failed send ends the loading
78
+ // state with a retryable error instead of leaving a dialog with nothing to do.
79
+ function requestPreview() {
80
+ var current = state();
81
+ if (!current) return;
82
+ var requestId = newRequestId();
83
+ patch({ phase: "loading", requestId: requestId, error: "" });
84
+ if (!send({ type: "session_folders_delete_preview", folderId: current.folderId, requestId: requestId })) {
85
+ patch({ phase: "error", error: "Not connected. Check the connection and retry." });
86
+ }
87
+ }
88
+
89
+ function buildDialog() {
90
+ var body = document.createElement("div");
91
+ body.className = "session-folder-dialog-body session-folder-delete";
92
+ var cancel = button("Cancel", "confirm-cancel", function () { closeFolderModal(); });
93
+ var primary = button("Delete folder", "confirm-ok", submit);
94
+ createModal("delete-folder", "Delete folder", body, [cancel, primary], function () {
95
+ var current = state();
96
+ return !current || current.phase !== "pending";
97
+ });
98
+ body.addEventListener("keydown", onBodyKey);
99
+ }
100
+
101
+ function onBodyKey(event) {
102
+ var current = state();
103
+ if (!current || current.phase === "pending") return;
104
+ var target = event.target;
105
+ if (target && target.getAttribute && target.getAttribute("role") === "radio") {
106
+ var step = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : (event.key === "ArrowUp" || event.key === "ArrowLeft" ? -1 : 0);
107
+ if (!step) return;
108
+ event.preventDefault();
109
+ var radios = Array.prototype.filter.call(parts().body.querySelectorAll('[role="radio"]'), function (r) { return !r.disabled; });
110
+ var at = radios.indexOf(target);
111
+ var next = radios[(at + step + radios.length) % radios.length];
112
+ next.focus();
113
+ choose(next.dataset.mode);
114
+ }
115
+ }
116
+
117
+ function choose(mode) {
118
+ var current = state();
119
+ if (!current || current.phase === "pending" || current.phase === "loading") return;
120
+ patch({ mode: mode, confirmDelete: false, error: "" });
121
+ }
122
+
123
+ function canSubmit(current) {
124
+ if (!current || !current.preview || current.phase === "pending" || current.phase === "loading") return false;
125
+ if (current.preview.sessionCount === 0) return true;
126
+ if (current.mode === "move") return !!current.destinationId;
127
+ if (current.mode === "delete") return current.preview.canDeleteSessions && current.confirmDelete;
128
+ return true;
129
+ }
130
+
131
+ function submit() {
132
+ var current = state();
133
+ if (current && current.phase === "error" && !current.preview) { requestPreview(); return; }
134
+ if (!canSubmit(current)) return;
135
+ var requestId = newRequestId();
136
+ var message = { type: "session_folders_delete", folderId: current.folderId, mode: current.preview.sessionCount === 0 ? "unfiled" : current.mode, token: current.preview.token, requestId: requestId };
137
+ if (message.mode === "move") message.destinationId = current.destinationId;
138
+ if (message.mode === "delete") message.confirmDelete = true;
139
+ patch({ phase: "pending", requestId: requestId, error: "" });
140
+ if (!send(message)) patch({ phase: "ready", requestId: current.requestId, error: "Not connected. Try again." });
141
+ }
142
+
143
+ function radioRow(mode, labelText, hint, current, extraClass) {
144
+ var row = document.createElement("button");
145
+ row.type = "button";
146
+ var selected = current.mode === mode;
147
+ row.className = "session-folder-radio session-folder-delete-option" + (selected ? " selected" : "") + (extraClass ? " " + extraClass : "");
148
+ row.dataset.mode = mode;
149
+ row.dataset.focusKey = "mode:" + mode;
150
+ row.setAttribute("role", "radio");
151
+ row.setAttribute("aria-checked", String(selected));
152
+ row.tabIndex = selected ? 0 : -1;
153
+ row.innerHTML = '<span class="session-folder-radio-dot" aria-hidden="true"></span><span class="session-folder-delete-option-text"><span>' + escapeHtml(labelText) + "</span>" +
154
+ (hint ? '<span class="session-folder-delete-hint">' + escapeHtml(hint) + "</span>" : "") + "</span>";
155
+ row.addEventListener("click", function () { choose(mode); });
156
+ return row;
157
+ }
158
+
159
+ function describe(current) {
160
+ var preview = current.preview;
161
+ if (preview.sessionCount === 0) return 'The folder "' + current.label + '" is empty. Deleting it does not affect any sessions.';
162
+ var text = 'The folder "' + current.label + '" contains ' + phrase(preview.sessionCount, "session") + ".";
163
+ if (preview.workerCount > 0) {
164
+ text += " " + phrase(preview.workerCount, "Split Worker session") + " owned by those Drivers would be deleted with them, including hidden sessions and previous generations.";
165
+ }
166
+ return text;
167
+ }
168
+
169
+ function paint() {
170
+ var current = state();
171
+ var dialog = parts();
172
+ if (!dialog || !current) return;
173
+ var body = dialog.body;
174
+ var active = document.activeElement;
175
+ var focusKey = active && body.contains(active) ? active.dataset.focusKey : null;
176
+ var pending = current.phase === "pending";
177
+ body.innerHTML = "";
178
+ body.setAttribute("aria-busy", String(pending || current.phase === "loading"));
179
+
180
+ if (current.phase === "loading") {
181
+ var loading = document.createElement("div");
182
+ loading.className = "session-folder-delete-status";
183
+ loading.textContent = "Checking what is in this folder";
184
+ body.appendChild(loading);
185
+ } else if (current.preview) {
186
+ var intro = document.createElement("div");
187
+ intro.className = "session-folder-delete-intro";
188
+ intro.textContent = describe(current);
189
+ body.appendChild(intro);
190
+ if (current.preview.sessionCount > 0) paintChoices(body, current, pending);
191
+ }
192
+
193
+ var alert = document.createElement("div");
194
+ alert.className = "session-folder-input-hint session-folder-delete-error";
195
+ alert.setAttribute("role", "alert");
196
+ alert.textContent = current.error || "";
197
+ body.appendChild(alert);
198
+
199
+ var destructive = current.preview && current.preview.sessionCount > 0 && current.mode === "delete";
200
+ dialog.primary.className = "confirm-btn " + (destructive ? "confirm-delete" : "confirm-ok");
201
+ var retry = current.phase === "error" && !current.preview;
202
+ dialog.primary.disabled = retry ? !store.get('connected') : !canSubmit(current);
203
+ dialog.cancel.disabled = pending;
204
+ dialog.primary.textContent = retry ? "Retry" : (pending ? (destructive ? "Deleting" : "Working") : primaryLabel(current));
205
+
206
+ if (focusKey) {
207
+ var target = body.querySelector('[data-focus-key="' + focusKey + '"]');
208
+ if (target && !target.disabled) target.focus();
209
+ } else if (!current.focused && (current.preview || current.phase === "error")) {
210
+ var first = body.querySelector('[role="radio"][aria-checked="true"]') || dialog.primary;
211
+ first.focus();
212
+ patch({ focused: true });
213
+ }
214
+ }
215
+
216
+ function primaryLabel(current) {
217
+ if (!current.preview || current.preview.sessionCount === 0) return "Delete folder";
218
+ if (current.mode === "move") return "Move and delete folder";
219
+ if (current.mode === "delete") return "Delete folder and " + phrase(current.preview.totalCount, "session");
220
+ return "Delete folder";
221
+ }
222
+
223
+ function paintChoices(body, current, pending) {
224
+ var preview = current.preview;
225
+ var group = document.createElement("div");
226
+ group.className = "session-folder-radio-group session-folder-delete-choices";
227
+ group.setAttribute("role", "radiogroup");
228
+ group.setAttribute("aria-label", "What should happen to the sessions");
229
+ var unfiled = radioRow("unfiled", "Move sessions to Unfiled", "Nothing is deleted.", current);
230
+ var move = radioRow("move", "Move sessions to another folder", "", current);
231
+ var del = radioRow("delete", "Delete folder and sessions", preview.canDeleteSessions ? "Permanently deletes " + phrase(preview.totalCount, "session") + " and their history." : (preview.deleteBlockedReason || "Not available."), current, "danger");
232
+ if (!preview.canDeleteSessions) { del.disabled = true; del.setAttribute("aria-disabled", "true"); }
233
+ [unfiled, move, del].forEach(function (row) { if (pending) row.disabled = true; group.appendChild(row); });
234
+ body.appendChild(group);
235
+
236
+ if (current.mode === "move") {
237
+ var label = document.createElement("label");
238
+ label.className = "session-folder-delete-destination";
239
+ label.textContent = "Destination";
240
+ var select = document.createElement("select");
241
+ select.className = "session-folder-input session-folder-delete-select";
242
+ select.dataset.focusKey = "destination";
243
+ select.disabled = pending;
244
+ var placeholder = document.createElement("option");
245
+ placeholder.value = "";
246
+ placeholder.textContent = "Choose a folder";
247
+ select.appendChild(placeholder);
248
+ moveDestinations(current.folderId).forEach(function (dest) {
249
+ var option = document.createElement("option");
250
+ option.value = dest.id;
251
+ option.textContent = dest.label;
252
+ select.appendChild(option);
253
+ });
254
+ select.value = current.destinationId || "";
255
+ select.addEventListener("change", function () { patch({ destinationId: select.value, error: "" }); });
256
+ label.appendChild(select);
257
+ body.appendChild(label);
258
+ }
259
+
260
+ if (current.mode === "delete" && preview.canDeleteSessions) {
261
+ var confirmRow = document.createElement("label");
262
+ confirmRow.className = "session-folder-delete-confirm";
263
+ var box = document.createElement("input");
264
+ box.type = "checkbox";
265
+ box.checked = current.confirmDelete;
266
+ box.disabled = pending;
267
+ box.dataset.focusKey = "confirm";
268
+ box.addEventListener("change", function () { patch({ confirmDelete: box.checked, error: "" }); });
269
+ var text = document.createElement("span");
270
+ text.textContent = "I understand these " + phrase(preview.totalCount, "session") + " will be permanently deleted.";
271
+ confirmRow.appendChild(box);
272
+ confirmRow.appendChild(text);
273
+ body.appendChild(confirmRow);
274
+ }
275
+ }
276
+
277
+ // The server's authoritative count and snapshot token.
278
+ export function handleFolderDeletePreview(msg) {
279
+ var current = state();
280
+ if (!current || msg.requestId !== current.requestId || msg.folderId !== current.folderId) return;
281
+ var preview = {
282
+ sessionCount: msg.sessionCount | 0, workerCount: msg.workerCount | 0, totalCount: msg.totalCount | 0, token: msg.token,
283
+ canDeleteSessions: msg.canDeleteSessions === true, deleteBlockedReason: msg.deleteBlockedReason || "",
284
+ };
285
+ var mode = current.mode === "delete" && !preview.canDeleteSessions ? "unfiled" : current.mode;
286
+ patch({ preview: preview, phase: current.phase === "pending" ? "ready" : "ready", mode: mode, confirmDelete: false });
287
+ }
288
+
289
+ // Acknowledgement or refusal of the delete request. A stale snapshot arrives
290
+ // as an error followed by a fresh preview; the dialog stays open on any error.
291
+ export function handleFolderDeleteState(msg) {
292
+ var current = state();
293
+ if (!current || !msg.requestId || msg.requestId !== current.requestId) return false;
294
+ if (msg.error) {
295
+ patch({ phase: current.preview ? "ready" : "error", error: msg.error, confirmDelete: false });
296
+ return true;
297
+ }
298
+ if (msg.folderDeleted === current.folderId) closeFolderModal();
299
+ return true;
300
+ }
301
+
302
+ store.subscribe(function (next, previous) {
303
+ if (next.sessionFolderDelete !== previous.sessionFolderDelete) paint();
304
+ // Retry depends on the connection, so a change in it repaints the dialog.
305
+ else if (next.connected !== previous.connected && next.sessionFolderDelete) paint();
306
+ var current = next.sessionFolderDelete;
307
+ if (!current || !previous.connected || next.connected) return;
308
+ if (current.phase === "pending") {
309
+ patch({ phase: "ready", error: "Connection lost. Check the folder before trying again." });
310
+ } else if (current.phase === "loading") {
311
+ patch({ phase: "error", error: "Connection lost while checking the folder. Retry once you are back online." });
312
+ }
313
+ });