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,173 @@
1
+ // Custom modal dialogs for session folders (rename entry and the folder picker). Built on the existing confirm-dialog look; no browser-native
2
+ // prompt, confirm or alert. The open dialog is tracked in the store
3
+ // (sessionFolderModal); this module only owns the functions.
4
+
5
+ import { store } from './store.js';
6
+ import { escapeHtml } from './utils.js';
7
+ import { iconHtml, refreshIcons } from './icons.js';
8
+ import { UNFILED_ID } from './session-folder-layout.js';
9
+
10
+ var FOCUSABLE = "button:not([disabled]), input, [tabindex]:not([tabindex='-1'])";
11
+
12
+ function modalState() {
13
+ return store.get('sessionFolderModal');
14
+ }
15
+
16
+ export function closeFolderModal() {
17
+ var modal = modalState();
18
+ if (!modal) return;
19
+ store.set({ sessionFolderModal: null, sessionFolderDelete: null });
20
+ document.removeEventListener("keydown", onModalKey, true);
21
+ modal.root.remove();
22
+ if (modal.restoreFocus && document.body.contains(modal.restoreFocus)) modal.restoreFocus.focus();
23
+ }
24
+
25
+ export function isFolderModalOpen() {
26
+ return !!modalState();
27
+ }
28
+
29
+ // Dismissal by the user (Escape, backdrop) can be vetoed by the dialog, for
30
+ // example while a server request it started is still in flight.
31
+ function requestClose() {
32
+ var modal = modalState();
33
+ if (modal && typeof modal.canClose === "function" && !modal.canClose()) return;
34
+ closeFolderModal();
35
+ }
36
+
37
+ function onModalKey(event) {
38
+ var modal = modalState();
39
+ if (!modal) return;
40
+ if (event.key === "Escape") {
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+ requestClose();
44
+ return;
45
+ }
46
+ if (event.key !== "Tab") return;
47
+ var focusable = modal.root.querySelectorAll(FOCUSABLE);
48
+ if (!focusable.length) return;
49
+ var first = focusable[0];
50
+ var last = focusable[focusable.length - 1];
51
+ var active = document.activeElement;
52
+ if (!modal.root.contains(active)) { event.preventDefault(); first.focus(); }
53
+ else if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); }
54
+ else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
55
+ }
56
+
57
+ export function createModal(kind, title, bodyEl, actions, canClose) {
58
+ closeFolderModal();
59
+ var restoreFocus = document.activeElement;
60
+ var root = document.createElement("div");
61
+ root.className = "session-folder-modal";
62
+ root.dataset.modalKind = kind;
63
+ var backdrop = document.createElement("div");
64
+ backdrop.className = "confirm-backdrop";
65
+ backdrop.addEventListener("click", requestClose);
66
+ var dialog = document.createElement("div");
67
+ dialog.className = "confirm-dialog session-folder-dialog";
68
+ dialog.setAttribute("role", "dialog");
69
+ dialog.setAttribute("aria-modal", "true");
70
+ dialog.setAttribute("aria-label", title);
71
+ var heading = document.createElement("div");
72
+ heading.className = "session-folder-dialog-title";
73
+ heading.textContent = title;
74
+ dialog.appendChild(heading);
75
+ dialog.appendChild(bodyEl);
76
+ var bar = document.createElement("div");
77
+ bar.className = "confirm-actions";
78
+ for (var i = 0; i < actions.length; i++) bar.appendChild(actions[i]);
79
+ dialog.appendChild(bar);
80
+ root.appendChild(backdrop);
81
+ root.appendChild(dialog);
82
+ store.set({ sessionFolderModal: { root: root, kind: kind, restoreFocus: restoreFocus, canClose: canClose || null } });
83
+ document.addEventListener("keydown", onModalKey, true);
84
+ document.body.appendChild(root);
85
+ refreshIcons();
86
+ return dialog;
87
+ }
88
+
89
+ export function button(label, className, onClick) {
90
+ var el = document.createElement("button");
91
+ el.type = "button";
92
+ el.className = "confirm-btn " + className;
93
+ el.textContent = label;
94
+ el.addEventListener("click", onClick);
95
+ return el;
96
+ }
97
+
98
+ export function openFolderNameDialog(options) {
99
+ var body = document.createElement("div");
100
+ body.className = "session-folder-dialog-body";
101
+ var input = document.createElement("input");
102
+ input.type = "text";
103
+ input.className = "session-folder-input";
104
+ input.maxLength = 60;
105
+ input.value = options.initial || "";
106
+ input.setAttribute("aria-label", "Folder name");
107
+ input.placeholder = "Folder name";
108
+ var hint = document.createElement("div");
109
+ hint.className = "session-folder-input-hint";
110
+ hint.setAttribute("role", "alert");
111
+ body.appendChild(input);
112
+ body.appendChild(hint);
113
+ function submit() {
114
+ var name = input.value.replace(/\s+/g, " ").trim();
115
+ if (!name) { hint.textContent = "Enter a folder name."; input.focus(); return; }
116
+ var lower = name.toLowerCase();
117
+ if (lower === "favorites" || lower === "unfiled") { hint.textContent = "That name is reserved."; input.focus(); return; }
118
+ var taken = options.existing || [];
119
+ for (var i = 0; i < taken.length; i++) {
120
+ if (taken[i].id !== options.selfId && taken[i].name.toLowerCase() === lower) { hint.textContent = "A folder with that name already exists."; input.focus(); return; }
121
+ }
122
+ closeFolderModal();
123
+ options.onSubmit(name);
124
+ }
125
+ input.addEventListener("keydown", function (event) {
126
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
127
+ });
128
+ createModal("name", options.title, body, [
129
+ button("Cancel", "confirm-cancel", closeFolderModal),
130
+ button(options.submitLabel || "Save", "confirm-ok", submit),
131
+ ]);
132
+ input.focus();
133
+ input.select();
134
+ }
135
+
136
+ // Lists every custom folder and Unfiled (real destinations only; Favorites is a tag); the current one is marked.
137
+ // "New folder" hands off to the caller, which creates and then moves.
138
+ export function openFolderPicker(options) {
139
+ var body = document.createElement("div");
140
+ body.className = "session-folder-picker";
141
+ body.setAttribute("role", "listbox");
142
+ body.setAttribute("aria-label", "Folders");
143
+ var choices = [];
144
+ for (var i = 0; i < options.folders.length; i++) choices.push({ id: options.folders[i].id, label: options.folders[i].name, icon: "folder" });
145
+ choices.push({ id: UNFILED_ID, label: "Unfiled", icon: "inbox" });
146
+ choices.forEach(function (choice) {
147
+ var row = document.createElement("button");
148
+ row.type = "button";
149
+ row.className = "session-folder-choice" + (choice.id === options.current ? " current" : "");
150
+ row.setAttribute("role", "option");
151
+ row.setAttribute("aria-selected", String(choice.id === options.current));
152
+ row.innerHTML = iconHtml(choice.icon) + '<span class="session-folder-choice-label">' + escapeHtml(choice.label) + "</span>" + (choice.id === options.current ? iconHtml("check") : "");
153
+ row.addEventListener("click", function () {
154
+ closeFolderModal();
155
+ if (choice.id !== options.current) options.onPick(choice.id === UNFILED_ID ? null : choice.id);
156
+ });
157
+ body.appendChild(row);
158
+ });
159
+ if (options.onNewFolder) {
160
+ var add = document.createElement("button");
161
+ add.type = "button";
162
+ add.className = "session-folder-choice session-folder-choice-new";
163
+ add.innerHTML = iconHtml("folder-plus") + '<span class="session-folder-choice-label">New folder</span>';
164
+ add.addEventListener("click", function () {
165
+ closeFolderModal();
166
+ options.onNewFolder();
167
+ });
168
+ body.appendChild(add);
169
+ }
170
+ createModal("picker", options.title, body, [button("Cancel", "confirm-cancel", closeFolderModal)]);
171
+ var current = body.querySelector(".current") || body.firstChild;
172
+ if (current) current.focus();
173
+ }
@@ -0,0 +1,214 @@
1
+ // Live-preview drag and drop for reordering custom folders (desktop pointer).
2
+ //
3
+ // While a folder block is dragged, its neighbours slide aside (CSS transforms,
4
+ // expanded contents included) into the proposed order and the dragged block
5
+ // shows as a dashed placeholder in its proposed slot. Nothing is sent until a
6
+ // valid drop; cancel, Escape, outside drops, rerenders, project/DM changes and
7
+ // disconnects all restore the original order. Favorites and Unfiled are not
8
+ // part of the movable set, so they can neither move nor be displaced.
9
+ //
10
+ // Hit testing uses each block's ORIGINAL layout position (measured once at drag
11
+ // start, parent-relative), never its transformed position, so moving blocks
12
+ // cannot shift their own hitboxes and cause flicker. The drag lives in the store
13
+ // (sessionFolderDrag); this module only owns the functions.
14
+
15
+ import { store } from './store.js';
16
+ import { sendFolderOp } from './session-folders.js';
17
+
18
+ var SETTLE_FALLBACK_MS = 1500;
19
+ var WATCHDOG_MS = 500;
20
+ var STALE_MS = 1500;
21
+
22
+ function drag() {
23
+ var current = store.get('sessionFolderDrag');
24
+ return current && current.type === "folder" ? current : null;
25
+ }
26
+
27
+ function blockFor(id) {
28
+ return document.querySelector('.session-folder-folder[data-folder-id="' + id + '"]');
29
+ }
30
+
31
+ function allBlocks() {
32
+ return document.querySelectorAll(".session-folder-folder.folder-dnd");
33
+ }
34
+
35
+ function sameOrder(a, b) {
36
+ if (a.length !== b.length) return false;
37
+ for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
38
+ return true;
39
+ }
40
+
41
+ function findEntry(layout, id) {
42
+ for (var i = 0; i < layout.length; i++) if (layout[i].id === id) return layout[i];
43
+ return null;
44
+ }
45
+
46
+ // Pure: where the dragged folder would land for a pointer at `y` (parent-relative).
47
+ export function proposeOrder(layout, id, y) {
48
+ var others = layout.filter(function (entry) { return entry.id !== id; });
49
+ var index = 0;
50
+ for (var i = 0; i < others.length; i++) {
51
+ if (others[i].top + others[i].height / 2 < y) index += 1;
52
+ }
53
+ var order = others.map(function (entry) { return entry.id; });
54
+ order.splice(index, 0, id);
55
+ return order;
56
+ }
57
+
58
+ // Pure: translateY for each folder so the stack matches `order`.
59
+ export function displacements(layout, gap, order) {
60
+ var start = layout[0].top;
61
+ for (var s = 1; s < layout.length; s++) if (layout[s].top < start) start = layout[s].top;
62
+ var result = {};
63
+ var cursor = start;
64
+ for (var i = 0; i < order.length; i++) {
65
+ var entry = findEntry(layout, order[i]);
66
+ result[order[i]] = Math.round((cursor - entry.top) * 100) / 100;
67
+ cursor += entry.height + gap;
68
+ }
69
+ return result;
70
+ }
71
+
72
+ function paint(state) {
73
+ var blocks = allBlocks();
74
+ var shifts = displacements(state.layout, state.gap, state.proposed);
75
+ for (var i = 0; i < blocks.length; i++) {
76
+ var id = blocks[i].dataset.folderId;
77
+ var shift = shifts[id] || 0;
78
+ blocks[i].style.transform = shift ? "translateY(" + shift + "px)" : "";
79
+ blocks[i].classList.toggle("folder-dnd-source", id === state.id);
80
+ }
81
+ }
82
+
83
+ function mark(state) {
84
+ var movable = document.querySelectorAll(".session-folder-folder");
85
+ for (var i = 0; i < movable.length; i++) movable[i].classList.add("folder-dnd");
86
+ paint(state);
87
+ }
88
+
89
+ function unmark() {
90
+ var blocks = allBlocks();
91
+ for (var i = 0; i < blocks.length; i++) {
92
+ blocks[i].style.transform = "";
93
+ blocks[i].classList.remove("folder-dnd", "folder-dnd-source");
94
+ }
95
+ }
96
+
97
+ function removeListeners() {
98
+ document.removeEventListener("dragover", onDragOver, true);
99
+ document.removeEventListener("drop", onDrop, true);
100
+ document.removeEventListener("dragend", onDragEnd, true);
101
+ document.removeEventListener("keydown", onKey, true);
102
+ }
103
+
104
+ export function cancelFolderDrag() {
105
+ var state = drag();
106
+ if (!state) return;
107
+ removeListeners();
108
+ unmark();
109
+ store.set({ sessionFolderDrag: null });
110
+ }
111
+
112
+ function onDragOver(event) {
113
+ var state = drag();
114
+ if (!state || state.settling) return;
115
+ var root = blockFor(state.id);
116
+ var parent = root && root.parentElement;
117
+ if (!parent) return;
118
+ var rect = parent.getBoundingClientRect();
119
+ var inside = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
120
+ var next = inside ? proposeOrder(state.layout, state.id, event.clientY - rect.top) : state.original;
121
+ var now = Date.now();
122
+ if (!sameOrder(next, state.proposed) || now - state.seen > 250 || state.inside !== inside) {
123
+ var updated = Object.assign({}, state, { proposed: next, seen: now, inside: inside });
124
+ store.set({ sessionFolderDrag: updated });
125
+ if (!sameOrder(next, state.proposed)) paint(updated);
126
+ }
127
+ if (inside) {
128
+ event.preventDefault();
129
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
130
+ } else if (event.dataTransfer) {
131
+ event.dataTransfer.dropEffect = "none";
132
+ }
133
+ }
134
+
135
+ function onDrop(event) {
136
+ var state = drag();
137
+ if (!state || state.settling) return;
138
+ if (!state.inside || sameOrder(state.proposed, state.original)) { cancelFolderDrag(); return; }
139
+ event.preventDefault();
140
+ var at = state.proposed.indexOf(state.id);
141
+ var beforeId = at < state.proposed.length - 1 ? state.proposed[at + 1] : null;
142
+ var targetId = beforeId || state.proposed[at - 1];
143
+ // Keep the previewed order on screen until the server's snapshot rerenders it.
144
+ store.set({ sessionFolderDrag: Object.assign({}, state, { settling: true, baseState: store.get('sessionFolders') }) });
145
+ sendFolderOp({ op: "reorder_folder", folderId: state.id, targetId: targetId, insertBefore: !!beforeId });
146
+ var token = state.id;
147
+ setTimeout(function () {
148
+ var current = drag();
149
+ if (current && current.settling && current.id === token) cancelFolderDrag();
150
+ }, SETTLE_FALLBACK_MS);
151
+ }
152
+
153
+ function onDragEnd() {
154
+ var state = drag();
155
+ if (state && !state.settling) cancelFolderDrag();
156
+ }
157
+
158
+ function onKey(event) {
159
+ if (event.key === "Escape" && drag()) cancelFolderDrag();
160
+ }
161
+
162
+ function watchdog() {
163
+ var state = drag();
164
+ if (!state || state.settling) return;
165
+ if (Date.now() - state.seen > STALE_MS) { cancelFolderDrag(); return; }
166
+ setTimeout(watchdog, WATCHDOG_MS);
167
+ }
168
+
169
+ // Called after a list rerender replaced the DOM: the preview is re-applied to the
170
+ // fresh blocks, or dropped once the server snapshot it was waiting for arrived.
171
+ export function reapplyFolderDragPreview() {
172
+ var state = drag();
173
+ if (!state) return;
174
+ if (state.settling && store.get('sessionFolders') !== state.baseState) { cancelFolderDrag(); return; }
175
+ if (!blockFor(state.id)) { cancelFolderDrag(); return; }
176
+ mark(state);
177
+ }
178
+
179
+ function ghostFor(label) {
180
+ var ghost = document.createElement("div");
181
+ ghost.className = "session-folder-drag-ghost";
182
+ ghost.textContent = label;
183
+ document.body.appendChild(ghost);
184
+ setTimeout(function () { ghost.remove(); }, 0);
185
+ return ghost;
186
+ }
187
+
188
+ export function wireFolderDrag(header, folderId, label) {
189
+ header.setAttribute("draggable", "true");
190
+ header.addEventListener("dragstart", function (event) {
191
+ var root = header.closest(".session-folder-folder");
192
+ var parent = root && root.parentElement;
193
+ if (!parent) return;
194
+ var parentRect = parent.getBoundingClientRect();
195
+ var blocks = Array.prototype.filter.call(parent.children, function (child) { return child.classList.contains("session-folder-folder"); });
196
+ var layout = blocks.map(function (block) {
197
+ var rect = block.getBoundingClientRect();
198
+ return { id: block.dataset.folderId, top: rect.top - parentRect.top, height: rect.height };
199
+ });
200
+ var gap = layout.length > 1 ? Math.max(0, layout[1].top - (layout[0].top + layout[0].height)) : 2;
201
+ var original = layout.map(function (entry) { return entry.id; });
202
+ var state = { type: "folder", id: folderId, original: original, proposed: original.slice(), layout: layout, gap: gap, seen: Date.now(), inside: true, settling: false };
203
+ store.set({ sessionFolderDrag: state });
204
+ event.dataTransfer.effectAllowed = "move";
205
+ event.dataTransfer.setData("text/plain", folderId);
206
+ if (event.dataTransfer.setDragImage) event.dataTransfer.setDragImage(ghostFor(label), 14, 14);
207
+ document.addEventListener("dragover", onDragOver, true);
208
+ document.addEventListener("drop", onDrop, true);
209
+ document.addEventListener("dragend", onDragEnd, true);
210
+ document.addEventListener("keydown", onKey, true);
211
+ setTimeout(function () { var live = drag(); if (live) mark(live); }, 0);
212
+ setTimeout(watchdog, WATCHDOG_MS);
213
+ });
214
+ }
@@ -0,0 +1,184 @@
1
+ // The temporary "new folder" draft: a folder header row in the continuous folder
2
+ // list (after the custom folders, before Unfiled) with an editable name. It is not
3
+ // a folder until the server acknowledges the creation, so it is never part of
4
+ // drag-and-drop, ordering or counts. The draft lives in the store
5
+ // (sessionFolderCreate) and survives list rerenders and server updates.
6
+
7
+ import { store } from './store.js';
8
+ import { iconHtml } from './icons.js';
9
+ import { currentFolderState, cancelFolderCreate, updateFolderCreateDraft, submitFolderCreate } from './session-folders.js';
10
+
11
+ // The desktop list and the mobile sheet can both be in the DOM, but only the
12
+ // visible one is the user's; a hidden copy must never be captured or focused.
13
+ function findDraftInput() {
14
+ var inputs = document.querySelectorAll(".session-folder-create-input");
15
+ for (var i = 0; i < inputs.length; i++) {
16
+ if (inputs[i] === document.activeElement || inputs[i].getClientRects().length) return inputs[i];
17
+ }
18
+ return null;
19
+ }
20
+
21
+ // Called right before a list rerender discards the row: remembers the draft,
22
+ // the caret and whether the input had focus, so the rebuilt row can restore them.
23
+ export function captureFolderDraft() {
24
+ var create = store.get('sessionFolderCreate');
25
+ var input = findDraftInput();
26
+ if (!create || !input) return;
27
+ // A row built moments ago still has its focus restore queued; it says nothing yet.
28
+ if (input.dataset.focusPending === "1") return;
29
+ var focused = document.activeElement === input;
30
+ if (create.draft === input.value && create.focus === focused && create.selStart === input.selectionStart && create.selEnd === input.selectionEnd) return;
31
+ store.set({ sessionFolderCreate: Object.assign({}, create, { draft: input.value, focus: focused, selStart: input.selectionStart, selEnd: input.selectionEnd }) });
32
+ }
33
+
34
+ // Only changes the user can see need a rebuild; typing, caret, focus and scroll do not.
35
+ export function folderDraftNeedsRender(state, previous) {
36
+ var a = state.sessionFolderCreate;
37
+ var b = previous.sessionFolderCreate;
38
+ if (!a !== !b) return true;
39
+ return !!(a && b && (a.pendingId !== b.pendingId || a.error !== b.error || a.sessionId !== b.sessionId));
40
+ }
41
+
42
+ export function focusFolderDraft() {
43
+ var input = findDraftInput();
44
+ if (input) {
45
+ input.focus();
46
+ if (input.scrollIntoView) input.scrollIntoView({ block: "nearest" });
47
+ }
48
+ }
49
+
50
+ function validate(name) {
51
+ if (!name) return "Enter a folder name.";
52
+ var lower = name.toLowerCase();
53
+ if (lower === "favorites" || lower === "unfiled") return "That name is reserved.";
54
+ var folders = currentFolderState().folders;
55
+ for (var i = 0; i < folders.length; i++) {
56
+ if (folders[i].name.toLowerCase() === lower) return "A folder with that name already exists.";
57
+ }
58
+ return "";
59
+ }
60
+
61
+ function submit(input) {
62
+ var create = store.get('sessionFolderCreate');
63
+ if (!create || create.pendingId) return;
64
+ var name = input.value.replace(/\s+/g, " ").trim();
65
+ var problem = validate(name);
66
+ if (problem) {
67
+ updateFolderCreateDraft({ draft: input.value, error: problem, focus: true, selStart: input.selectionStart, selEnd: input.selectionEnd });
68
+ return;
69
+ }
70
+ updateFolderCreateDraft({ draft: input.value, selStart: input.selectionStart, selEnd: input.selectionEnd });
71
+ submitFolderCreate(name);
72
+ }
73
+
74
+ function cancel(surface) {
75
+ cancelFolderCreate();
76
+ queueMicrotask(function () {
77
+ var button = document.querySelector('[data-new-folder-button="' + surface + '"]');
78
+ if (button && button.getClientRects().length) button.focus();
79
+ });
80
+ }
81
+
82
+ function iconButton(label, icon, primary, onClick, disabled) {
83
+ var btn = document.createElement("button");
84
+ btn.type = "button";
85
+ btn.className = "session-folder-create-btn" + (primary ? " primary" : "");
86
+ btn.setAttribute("aria-label", label);
87
+ btn.title = label;
88
+ btn.disabled = disabled;
89
+ btn.innerHTML = iconHtml(icon);
90
+ btn.addEventListener("click", onClick);
91
+ return btn;
92
+ }
93
+
94
+ export function renderFolderDraft(surface) {
95
+ var create = store.get('sessionFolderCreate');
96
+ if (!create) return null;
97
+ var busy = !!create.pendingId;
98
+ var root = document.createElement("section");
99
+ root.className = "session-folder session-folder-draft" + (surface === "mobile" ? " is-mobile" : "");
100
+ root.setAttribute("role", "group");
101
+ root.setAttribute("aria-label", "New folder");
102
+ root.setAttribute("aria-busy", String(busy));
103
+
104
+ var header = document.createElement("div");
105
+ header.className = "session-folder-header";
106
+ var main = document.createElement("div");
107
+ main.className = "session-folder-draft-main";
108
+ var spacer = document.createElement("span");
109
+ spacer.className = "session-folder-chevron session-folder-draft-spacer";
110
+ var icon = document.createElement("span");
111
+ icon.className = "session-folder-icon";
112
+ icon.innerHTML = iconHtml("folder");
113
+
114
+ var input = document.createElement("input");
115
+ input.type = "text";
116
+ input.className = "session-folder-create-input";
117
+ input.maxLength = 60;
118
+ input.value = create.draft;
119
+ input.readOnly = busy;
120
+ input.placeholder = "New folder";
121
+ input.autocomplete = "off";
122
+ input.spellcheck = false;
123
+ input.setAttribute("aria-label", "Folder name");
124
+ input.setAttribute("aria-invalid", String(!!create.error));
125
+ var errorId = "session-folder-create-error-" + surface;
126
+ input.setAttribute("aria-describedby", errorId);
127
+ if (create.focus) input.dataset.focusPending = "1";
128
+ var remember = function () {
129
+ updateFolderCreateDraft({ draft: input.value, selStart: input.selectionStart, selEnd: input.selectionEnd });
130
+ };
131
+ input.addEventListener("input", function () {
132
+ updateFolderCreateDraft({ draft: input.value, error: "", selStart: input.selectionStart, selEnd: input.selectionEnd });
133
+ });
134
+ input.addEventListener("keyup", remember);
135
+ input.addEventListener("mouseup", remember);
136
+ input.addEventListener("keydown", function (event) {
137
+ // Enter and Escape belong to the input method while it is composing (Korean,
138
+ // Japanese, Chinese); keyCode 229 covers browsers that omit isComposing.
139
+ if (event.isComposing || event.keyCode === 229) return;
140
+ if (event.key === "Enter") { event.preventDefault(); submit(input); }
141
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); cancel(surface); }
142
+ });
143
+
144
+ main.appendChild(spacer);
145
+ main.appendChild(icon);
146
+ main.appendChild(input);
147
+ header.appendChild(main);
148
+ var actions = document.createElement("div");
149
+ actions.className = "session-folder-create-actions";
150
+ actions.appendChild(iconButton("Create folder", "check", true, function () { submit(input); }, busy));
151
+ actions.appendChild(iconButton("Cancel new folder", "x", false, function () { cancel(surface); }, false));
152
+ header.appendChild(actions);
153
+ root.appendChild(header);
154
+
155
+ var error = document.createElement("div");
156
+ error.id = errorId;
157
+ error.className = "session-folder-create-error";
158
+ error.setAttribute("role", "alert");
159
+ error.textContent = create.error || "";
160
+ root.appendChild(error);
161
+
162
+ // The row is attached by the caller; restore focus, caret and (once) scroll afterwards.
163
+ queueMicrotask(function () {
164
+ input.removeAttribute("data-focus-pending");
165
+ var latest = store.get('sessionFolderCreate');
166
+ if (!latest) return;
167
+ if (latest.focus) {
168
+ var live = findDraftInput();
169
+ if (live) {
170
+ // Restoring focus after a rerender must not scroll; opening scrolls once, below.
171
+ live.focus({ preventScroll: true });
172
+ var end = live.value.length;
173
+ var start = typeof latest.selStart === "number" ? Math.min(latest.selStart, end) : end;
174
+ var stop = typeof latest.selEnd === "number" ? Math.min(latest.selEnd, end) : end;
175
+ live.setSelectionRange(start, stop);
176
+ }
177
+ }
178
+ if (latest.scroll) {
179
+ if (root.scrollIntoView && root.getClientRects().length) root.scrollIntoView({ block: "nearest" });
180
+ store.set({ sessionFolderCreate: Object.assign({}, latest, { scroll: false }) });
181
+ }
182
+ });
183
+ return root;
184
+ }