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.
- package/lib/daemon.js +9 -0
- package/lib/new-session-default.js +45 -0
- package/lib/project-connection.js +4 -2
- package/lib/project-session-creation.js +107 -0
- package/lib/project-session-delete.js +21 -3
- package/lib/project-session-folder-delete.js +169 -0
- package/lib/project-session-folders.js +272 -0
- package/lib/project-sessions.js +46 -26
- package/lib/project.js +2 -0
- package/lib/public/app.js +10 -0
- package/lib/public/css/filebrowser.css +3 -3
- package/lib/public/css/mates.css +1 -1
- package/lib/public/css/mobile-nav.css +0 -128
- package/lib/public/css/session-folders.css +383 -0
- package/lib/public/css/sidebar.css +28 -545
- package/lib/public/index.html +3 -21
- package/lib/public/modules/app-favicon.js +5 -4
- package/lib/public/modules/app-messages.js +13 -0
- package/lib/public/modules/input.js +3 -0
- package/lib/public/modules/project-switcher.js +4 -4
- package/lib/public/modules/session-create-form.js +428 -0
- package/lib/public/modules/session-create.js +338 -0
- package/lib/public/modules/session-folder-collapse.js +192 -0
- package/lib/public/modules/session-folder-context.js +215 -0
- package/lib/public/modules/session-folder-delete.js +313 -0
- package/lib/public/modules/session-folder-dialogs.js +173 -0
- package/lib/public/modules/session-folder-dnd.js +214 -0
- package/lib/public/modules/session-folder-draft.js +184 -0
- package/lib/public/modules/session-folder-layout.js +133 -0
- package/lib/public/modules/session-folder-toolbar.js +185 -0
- package/lib/public/modules/session-folder-view-menu.js +187 -0
- package/lib/public/modules/session-folders.js +95 -0
- package/lib/public/modules/session-list-search.js +108 -0
- package/lib/public/modules/session-presence-ui.js +44 -0
- package/lib/public/modules/sidebar-mobile.js +38 -182
- package/lib/public/modules/sidebar-session-folders-ui.js +382 -0
- package/lib/public/modules/sidebar-sessions.js +141 -718
- package/lib/public/modules/sidebar.js +10 -2
- package/lib/public/modules/terminal.js +2 -2
- package/lib/public/modules/tool-palette.js +37 -219
- package/lib/public/style.css +1 -0
- package/lib/server.js +4 -0
- package/lib/session-folders.js +414 -0
- package/lib/sessions.js +7 -5
- package/lib/users-session-folder-preferences.js +69 -0
- package/lib/users.js +6 -0
- package/lib/ws-schema.js +11 -0
- package/package.json +1 -1
- package/lib/public/modules/session-creation-cta.js +0 -46
- package/lib/public/modules/tool-palette-overlays.js +0 -159
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Pure projection of sidebar units into folder sections. No DOM, no store.
|
|
2
|
+
//
|
|
3
|
+
// A unit is one organizable root: a standalone session or a Driver with all of
|
|
4
|
+
// its Workers. { key, title, createdAt, lastActivity, item }. Units whose key
|
|
5
|
+
// is null (loop runs, split groups, orphan Workers) cannot be filed, so they
|
|
6
|
+
// always live with the ordinary, unfiled sessions.
|
|
7
|
+
|
|
8
|
+
export var FAVORITES_ID = "favorites";
|
|
9
|
+
export var UNFILED_ID = "unfiled";
|
|
10
|
+
|
|
11
|
+
export function defaultFolderState() {
|
|
12
|
+
return { folders: [], assignments: {}, favorites: [], orders: {}, collapsed: {}, view: { sort: "activity", direction: "desc" } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sortUnits(units, view, order) {
|
|
16
|
+
var position = {};
|
|
17
|
+
var list = order || [];
|
|
18
|
+
for (var i = 0; i < list.length; i++) position[list[i]] = i;
|
|
19
|
+
var sort = view.sort;
|
|
20
|
+
var sign = view.direction === "asc" ? 1 : -1;
|
|
21
|
+
return units.slice().sort(function (a, b) {
|
|
22
|
+
var result = 0;
|
|
23
|
+
if (sort === "manual") {
|
|
24
|
+
var ap = a.key !== null && position[a.key] !== undefined ? position[a.key] : Number.MAX_SAFE_INTEGER;
|
|
25
|
+
var bp = b.key !== null && position[b.key] !== undefined ? position[b.key] : Number.MAX_SAFE_INTEGER;
|
|
26
|
+
result = ap - bp;
|
|
27
|
+
} else if (sort === "title") {
|
|
28
|
+
result = String(a.title || "").toLowerCase().localeCompare(String(b.title || "").toLowerCase()) * (view.direction === "desc" ? -1 : 1);
|
|
29
|
+
} else if (sort === "created") {
|
|
30
|
+
result = ((a.createdAt || 0) - (b.createdAt || 0)) * sign;
|
|
31
|
+
} else {
|
|
32
|
+
result = ((a.lastActivity || 0) - (b.lastActivity || 0)) * sign;
|
|
33
|
+
}
|
|
34
|
+
if (result !== 0) return result;
|
|
35
|
+
return (b.lastActivity || 0) - (a.lastActivity || 0);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Favorites are a tag curated by hand, so they always keep their own order
|
|
40
|
+
// whatever the view sort is. A favorite also stays in its real folder.
|
|
41
|
+
function favoriteOrder(units, order) {
|
|
42
|
+
return sortUnits(units, { sort: "manual", direction: "desc" }, order);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isFavorite(state, key) {
|
|
46
|
+
return key !== null && key !== undefined && (state.favorites || []).indexOf(key) !== -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function containerOf(state, unit) {
|
|
50
|
+
if (unit.key === null) return UNFILED_ID;
|
|
51
|
+
var id = state.assignments[unit.key];
|
|
52
|
+
for (var i = 0; i < state.folders.length; i++) if (state.folders[i].id === id) return id;
|
|
53
|
+
return UNFILED_ID;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns { favorites: [units], sections }. Folders are the only layout; the
|
|
57
|
+
// view sort and direction order sessions inside each folder, never the folders
|
|
58
|
+
// themselves. Sections: { id, type, label,
|
|
59
|
+
// units, collapsed, folderId }. While a search is active every section is
|
|
60
|
+
// expanded and empty ones disappear so matches are never hidden.
|
|
61
|
+
export function buildLayout(units, state, options) {
|
|
62
|
+
var opts = options || {};
|
|
63
|
+
var view = state.view;
|
|
64
|
+
var searching = opts.searching === true;
|
|
65
|
+
var favorites = [];
|
|
66
|
+
var ordinary = [];
|
|
67
|
+
for (var i = 0; i < units.length; i++) {
|
|
68
|
+
// The same unit may appear twice: once as a favorite, once in its folder.
|
|
69
|
+
if (isFavorite(state, units[i].key)) favorites.push(units[i]);
|
|
70
|
+
ordinary.push(units[i]);
|
|
71
|
+
}
|
|
72
|
+
var result = {
|
|
73
|
+
favorites: favoriteOrder(favorites, state.favorites),
|
|
74
|
+
favoritesCollapsed: !searching && state.collapsed[FAVORITES_ID] === true,
|
|
75
|
+
searching: searching,
|
|
76
|
+
sections: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
var byContainer = {};
|
|
80
|
+
for (var u = 0; u < ordinary.length; u++) {
|
|
81
|
+
var id = containerOf(state, ordinary[u]);
|
|
82
|
+
if (!byContainer[id]) byContainer[id] = [];
|
|
83
|
+
byContainer[id].push(ordinary[u]);
|
|
84
|
+
}
|
|
85
|
+
for (var f = 0; f < state.folders.length; f++) {
|
|
86
|
+
var folder = state.folders[f];
|
|
87
|
+
var members = sortUnits(byContainer[folder.id] || [], view, state.orders[folder.id]);
|
|
88
|
+
if (searching && members.length === 0) continue;
|
|
89
|
+
result.sections.push({ id: folder.id, type: "folder", label: folder.name, units: members, collapsed: !searching && state.collapsed[folder.id] === true, folderId: folder.id });
|
|
90
|
+
}
|
|
91
|
+
var unfiled = sortUnits(byContainer[UNFILED_ID] || [], view, state.orders[UNFILED_ID]);
|
|
92
|
+
if (!searching || unfiled.length) {
|
|
93
|
+
result.sections.push({ id: UNFILED_ID, type: "unfiled", label: "Unfiled", units: unfiled, collapsed: !searching && state.collapsed[UNFILED_ID] === true, folderId: null });
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// New order for one container after dropping `movingKey` next to `targetKey`
|
|
99
|
+
// (or at the end when targetKey is null). `currentKeys` is the container's
|
|
100
|
+
// displayed order, which already reflects the active sort.
|
|
101
|
+
export function orderAfterMove(currentKeys, movingKey, targetKey, insertBefore) {
|
|
102
|
+
var rest = currentKeys.filter(function (k) { return k !== movingKey; });
|
|
103
|
+
var at = targetKey === null ? -1 : rest.indexOf(targetKey);
|
|
104
|
+
if (at === -1) rest.push(movingKey);
|
|
105
|
+
else rest.splice(insertBefore === false ? at + 1 : at, 0, movingKey);
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The keys a container shows right now, in order. Used both to build the
|
|
110
|
+
// move payload and to decide whether a drop is allowed.
|
|
111
|
+
export function containerKeys(layout, containerId) {
|
|
112
|
+
var list;
|
|
113
|
+
if (containerId === FAVORITES_ID) list = layout.favorites;
|
|
114
|
+
else {
|
|
115
|
+
list = [];
|
|
116
|
+
for (var i = 0; i < layout.sections.length; i++) {
|
|
117
|
+
if (layout.sections[i].id === containerId) list = layout.sections[i].units;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return list.filter(function (u) { return u.key !== null; }).map(function (u) { return u.key; });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export var SORT_OPTIONS = [
|
|
124
|
+
{ sort: "activity", label: "Last activity", directions: { desc: "Recent", asc: "Oldest" } },
|
|
125
|
+
{ sort: "created", label: "Date created", directions: { desc: "Newest", asc: "Oldest" } },
|
|
126
|
+
{ sort: "title", label: "Title", directions: { asc: "A to Z", desc: "Z to A" } },
|
|
127
|
+
{ sort: "manual", label: "Manual order", directions: null },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// Switching sort starts at that sort's natural direction.
|
|
131
|
+
export function defaultDirectionFor(sort) {
|
|
132
|
+
return sort === "title" ? "asc" : "desc";
|
|
133
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The folders toolbar: New folder, View and a magnifier. Search replaces the
|
|
2
|
+
// controls in the same footprint (same row, same height) with a full-width input
|
|
3
|
+
// and an explicit close button; closing restores the controls and focus. State
|
|
4
|
+
// lives in the store (sessionSearch, sessionFolderCreate, sessionFolderViewMenu).
|
|
5
|
+
|
|
6
|
+
import { store } from './store.js';
|
|
7
|
+
import { iconHtml } from './icons.js';
|
|
8
|
+
import { openFolderCreate } from './session-folders.js';
|
|
9
|
+
import { toggleViewMenu, positionViewMenu } from './session-folder-view-menu.js';
|
|
10
|
+
import { captureCreateFocus } from './session-create-form.js';
|
|
11
|
+
import { captureFolderDraft, folderDraftNeedsRender, focusFolderDraft } from './session-folder-draft.js';
|
|
12
|
+
import {
|
|
13
|
+
openListSearch, closeListSearch, clearListSearchText, setListSearchQuery, rememberListSearchCaret,
|
|
14
|
+
getListSearchQuery, getListSearchMatchIds, listSearchNeedsRender
|
|
15
|
+
} from './session-list-search.js';
|
|
16
|
+
|
|
17
|
+
// Visible one wins: the desktop list and the mobile sheet can both be in the DOM.
|
|
18
|
+
function visible(selector) {
|
|
19
|
+
var nodes = document.querySelectorAll(selector);
|
|
20
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
21
|
+
if (nodes[i] === document.activeElement || nodes[i].getClientRects().length) return nodes[i];
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function focusMagnifier() {
|
|
27
|
+
queueMicrotask(function () {
|
|
28
|
+
var button = visible("[data-search-button]");
|
|
29
|
+
if (button) button.focus();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Called right before a list rerender discards the toolbar and the draft row.
|
|
34
|
+
export function captureFolderInputs() {
|
|
35
|
+
captureFolderDraft();
|
|
36
|
+
captureCreateFocus();
|
|
37
|
+
var state = store.get('sessionSearch');
|
|
38
|
+
var input = visible(".session-search-input");
|
|
39
|
+
if (!state || !state.open || !input || input.dataset.focusPending === "1") return;
|
|
40
|
+
rememberListSearchCaret(document.activeElement === input, input.selectionStart, input.selectionEnd);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function folderToolbarNeedsRender(state, previous) {
|
|
44
|
+
return folderDraftNeedsRender(state, previous) || listSearchNeedsRender(state, previous);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderSearchBar(bar, surface) {
|
|
48
|
+
var state = store.get('sessionSearch');
|
|
49
|
+
var query = getListSearchQuery();
|
|
50
|
+
var matches = getListSearchMatchIds();
|
|
51
|
+
bar.classList.add("is-searching");
|
|
52
|
+
|
|
53
|
+
var close = document.createElement("button");
|
|
54
|
+
close.type = "button";
|
|
55
|
+
close.className = "session-folder-tool session-search-close";
|
|
56
|
+
close.setAttribute("aria-label", "Close search");
|
|
57
|
+
close.title = "Close search (Esc)";
|
|
58
|
+
close.innerHTML = iconHtml("arrow-left");
|
|
59
|
+
close.addEventListener("click", function () { closeListSearch(); focusMagnifier(); });
|
|
60
|
+
|
|
61
|
+
var field = document.createElement("div");
|
|
62
|
+
field.className = "session-search-field";
|
|
63
|
+
var input = document.createElement("input");
|
|
64
|
+
input.type = "text";
|
|
65
|
+
input.className = "session-search-input";
|
|
66
|
+
input.dataset.surface = surface;
|
|
67
|
+
input.placeholder = "Search sessions";
|
|
68
|
+
input.value = query;
|
|
69
|
+
input.autocomplete = "off";
|
|
70
|
+
input.spellcheck = false;
|
|
71
|
+
input.setAttribute("aria-label", "Search sessions");
|
|
72
|
+
if (state.focus) input.dataset.focusPending = "1";
|
|
73
|
+
var remember = function () { rememberListSearchCaret(true, input.selectionStart, input.selectionEnd); };
|
|
74
|
+
input.addEventListener("input", function () {
|
|
75
|
+
setListSearchQuery(input.value);
|
|
76
|
+
remember();
|
|
77
|
+
syncClear();
|
|
78
|
+
});
|
|
79
|
+
input.addEventListener("keyup", remember);
|
|
80
|
+
input.addEventListener("mouseup", remember);
|
|
81
|
+
input.addEventListener("keydown", function (event) {
|
|
82
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
83
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeListSearch(); focusMagnifier(); }
|
|
84
|
+
});
|
|
85
|
+
input.addEventListener("blur", function () {
|
|
86
|
+
// Leaving an empty search closes it, as before; a rebuilt toolbar is not "leaving".
|
|
87
|
+
setTimeout(function () {
|
|
88
|
+
if (!document.contains(input) || getListSearchQuery()) return;
|
|
89
|
+
var active = document.activeElement;
|
|
90
|
+
if (active && active.closest && active.closest(".session-folder-toolbar")) return;
|
|
91
|
+
closeListSearch();
|
|
92
|
+
}, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
var count = document.createElement("span");
|
|
96
|
+
count.className = "session-search-count";
|
|
97
|
+
if (query && matches !== null) count.textContent = String(matches.size);
|
|
98
|
+
var clear = document.createElement("button");
|
|
99
|
+
clear.type = "button";
|
|
100
|
+
clear.className = "session-search-clear";
|
|
101
|
+
clear.setAttribute("aria-label", "Clear search text");
|
|
102
|
+
clear.title = "Clear text";
|
|
103
|
+
clear.innerHTML = iconHtml("x");
|
|
104
|
+
clear.hidden = !query;
|
|
105
|
+
clear.addEventListener("click", function () {
|
|
106
|
+
clearListSearchText();
|
|
107
|
+
input.value = "";
|
|
108
|
+
clear.hidden = true;
|
|
109
|
+
input.focus();
|
|
110
|
+
});
|
|
111
|
+
function syncClear() { clear.hidden = !input.value; }
|
|
112
|
+
|
|
113
|
+
field.appendChild(input);
|
|
114
|
+
field.appendChild(count);
|
|
115
|
+
field.appendChild(clear);
|
|
116
|
+
bar.appendChild(close);
|
|
117
|
+
bar.appendChild(field);
|
|
118
|
+
|
|
119
|
+
queueMicrotask(function () {
|
|
120
|
+
input.removeAttribute("data-focus-pending");
|
|
121
|
+
var latest = store.get('sessionSearch');
|
|
122
|
+
if (!latest || !latest.open || !latest.focus) return;
|
|
123
|
+
var live = visible(".session-search-input");
|
|
124
|
+
if (!live) return;
|
|
125
|
+
live.focus({ preventScroll: true });
|
|
126
|
+
var end = live.value.length;
|
|
127
|
+
var start = typeof latest.selStart === "number" ? Math.min(latest.selStart, end) : end;
|
|
128
|
+
var stop = typeof latest.selEnd === "number" ? Math.min(latest.selEnd, end) : end;
|
|
129
|
+
live.setSelectionRange(start, stop);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderFolderToolbar(surface) {
|
|
134
|
+
var create = store.get('sessionFolderCreate');
|
|
135
|
+
var search = store.get('sessionSearch');
|
|
136
|
+
var wrap = document.createElement("div");
|
|
137
|
+
wrap.className = "session-folder-tools" + (surface === "mobile" ? " is-mobile" : "");
|
|
138
|
+
var bar = document.createElement("div");
|
|
139
|
+
bar.className = "session-folder-toolbar" + (surface === "mobile" ? " is-mobile" : "");
|
|
140
|
+
wrap.appendChild(bar);
|
|
141
|
+
|
|
142
|
+
if (search && search.open) {
|
|
143
|
+
renderSearchBar(bar, surface);
|
|
144
|
+
return wrap;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
var add = document.createElement("button");
|
|
148
|
+
add.type = "button";
|
|
149
|
+
add.className = "session-folder-tool";
|
|
150
|
+
add.dataset.newFolderButton = surface;
|
|
151
|
+
add.setAttribute("aria-expanded", String(!!create));
|
|
152
|
+
add.innerHTML = iconHtml("folder-plus") + "<span>New folder</span>";
|
|
153
|
+
add.addEventListener("click", function () {
|
|
154
|
+
if (store.get('sessionFolderCreate')) {
|
|
155
|
+
focusFolderDraft();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
openFolderCreate();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
var view = document.createElement("button");
|
|
162
|
+
view.type = "button";
|
|
163
|
+
view.className = "session-folder-tool";
|
|
164
|
+
view.dataset.viewButton = surface;
|
|
165
|
+
view.setAttribute("aria-haspopup", "true");
|
|
166
|
+
var viewState = store.get('sessionFolderViewMenu');
|
|
167
|
+
view.setAttribute("aria-expanded", String(!!(viewState && viewState.surface === surface)));
|
|
168
|
+
view.innerHTML = iconHtml("sliders-horizontal") + "<span>View</span>";
|
|
169
|
+
view.addEventListener("click", function () { toggleViewMenu(surface); });
|
|
170
|
+
|
|
171
|
+
var magnifier = document.createElement("button");
|
|
172
|
+
magnifier.type = "button";
|
|
173
|
+
magnifier.className = "session-folder-tool session-folder-tool-icon";
|
|
174
|
+
magnifier.dataset.searchButton = surface;
|
|
175
|
+
magnifier.setAttribute("aria-label", "Search sessions");
|
|
176
|
+
magnifier.title = "Search sessions";
|
|
177
|
+
magnifier.innerHTML = iconHtml("search");
|
|
178
|
+
magnifier.addEventListener("click", function () { openListSearch(); });
|
|
179
|
+
|
|
180
|
+
bar.appendChild(add);
|
|
181
|
+
bar.appendChild(view);
|
|
182
|
+
bar.appendChild(magnifier);
|
|
183
|
+
if (viewState && viewState.surface === surface) queueMicrotask(positionViewMenu);
|
|
184
|
+
return wrap;
|
|
185
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Compact, non-modal View dropdown for the session folders toolbar. It floats
|
|
2
|
+
// below its button with no backdrop and no focus trap; sort and direction
|
|
3
|
+
// apply immediately while the list stays visible. Open state lives in the store (sessionFolderViewMenu); the menu's contents are repainted in place
|
|
4
|
+
// so keyboard focus survives server updates.
|
|
5
|
+
|
|
6
|
+
import { store } from './store.js';
|
|
7
|
+
import { escapeHtml } from './utils.js';
|
|
8
|
+
import { currentFolderState, sendFolderOp } from './session-folders.js';
|
|
9
|
+
import { SORT_OPTIONS, defaultDirectionFor } from './session-folder-layout.js';
|
|
10
|
+
|
|
11
|
+
var MENU_SELECTOR = ".session-folder-viewmenu";
|
|
12
|
+
|
|
13
|
+
function menuEl() {
|
|
14
|
+
return document.querySelector(MENU_SELECTOR);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buttonFor(surface) {
|
|
18
|
+
return document.querySelector('[data-view-button="' + surface + '"]');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sendViewPatch(patch) {
|
|
22
|
+
var op = Object.assign({ op: "set_view" }, patch);
|
|
23
|
+
if (patch.sort) op.direction = defaultDirectionFor(patch.sort);
|
|
24
|
+
sendFolderOp(op);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function radioGroup(name, legend, entries, selected, onPick, segmented) {
|
|
28
|
+
var set = document.createElement("div");
|
|
29
|
+
set.className = "session-folder-radio-group" + (segmented ? " segmented" : "");
|
|
30
|
+
set.setAttribute("role", "radiogroup");
|
|
31
|
+
set.setAttribute("aria-label", legend);
|
|
32
|
+
if (!segmented) {
|
|
33
|
+
var label = document.createElement("div");
|
|
34
|
+
label.className = "session-folder-radio-legend";
|
|
35
|
+
label.textContent = legend;
|
|
36
|
+
set.appendChild(label);
|
|
37
|
+
}
|
|
38
|
+
var rows = [];
|
|
39
|
+
entries.forEach(function (entry, index) {
|
|
40
|
+
var row = document.createElement("button");
|
|
41
|
+
row.type = "button";
|
|
42
|
+
row.className = "session-folder-radio" + (entry.value === selected ? " selected" : "");
|
|
43
|
+
row.dataset.focusKey = name + ":" + entry.value;
|
|
44
|
+
row.setAttribute("role", "radio");
|
|
45
|
+
row.setAttribute("aria-checked", String(entry.value === selected));
|
|
46
|
+
row.innerHTML = (segmented ? "" : '<span class="session-folder-radio-dot" aria-hidden="true"></span>') + "<span>" + escapeHtml(entry.label) + "</span>";
|
|
47
|
+
row.addEventListener("click", function () { onPick(entry.value); });
|
|
48
|
+
row.addEventListener("keydown", function (event) {
|
|
49
|
+
var step = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : (event.key === "ArrowUp" || event.key === "ArrowLeft" ? -1 : 0);
|
|
50
|
+
if (!step) return;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
var next = (index + step + entries.length) % entries.length;
|
|
53
|
+
rows[next].focus();
|
|
54
|
+
onPick(entries[next].value);
|
|
55
|
+
});
|
|
56
|
+
rows.push(row);
|
|
57
|
+
set.appendChild(row);
|
|
58
|
+
});
|
|
59
|
+
return set;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Rebuilds the menu body from the current view and puts focus back on the same
|
|
63
|
+
// control (found by key) so a repaint never drops the keyboard user's place.
|
|
64
|
+
export function paintViewMenu() {
|
|
65
|
+
var menu = menuEl();
|
|
66
|
+
if (!menu) return;
|
|
67
|
+
var active = document.activeElement;
|
|
68
|
+
var focusKey = active && menu.contains(active) ? active.dataset.focusKey : null;
|
|
69
|
+
var view = currentFolderState().view;
|
|
70
|
+
var sortEntry = SORT_OPTIONS.filter(function (o) { return o.sort === view.sort; })[0];
|
|
71
|
+
menu.innerHTML = "";
|
|
72
|
+
menu.appendChild(radioGroup("sort", "Sort by", SORT_OPTIONS.map(function (o) { return { value: o.sort, label: o.label }; }), view.sort, function (v) { sendViewPatch({ sort: v }); }));
|
|
73
|
+
if (sortEntry && sortEntry.directions) {
|
|
74
|
+
menu.appendChild(radioGroup("direction", "Direction", Object.keys(sortEntry.directions).map(function (d) { return { value: d, label: sortEntry.directions[d] }; }), view.direction, function (v) { sendViewPatch({ direction: v }); }, true));
|
|
75
|
+
} else {
|
|
76
|
+
var note = document.createElement("div");
|
|
77
|
+
note.className = "session-folder-view-note";
|
|
78
|
+
note.textContent = "Drag sessions to arrange them. Favorites keep their own order.";
|
|
79
|
+
menu.appendChild(note);
|
|
80
|
+
}
|
|
81
|
+
if (focusKey) {
|
|
82
|
+
var target = menu.querySelector('[data-focus-key="' + focusKey + '"]') || menu.querySelector(".session-folder-radio.selected");
|
|
83
|
+
if (target) target.focus();
|
|
84
|
+
}
|
|
85
|
+
positionViewMenu();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Anchored below the button and clamped to the visible viewport, so it never
|
|
89
|
+
// runs off a phone screen; very tall content scrolls inside the menu.
|
|
90
|
+
export function positionViewMenu() {
|
|
91
|
+
var state = store.get('sessionFolderViewMenu');
|
|
92
|
+
var menu = menuEl();
|
|
93
|
+
if (!state || !menu) return;
|
|
94
|
+
var button = buttonFor(state.surface);
|
|
95
|
+
if (!button) return;
|
|
96
|
+
var rect = button.getBoundingClientRect();
|
|
97
|
+
var margin = 8;
|
|
98
|
+
var width = Math.min(state.surface === "mobile" ? 244 : 216, window.innerWidth - margin * 2);
|
|
99
|
+
menu.style.width = width + "px";
|
|
100
|
+
var left = Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin);
|
|
101
|
+
var top = rect.bottom + 4;
|
|
102
|
+
menu.style.left = left + "px";
|
|
103
|
+
menu.style.top = top + "px";
|
|
104
|
+
menu.style.maxHeight = Math.max(120, window.innerHeight - top - margin) + "px";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Any press that starts outside the menu and the View button dismisses it.
|
|
108
|
+
// pointerdown is the primary signal: surfaces that call preventDefault() on
|
|
109
|
+
// pointerdown (the shared-browser canvas, dock divider, note drag handles) make
|
|
110
|
+
// the browser skip the compatibility mousedown/touchstart, so listening only
|
|
111
|
+
// for those left the menu open. mousedown and touchstart stay as fallbacks for
|
|
112
|
+
// engines and synthetic input without pointer events. The press itself is never
|
|
113
|
+
// cancelled and focus is left where the clicked control puts it.
|
|
114
|
+
function onOutside(event) {
|
|
115
|
+
var state = store.get('sessionFolderViewMenu');
|
|
116
|
+
if (!state) return;
|
|
117
|
+
var menu = menuEl();
|
|
118
|
+
var button = buttonFor(state.surface);
|
|
119
|
+
var target = event.target;
|
|
120
|
+
if (target && target.nodeType === 1 && ((menu && menu.contains(target)) || (button && button.contains(target)))) return;
|
|
121
|
+
closeViewMenu(false);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Focus moving into an iframe or another window never reaches this document's
|
|
125
|
+
// pointer events; losing window focus is treated as leaving the menu.
|
|
126
|
+
function onWindowBlur() {
|
|
127
|
+
if (store.get('sessionFolderViewMenu')) closeViewMenu(false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onKey(event) {
|
|
131
|
+
if (event.key !== "Escape" || !store.get('sessionFolderViewMenu')) return;
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
closeViewMenu(true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onResize() {
|
|
138
|
+
positionViewMenu();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function closeViewMenu(restoreFocus) {
|
|
142
|
+
var state = store.get('sessionFolderViewMenu');
|
|
143
|
+
if (!state) return;
|
|
144
|
+
store.set({ sessionFolderViewMenu: null });
|
|
145
|
+
document.removeEventListener("pointerdown", onOutside, true);
|
|
146
|
+
document.removeEventListener("mousedown", onOutside, true);
|
|
147
|
+
document.removeEventListener("touchstart", onOutside, true);
|
|
148
|
+
document.removeEventListener("keydown", onKey, true);
|
|
149
|
+
window.removeEventListener("resize", onResize);
|
|
150
|
+
window.removeEventListener("blur", onWindowBlur);
|
|
151
|
+
var menu = menuEl();
|
|
152
|
+
if (menu) menu.remove();
|
|
153
|
+
var button = buttonFor(state.surface);
|
|
154
|
+
if (button) {
|
|
155
|
+
button.setAttribute("aria-expanded", "false");
|
|
156
|
+
if (restoreFocus) button.focus();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toggleViewMenu(surface) {
|
|
161
|
+
if (store.get('sessionFolderViewMenu')) {
|
|
162
|
+
closeViewMenu(true);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
var button = buttonFor(surface);
|
|
166
|
+
if (!button) return;
|
|
167
|
+
var menu = document.createElement("div");
|
|
168
|
+
menu.className = "session-folder-viewmenu" + (surface === "mobile" ? " is-mobile" : "");
|
|
169
|
+
menu.setAttribute("role", "group");
|
|
170
|
+
menu.setAttribute("aria-label", "View options");
|
|
171
|
+
document.body.appendChild(menu);
|
|
172
|
+
store.set({ sessionFolderViewMenu: { surface: surface } });
|
|
173
|
+
button.setAttribute("aria-expanded", "true");
|
|
174
|
+
paintViewMenu();
|
|
175
|
+
document.addEventListener("pointerdown", onOutside, true);
|
|
176
|
+
document.addEventListener("mousedown", onOutside, true);
|
|
177
|
+
document.addEventListener("touchstart", onOutside, true);
|
|
178
|
+
document.addEventListener("keydown", onKey, true);
|
|
179
|
+
window.addEventListener("resize", onResize);
|
|
180
|
+
window.addEventListener("blur", onWindowBlur);
|
|
181
|
+
var first = menu.querySelector(".session-folder-radio.selected") || menu.querySelector("button");
|
|
182
|
+
if (first) first.focus();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
store.subscribe(function (state, previous) {
|
|
186
|
+
if (state.sessionFolders !== previous.sessionFolders) paintViewMenu();
|
|
187
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Client state and server round trips for sidebar session folders. The
|
|
2
|
+
// server owns the data (per user and project); this module mirrors the latest
|
|
3
|
+
// snapshot into the store and sends operations the server validates again.
|
|
4
|
+
// Snapshots and requests are both tagged with the project so a stale one can
|
|
5
|
+
// never be applied to, or mutate, another project.
|
|
6
|
+
|
|
7
|
+
import { store } from './store.js';
|
|
8
|
+
import { getWs } from './ws-ref.js';
|
|
9
|
+
import { showToast } from './utils.js';
|
|
10
|
+
import { defaultFolderState } from './session-folder-layout.js';
|
|
11
|
+
|
|
12
|
+
export function handleSessionFoldersState(msg) {
|
|
13
|
+
var slug = store.get('currentSlug');
|
|
14
|
+
if (msg.slug && slug && msg.slug !== slug) return;
|
|
15
|
+
var create = store.get('sessionFolderCreate');
|
|
16
|
+
if (msg.state) store.set({ sessionFolders: msg.state, sessionFoldersSlug: msg.slug || slug || null });
|
|
17
|
+
var correlated = !!(create && create.pendingId && msg.requestId === create.pendingId);
|
|
18
|
+
// A folder deletion in progress shows its own error inside its dialog.
|
|
19
|
+
var deleting = store.get('sessionFolderDelete');
|
|
20
|
+
var deleteCorrelated = !!(deleting && msg.requestId && msg.requestId === deleting.requestId);
|
|
21
|
+
if (msg.error && !correlated && !deleteCorrelated) showToast(msg.error, "warn");
|
|
22
|
+
if (!correlated) return;
|
|
23
|
+
if (msg.error) {
|
|
24
|
+
// The creation was refused: keep the row open with the server's reason.
|
|
25
|
+
store.set({ sessionFolderCreate: Object.assign({}, create, { pendingId: null, error: msg.error, focus: true }) });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
store.set({ sessionFolderCreate: null });
|
|
29
|
+
if (create.sessionId !== null && msg.folderId) sendFolderOp({ op: "place_session", sessionId: create.sessionId, folderId: msg.folderId });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function currentFolderState() {
|
|
33
|
+
var state = store.get('sessionFolders');
|
|
34
|
+
if (!state || store.get('sessionFoldersSlug') !== store.get('currentSlug')) return defaultFolderState();
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sendFolderOp(op, requestId) {
|
|
39
|
+
var ws = getWs();
|
|
40
|
+
if (!ws || !store.get('connected')) return false;
|
|
41
|
+
var payload = { type: "session_folders_op", slug: store.get('currentSlug'), op: op };
|
|
42
|
+
if (requestId) payload.requestId = requestId;
|
|
43
|
+
ws.send(JSON.stringify(payload));
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Inline folder creation (the row itself lives in session-folder-toolbar.js) ---
|
|
48
|
+
// A creation is closed only by the server's acknowledgement; when it was started
|
|
49
|
+
// from a session's Move picker, that session is filed after a successful ack.
|
|
50
|
+
|
|
51
|
+
export function openFolderCreate(sessionId) {
|
|
52
|
+
var current = store.get('sessionFolderCreate');
|
|
53
|
+
if (current) {
|
|
54
|
+
store.set({ sessionFolderCreate: Object.assign({}, current, { focus: true, scroll: true, sessionId: sessionId === undefined ? current.sessionId : sessionId }) });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
store.set({ sessionFolderCreate: { draft: "", error: "", pendingId: null, sessionId: sessionId === undefined ? null : sessionId, focus: true, scroll: true, selStart: 0, selEnd: 0 } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cancelFolderCreate() {
|
|
61
|
+
if (store.get('sessionFolderCreate')) store.set({ sessionFolderCreate: null });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function updateFolderCreateDraft(patch) {
|
|
65
|
+
var current = store.get('sessionFolderCreate');
|
|
66
|
+
if (current) store.set({ sessionFolderCreate: Object.assign({}, current, patch) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function submitFolderCreate(name) {
|
|
70
|
+
var current = store.get('sessionFolderCreate');
|
|
71
|
+
if (!current || current.pendingId) return false;
|
|
72
|
+
var requestId = "folder-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
|
73
|
+
store.set({ sessionFolderCreate: Object.assign({}, current, { pendingId: requestId, error: "", focus: true }) });
|
|
74
|
+
if (!sendFolderOp({ op: "create_folder", name: name }, requestId)) {
|
|
75
|
+
store.set({ sessionFolderCreate: Object.assign({}, current, { pendingId: null, error: "Not connected. Try again.", focus: true }) });
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
store.subscribe(function (state, previous) {
|
|
82
|
+
var create = state.sessionFolderCreate;
|
|
83
|
+
if (create && create.pendingId && previous.connected && !state.connected) {
|
|
84
|
+
store.set({ sessionFolderCreate: Object.assign({}, create, { pendingId: null, error: "Connection lost. Try again." }) });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export function resetSessionFolders() {
|
|
89
|
+
store.set({ sessionFolders: null, sessionFoldersSlug: null });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function requestFolderState() {
|
|
93
|
+
var ws = getWs();
|
|
94
|
+
if (ws && store.get('connected')) ws.send(JSON.stringify({ type: "session_folders_get" }));
|
|
95
|
+
}
|