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,414 @@
|
|
|
1
|
+
// Pure model for per-user, per-project session folders. No I/O and no
|
|
2
|
+
// dependency on live sessions: callers inject the checks that need them, so
|
|
3
|
+
// every invariant here is enforced the same way for any transport.
|
|
4
|
+
//
|
|
5
|
+
// Shape of one state:
|
|
6
|
+
// folders: [{ id, name }] custom folders, array order = display order
|
|
7
|
+
// assignments: { sessionKey: folderId } folderId is a custom id; absent = Unfiled (exclusive placement)
|
|
8
|
+
// favorites: [sessionKey] independent Favorites tag in curated manual order; a favorite
|
|
9
|
+
// also keeps its folder (or Unfiled) assignment
|
|
10
|
+
// orders: { containerKey: [keys] } manual order per container ("unfiled", custom id, "all")
|
|
11
|
+
// collapsed: { containerKey: true }
|
|
12
|
+
// view: { group, sort, direction }
|
|
13
|
+
// sessionKey is the session's opaque, durable sessionOriginId.
|
|
14
|
+
|
|
15
|
+
var crypto = require("crypto");
|
|
16
|
+
|
|
17
|
+
var FAVORITES_ID = "favorites";
|
|
18
|
+
var UNFILED_ID = "unfiled";
|
|
19
|
+
var ALL_ID = "all";
|
|
20
|
+
var MAX_FOLDERS = 50;
|
|
21
|
+
var MAX_NAME_LENGTH = 60;
|
|
22
|
+
var MAX_KEYS = 5000;
|
|
23
|
+
var MAX_KEY_LENGTH = 128;
|
|
24
|
+
|
|
25
|
+
// Folders are the only layout. Older clients and stored preferences may still
|
|
26
|
+
// name "dates" or "none"; those are accepted and canonicalized to "folders".
|
|
27
|
+
var LEGACY_GROUPS = ["folders", "dates", "none"];
|
|
28
|
+
var SORTS = ["activity", "created", "title", "manual"];
|
|
29
|
+
var DIRECTIONS = ["desc", "asc"];
|
|
30
|
+
|
|
31
|
+
function defaultState() {
|
|
32
|
+
return {
|
|
33
|
+
folders: [],
|
|
34
|
+
assignments: {},
|
|
35
|
+
favorites: [],
|
|
36
|
+
orders: {},
|
|
37
|
+
collapsed: {},
|
|
38
|
+
view: { group: "folders", sort: "activity", direction: "desc" },
|
|
39
|
+
legacyFavoritesMigrated: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPlainObject(value) {
|
|
44
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function has(object, key) {
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dictionary keys come from storage and clients, so names that exist on
|
|
52
|
+
// Object.prototype must never be treated as present, and __proto__ is refused.
|
|
53
|
+
function validKey(value) {
|
|
54
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_KEY_LENGTH && !/[\x00-\x1f]/.test(value) && value !== "__proto__";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validFolderId(value) {
|
|
58
|
+
return typeof value === "string" && /^f_[a-z0-9]{6,32}$/.test(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanName(raw) {
|
|
62
|
+
if (typeof raw !== "string") return { error: "Folder name must be text" };
|
|
63
|
+
var name = raw.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
|
|
64
|
+
if (!name) return { error: "Folder name is required" };
|
|
65
|
+
if (name.length > MAX_NAME_LENGTH) return { error: "Folder name must be " + MAX_NAME_LENGTH + " characters or fewer" };
|
|
66
|
+
var lower = name.toLowerCase();
|
|
67
|
+
if (lower === "favorites" || lower === "unfiled") return { error: "That name is reserved for a built-in folder" };
|
|
68
|
+
return { name: name };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nameTaken(state, name, exceptId) {
|
|
72
|
+
var lower = name.toLowerCase();
|
|
73
|
+
for (var i = 0; i < state.folders.length; i++) {
|
|
74
|
+
if (state.folders[i].id !== exceptId && state.folders[i].name.toLowerCase() === lower) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A real destination: a custom folder. Favorites is a tag, never a destination.
|
|
80
|
+
function knownContainer(state, id) {
|
|
81
|
+
if (typeof id !== "string") return false;
|
|
82
|
+
for (var i = 0; i < state.folders.length; i++) if (state.folders[i].id === id) return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function uniqueKeys(list) {
|
|
87
|
+
var seen = Object.create(null);
|
|
88
|
+
var out = [];
|
|
89
|
+
if (!Array.isArray(list)) return out;
|
|
90
|
+
for (var i = 0; i < list.length && out.length < MAX_KEYS; i++) {
|
|
91
|
+
if (!validKey(list[i]) || seen[list[i]]) continue;
|
|
92
|
+
seen[list[i]] = true;
|
|
93
|
+
out.push(list[i]);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Accepts anything stored on disk and returns a state that satisfies every
|
|
99
|
+
// invariant; unknown fields and dangling references are dropped.
|
|
100
|
+
function normalizeState(raw) {
|
|
101
|
+
var source = isPlainObject(raw) ? raw : {};
|
|
102
|
+
var state = defaultState();
|
|
103
|
+
var seenIds = Object.create(null);
|
|
104
|
+
var seenNames = Object.create(null);
|
|
105
|
+
var folders = Array.isArray(source.folders) ? source.folders : [];
|
|
106
|
+
for (var i = 0; i < folders.length && state.folders.length < MAX_FOLDERS; i++) {
|
|
107
|
+
var f = folders[i];
|
|
108
|
+
if (!isPlainObject(f) || !validFolderId(f.id) || seenIds[f.id]) continue;
|
|
109
|
+
var cleaned = cleanName(f.name);
|
|
110
|
+
if (cleaned.error || seenNames[cleaned.name.toLowerCase()]) continue;
|
|
111
|
+
seenIds[f.id] = true;
|
|
112
|
+
seenNames[cleaned.name.toLowerCase()] = true;
|
|
113
|
+
state.folders.push({ id: f.id, name: cleaned.name });
|
|
114
|
+
}
|
|
115
|
+
var assignments = isPlainObject(source.assignments) ? source.assignments : {};
|
|
116
|
+
var keys = Object.keys(assignments);
|
|
117
|
+
var legacyTagged = [];
|
|
118
|
+
for (var a = 0; a < keys.length && a < MAX_KEYS; a++) {
|
|
119
|
+
if (!validKey(keys[a]) || !has(assignments, keys[a])) continue;
|
|
120
|
+
// Migration: when Favorites was an exclusive folder, the assignment IS the
|
|
121
|
+
// tag. The session's previous folder was never recorded, so it lands in
|
|
122
|
+
// Unfiled; nothing is invented.
|
|
123
|
+
if (assignments[keys[a]] === FAVORITES_ID) legacyTagged.push(keys[a]);
|
|
124
|
+
else if (knownContainer(state, assignments[keys[a]])) state.assignments[keys[a]] = assignments[keys[a]];
|
|
125
|
+
}
|
|
126
|
+
var orders = isPlainObject(source.orders) ? source.orders : {};
|
|
127
|
+
var containers = Object.keys(orders);
|
|
128
|
+
for (var o = 0; o < containers.length; o++) {
|
|
129
|
+
var cid = containers[o];
|
|
130
|
+
if (cid === FAVORITES_ID || (cid !== UNFILED_ID && cid !== ALL_ID && !knownContainer(state, cid))) continue;
|
|
131
|
+
state.orders[cid] = uniqueKeys(orders[cid]);
|
|
132
|
+
}
|
|
133
|
+
reconcileOrders(state);
|
|
134
|
+
// Favorites tags: the new list first, then migrated ones in their previous
|
|
135
|
+
// curated order (legacy orders.favorites), then any remaining in key order.
|
|
136
|
+
var tagged = uniqueKeys(source.favorites);
|
|
137
|
+
var legacyOrder = uniqueKeys(has(orders, FAVORITES_ID) ? orders[FAVORITES_ID] : []).filter(function (k) { return legacyTagged.indexOf(k) !== -1; });
|
|
138
|
+
legacyTagged.forEach(function (k) { if (legacyOrder.indexOf(k) === -1) legacyOrder.push(k); });
|
|
139
|
+
tagged = uniqueKeys(tagged.concat(legacyOrder));
|
|
140
|
+
state.favorites = tagged;
|
|
141
|
+
var collapsed = isPlainObject(source.collapsed) ? source.collapsed : {};
|
|
142
|
+
var collapsedKeys = Object.keys(collapsed);
|
|
143
|
+
for (var c = 0; c < collapsedKeys.length; c++) {
|
|
144
|
+
var ck = collapsedKeys[c];
|
|
145
|
+
if (has(collapsed, ck) && collapsed[ck] === true && (ck === FAVORITES_ID || ck === UNFILED_ID || knownContainer(state, ck))) state.collapsed[ck] = true;
|
|
146
|
+
}
|
|
147
|
+
var view = isPlainObject(source.view) ? source.view : {};
|
|
148
|
+
if (SORTS.indexOf(view.sort) !== -1) state.view.sort = view.sort;
|
|
149
|
+
if (DIRECTIONS.indexOf(view.direction) !== -1) state.view.direction = view.direction;
|
|
150
|
+
state.legacyFavoritesMigrated = source.legacyFavoritesMigrated === true;
|
|
151
|
+
return state;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A container's manual order may only list sessions that currently belong to
|
|
155
|
+
// it. Moves, folder deletion and legacy data can leave stale members behind;
|
|
156
|
+
// dropping them here keeps every order consistent with the assignments.
|
|
157
|
+
// "all" spans every ordinary session and is not membership-checked.
|
|
158
|
+
function reconcileOrders(state) {
|
|
159
|
+
var containers = Object.keys(state.orders);
|
|
160
|
+
for (var c = 0; c < containers.length; c++) {
|
|
161
|
+
var cid = containers[c];
|
|
162
|
+
if (cid === ALL_ID) continue;
|
|
163
|
+
var members = state.orders[cid].filter(function (key) {
|
|
164
|
+
var assigned = has(state.assignments, key) ? state.assignments[key] : UNFILED_ID;
|
|
165
|
+
return assigned === cid;
|
|
166
|
+
});
|
|
167
|
+
if (members.length) state.orders[cid] = members;
|
|
168
|
+
else delete state.orders[cid];
|
|
169
|
+
}
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One-time seed from the legacy session-level favorites. Favorites are now an
|
|
174
|
+
// independent tag, so an existing custom-folder assignment is kept while the
|
|
175
|
+
// tag is added. The legacy flags themselves are left untouched.
|
|
176
|
+
function seedLegacyFavorites(state, legacyKeys) {
|
|
177
|
+
var next = normalizeState(state);
|
|
178
|
+
if (next.legacyFavoritesMigrated) return next;
|
|
179
|
+
for (var i = 0; i < legacyKeys.length; i++) {
|
|
180
|
+
var key = legacyKeys[i];
|
|
181
|
+
if (!validKey(key) || next.favorites.indexOf(key) !== -1) continue;
|
|
182
|
+
next.favorites.push(key);
|
|
183
|
+
}
|
|
184
|
+
next.legacyFavoritesMigrated = true;
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function newFolderId() {
|
|
189
|
+
return "f_" + crypto.randomBytes(6).toString("hex");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pruneKeys(state, exists) {
|
|
193
|
+
var keys = Object.keys(state.assignments);
|
|
194
|
+
for (var i = 0; i < keys.length; i++) if (!exists(keys[i])) delete state.assignments[keys[i]];
|
|
195
|
+
state.favorites = state.favorites.filter(exists);
|
|
196
|
+
var containers = Object.keys(state.orders);
|
|
197
|
+
for (var c = 0; c < containers.length; c++) {
|
|
198
|
+
state.orders[containers[c]] = state.orders[containers[c]].filter(exists);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function containerOf(state, key) {
|
|
203
|
+
return has(state.assignments, key) ? state.assignments[key] : UNFILED_ID;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// env.canOrganize(key): the key names a session this user may see and that is
|
|
207
|
+
// a movable unit (a Driver root or standalone session, never a Worker).
|
|
208
|
+
// env.exists(key): the session still exists in the project (for pruning).
|
|
209
|
+
function applyOperation(input, op, env) {
|
|
210
|
+
var result = applyRaw(input, op, env);
|
|
211
|
+
if (result.state) {
|
|
212
|
+
pruneKeys(result.state, env.exists);
|
|
213
|
+
reconcileOrders(result.state);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function applyRaw(input, op, env) {
|
|
219
|
+
var state = normalizeState(input);
|
|
220
|
+
if (!isPlainObject(op) || typeof op.op !== "string") return { error: "Invalid folder request" };
|
|
221
|
+
var kind = op.op;
|
|
222
|
+
var i;
|
|
223
|
+
|
|
224
|
+
if (kind === "create_folder") {
|
|
225
|
+
if (state.folders.length >= MAX_FOLDERS) return { error: "You can have at most " + MAX_FOLDERS + " folders" };
|
|
226
|
+
var created = cleanName(op.name);
|
|
227
|
+
if (created.error) return created;
|
|
228
|
+
if (nameTaken(state, created.name, null)) return { error: "A folder with that name already exists" };
|
|
229
|
+
var folder = { id: newFolderId(), name: created.name };
|
|
230
|
+
state.folders.push(folder);
|
|
231
|
+
return { state: state, folderId: folder.id };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (kind === "rename_folder") {
|
|
235
|
+
if (!validFolderId(op.folderId) || !knownContainer(state, op.folderId)) return { error: "Folder not found" };
|
|
236
|
+
var renamed = cleanName(op.name);
|
|
237
|
+
if (renamed.error) return renamed;
|
|
238
|
+
if (nameTaken(state, renamed.name, op.folderId)) return { error: "A folder with that name already exists" };
|
|
239
|
+
for (i = 0; i < state.folders.length; i++) if (state.folders[i].id === op.folderId) state.folders[i].name = renamed.name;
|
|
240
|
+
return { state: state };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (kind === "delete_folder") {
|
|
244
|
+
if (!validFolderId(op.folderId) || !knownContainer(state, op.folderId)) return { error: "Folder not found" };
|
|
245
|
+
// Contents either go to Unfiled (the default, and what older clients get) or
|
|
246
|
+
// to another folder. Deleting the sessions themselves is a separate,
|
|
247
|
+
// server-authorized request and never reaches this pure model.
|
|
248
|
+
var mode = op.mode === undefined ? "unfiled" : op.mode;
|
|
249
|
+
if (mode !== "unfiled" && mode !== "move") return { error: "Unsupported folder deletion" };
|
|
250
|
+
var members = Object.keys(state.assignments).filter(function (k) { return state.assignments[k] === op.folderId; });
|
|
251
|
+
if (mode === "move") {
|
|
252
|
+
var dest = op.destinationId;
|
|
253
|
+
if (typeof dest !== "string" || dest === op.folderId || dest === UNFILED_ID || dest === FAVORITES_ID || !knownContainer(state, dest)) return { error: "Choose another folder for these sessions" };
|
|
254
|
+
var listed = (state.orders[op.folderId] || []).filter(function (k) { return members.indexOf(k) !== -1; });
|
|
255
|
+
var moved = listed.concat(members.filter(function (k) { return listed.indexOf(k) === -1; }));
|
|
256
|
+
for (i = 0; i < moved.length; i++) state.assignments[moved[i]] = dest;
|
|
257
|
+
if (moved.length && state.orders[dest]) {
|
|
258
|
+
state.orders[dest] = (state.orders[dest] || []).concat(moved);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
for (i = 0; i < members.length; i++) delete state.assignments[members[i]];
|
|
262
|
+
}
|
|
263
|
+
state.folders = state.folders.filter(function (f) { return f.id !== op.folderId; });
|
|
264
|
+
delete state.orders[op.folderId];
|
|
265
|
+
delete state.collapsed[op.folderId];
|
|
266
|
+
return { state: state };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (kind === "reorder_folder") {
|
|
270
|
+
if (!validFolderId(op.folderId) || !knownContainer(state, op.folderId)) return { error: "Folder not found" };
|
|
271
|
+
if (!validFolderId(op.targetId) || !knownContainer(state, op.targetId)) return { error: "Target folder not found" };
|
|
272
|
+
if (op.folderId === op.targetId) return { state: state };
|
|
273
|
+
var moving = null;
|
|
274
|
+
var rest = [];
|
|
275
|
+
for (i = 0; i < state.folders.length; i++) {
|
|
276
|
+
if (state.folders[i].id === op.folderId) moving = state.folders[i];
|
|
277
|
+
else rest.push(state.folders[i]);
|
|
278
|
+
}
|
|
279
|
+
var at = -1;
|
|
280
|
+
for (i = 0; i < rest.length; i++) if (rest[i].id === op.targetId) at = i;
|
|
281
|
+
rest.splice(op.insertBefore === false ? at + 1 : at, 0, moving);
|
|
282
|
+
state.folders = rest;
|
|
283
|
+
return { state: state };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (kind === "place_session") {
|
|
287
|
+
if (!validKey(op.sessionKey) || !env.canOrganize(op.sessionKey)) return { error: "Session not found" };
|
|
288
|
+
// Older clients ask to place a session "in Favorites": that is the tag, and
|
|
289
|
+
// the session keeps its folder.
|
|
290
|
+
if (op.folderId === FAVORITES_ID) return tagFavorite(state, op, env, true);
|
|
291
|
+
var folderId = op.folderId === null || op.folderId === undefined || op.folderId === UNFILED_ID ? UNFILED_ID : op.folderId;
|
|
292
|
+
if (folderId !== UNFILED_ID && !knownContainer(state, folderId)) return { error: "Folder not found" };
|
|
293
|
+
if (folderId === UNFILED_ID) delete state.assignments[op.sessionKey];
|
|
294
|
+
else state.assignments[op.sessionKey] = folderId;
|
|
295
|
+
pruneKeys(state, env.exists);
|
|
296
|
+
if (op.order !== undefined) {
|
|
297
|
+
var placed = setOrder(state, op.order, op.sessionKey, folderId, env);
|
|
298
|
+
if (placed.error) return placed;
|
|
299
|
+
}
|
|
300
|
+
return { state: state };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kind === "set_favorite") {
|
|
304
|
+
if (!validKey(op.sessionKey) || !env.canOrganize(op.sessionKey)) return { error: "Session not found" };
|
|
305
|
+
return tagFavorite(state, op, env, op.favorite === true);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (kind === "set_order") {
|
|
309
|
+
var target = op.containerKey;
|
|
310
|
+
if (target !== FAVORITES_ID && target !== UNFILED_ID && target !== ALL_ID && !knownContainer(state, target)) return { error: "Folder not found" };
|
|
311
|
+
var result = target === FAVORITES_ID ? setFavoritesOrder(state, op.order, env) : setOrder(state, op.order, null, target, env);
|
|
312
|
+
if (result.error) return result;
|
|
313
|
+
return { state: state };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (kind === "set_collapsed") {
|
|
317
|
+
var key = op.containerKey;
|
|
318
|
+
if (key !== FAVORITES_ID && key !== UNFILED_ID && !knownContainer(state, key)) return { error: "Folder not found" };
|
|
319
|
+
if (op.collapsed === true) state.collapsed[key] = true;
|
|
320
|
+
else delete state.collapsed[key];
|
|
321
|
+
return { state: state };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (kind === "set_view") {
|
|
325
|
+
if (op.group !== undefined) {
|
|
326
|
+
if (LEGACY_GROUPS.indexOf(op.group) === -1) return { error: "Invalid grouping" };
|
|
327
|
+
state.view.group = "folders";
|
|
328
|
+
}
|
|
329
|
+
if (op.sort !== undefined) {
|
|
330
|
+
if (SORTS.indexOf(op.sort) === -1) return { error: "Invalid sort" };
|
|
331
|
+
state.view.sort = op.sort;
|
|
332
|
+
}
|
|
333
|
+
if (op.direction !== undefined) {
|
|
334
|
+
if (DIRECTIONS.indexOf(op.direction) === -1) return { error: "Invalid direction" };
|
|
335
|
+
state.view.direction = op.direction;
|
|
336
|
+
}
|
|
337
|
+
return { state: state };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { error: "Unknown folder request" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Adds or removes the Favorites tag. The session's folder is never touched. An
|
|
344
|
+
// optional order places the tagged session in the curated list.
|
|
345
|
+
function tagFavorite(state, op, env, favorite) {
|
|
346
|
+
var key = op.sessionKey;
|
|
347
|
+
var at = state.favorites.indexOf(key);
|
|
348
|
+
if (!favorite) {
|
|
349
|
+
if (at !== -1) state.favorites.splice(at, 1);
|
|
350
|
+
return { state: state };
|
|
351
|
+
}
|
|
352
|
+
if (at === -1) {
|
|
353
|
+
if (state.favorites.length >= MAX_KEYS) return { error: "Too many favorites" };
|
|
354
|
+
state.favorites.push(key);
|
|
355
|
+
}
|
|
356
|
+
if (op.order !== undefined) {
|
|
357
|
+
var placed = setFavoritesOrder(state, op.order, env, key);
|
|
358
|
+
if (placed.error) return placed;
|
|
359
|
+
}
|
|
360
|
+
return { state: state };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The listed tags first, in the given order; any other favorite follows in its
|
|
364
|
+
// existing order. Every listed key must already be a favorite.
|
|
365
|
+
function setFavoritesOrder(state, order, env, requiredKey) {
|
|
366
|
+
if (!Array.isArray(order) || order.length > MAX_KEYS) return { error: "Invalid order" };
|
|
367
|
+
var listed = [];
|
|
368
|
+
var seen = Object.create(null);
|
|
369
|
+
for (var i = 0; i < order.length; i++) {
|
|
370
|
+
var key = order[i];
|
|
371
|
+
if (!validKey(key) || seen[key]) return { error: "Invalid order" };
|
|
372
|
+
if (!env.canOrganize(key)) return { error: "Session not found" };
|
|
373
|
+
if (state.favorites.indexOf(key) === -1) return { error: "Order does not match the favorites" };
|
|
374
|
+
seen[key] = true;
|
|
375
|
+
listed.push(key);
|
|
376
|
+
}
|
|
377
|
+
if (requiredKey && !seen[requiredKey]) return { error: "Order must include the favorite" };
|
|
378
|
+
state.favorites = listed.concat(state.favorites.filter(function (k) { return !seen[k]; }));
|
|
379
|
+
return { ok: true };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Replaces one container's manual order. Every key must name an organizable
|
|
383
|
+
// session; keys that belong to another folder are rejected so a crafted list
|
|
384
|
+
// cannot smuggle sessions across containers.
|
|
385
|
+
function setOrder(state, order, requiredKey, containerKey, env) {
|
|
386
|
+
if (!Array.isArray(order) || order.length > MAX_KEYS) return { error: "Invalid order" };
|
|
387
|
+
var keys = [];
|
|
388
|
+
var seen = {};
|
|
389
|
+
for (var i = 0; i < order.length; i++) {
|
|
390
|
+
var key = order[i];
|
|
391
|
+
if (!validKey(key) || seen[key]) return { error: "Invalid order" };
|
|
392
|
+
if (!env.canOrganize(key)) return { error: "Session not found" };
|
|
393
|
+
if (containerKey !== ALL_ID && containerOf(state, key) !== containerKey) return { error: "Order does not match the folder contents" };
|
|
394
|
+
seen[key] = true;
|
|
395
|
+
keys.push(key);
|
|
396
|
+
}
|
|
397
|
+
if (requiredKey && !seen[requiredKey]) return { error: "Order must include the moved session" };
|
|
398
|
+
state.orders[containerKey] = keys;
|
|
399
|
+
return { ok: true };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
module.exports = {
|
|
403
|
+
FAVORITES_ID: FAVORITES_ID,
|
|
404
|
+
UNFILED_ID: UNFILED_ID,
|
|
405
|
+
ALL_ID: ALL_ID,
|
|
406
|
+
MAX_FOLDERS: MAX_FOLDERS,
|
|
407
|
+
LEGACY_GROUPS: LEGACY_GROUPS,
|
|
408
|
+
SORTS: SORTS,
|
|
409
|
+
DIRECTIONS: DIRECTIONS,
|
|
410
|
+
defaultState: defaultState,
|
|
411
|
+
normalizeState: normalizeState,
|
|
412
|
+
seedLegacyFavorites: seedLegacyFavorites,
|
|
413
|
+
applyOperation: applyOperation,
|
|
414
|
+
};
|
package/lib/sessions.js
CHANGED
|
@@ -613,6 +613,7 @@ function createSessionManager(opts) {
|
|
|
613
613
|
return {
|
|
614
614
|
id: s.localId,
|
|
615
615
|
cliSessionId: s.cliSessionId || null,
|
|
616
|
+
createdAt: s.createdAt || 0,
|
|
616
617
|
title: s.title || "New Session",
|
|
617
618
|
githubLinks: s.githubLinks || [],
|
|
618
619
|
linearLinks: s.linearLinks || [],
|
|
@@ -812,9 +813,9 @@ function createSessionManager(opts) {
|
|
|
812
813
|
return session;
|
|
813
814
|
}
|
|
814
815
|
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
816
|
+
// History entries include tool activity and streamed output, so opening a
|
|
817
|
+
// conversation needs a wider window than each incremental scroll-up page.
|
|
818
|
+
var INITIAL_HISTORY_SIZE = 500;
|
|
818
819
|
var HISTORY_PAGE_SIZE = 100;
|
|
819
820
|
|
|
820
821
|
function findTurnBoundary(history, targetIndex) {
|
|
@@ -828,10 +829,10 @@ function createSessionManager(opts) {
|
|
|
828
829
|
var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
|
|
829
830
|
var total = session.history.length;
|
|
830
831
|
if (typeof fromIndex !== "number") {
|
|
831
|
-
if (total <=
|
|
832
|
+
if (total <= INITIAL_HISTORY_SIZE) {
|
|
832
833
|
fromIndex = 0;
|
|
833
834
|
} else {
|
|
834
|
-
fromIndex = findTurnBoundary(session.history, Math.max(0, total -
|
|
835
|
+
fromIndex = findTurnBoundary(session.history, Math.max(0, total - INITIAL_HISTORY_SIZE));
|
|
835
836
|
}
|
|
836
837
|
}
|
|
837
838
|
|
|
@@ -1442,6 +1443,7 @@ function createSessionManager(opts) {
|
|
|
1442
1443
|
for (var i = 0; i < listeners.length; i++) listeners[i](localId);
|
|
1443
1444
|
},
|
|
1444
1445
|
HISTORY_PAGE_SIZE: HISTORY_PAGE_SIZE,
|
|
1446
|
+
INITIAL_HISTORY_SIZE: INITIAL_HISTORY_SIZE,
|
|
1445
1447
|
getActiveSession: getActiveSession,
|
|
1446
1448
|
createSession: createSession,
|
|
1447
1449
|
createSessionRaw: createSessionRaw,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Per-user, per-project persistence for sidebar session folders. Multi-user
|
|
2
|
+
// installs store it on the user's own record; single-user installs use the
|
|
3
|
+
// daemon config. A user can only ever read or write their own entry.
|
|
4
|
+
|
|
5
|
+
var sessionFolders = require("./session-folders");
|
|
6
|
+
|
|
7
|
+
function validSlug(slug) {
|
|
8
|
+
return typeof slug === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(slug);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function attachSessionFolderPreferences(deps) {
|
|
12
|
+
var loadUsers = deps.loadUsers;
|
|
13
|
+
var saveUsers = deps.saveUsers;
|
|
14
|
+
|
|
15
|
+
function loadConfigState(slug) {
|
|
16
|
+
try {
|
|
17
|
+
var cfg = require("./config").loadConfig() || {};
|
|
18
|
+
var all = cfg.sessionFolderPreferences;
|
|
19
|
+
return all && typeof all === "object" && Object.prototype.hasOwnProperty.call(all, slug) ? all[slug] : null;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveConfigState(slug, state) {
|
|
26
|
+
try {
|
|
27
|
+
var config = require("./config");
|
|
28
|
+
var cfg = config.loadConfig() || {};
|
|
29
|
+
var all = Object.assign({}, cfg.sessionFolderPreferences || {});
|
|
30
|
+
all[slug] = state;
|
|
31
|
+
config.saveConfig(Object.assign({}, cfg, { sessionFolderPreferences: all }));
|
|
32
|
+
return { ok: true, state: state };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { error: "Unable to save session folders" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getSessionFolders(userId, slug) {
|
|
39
|
+
if (!validSlug(slug)) return sessionFolders.defaultState();
|
|
40
|
+
var data = loadUsers();
|
|
41
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
42
|
+
if (data.users[i].id !== userId) continue;
|
|
43
|
+
var stored = data.users[i].sessionFolders;
|
|
44
|
+
return sessionFolders.normalizeState(stored && typeof stored === "object" && Object.prototype.hasOwnProperty.call(stored, slug) ? stored[slug] : null);
|
|
45
|
+
}
|
|
46
|
+
if (userId === "default") return sessionFolders.normalizeState(loadConfigState(slug));
|
|
47
|
+
return sessionFolders.defaultState();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setSessionFolders(userId, slug, state) {
|
|
51
|
+
if (!validSlug(slug)) return { error: "Invalid project" };
|
|
52
|
+
var next = sessionFolders.normalizeState(state);
|
|
53
|
+
var data = loadUsers();
|
|
54
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
55
|
+
if (data.users[i].id !== userId) continue;
|
|
56
|
+
var all = data.users[i].sessionFolders && typeof data.users[i].sessionFolders === "object" ? data.users[i].sessionFolders : {};
|
|
57
|
+
all[slug] = next;
|
|
58
|
+
data.users[i].sessionFolders = all;
|
|
59
|
+
saveUsers(data);
|
|
60
|
+
return { ok: true, state: next };
|
|
61
|
+
}
|
|
62
|
+
if (userId === "default") return saveConfigState(slug, next);
|
|
63
|
+
return { error: "User not found" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { getSessionFolders: getSessionFolders, setSessionFolders: setSessionFolders };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { attachSessionFolderPreferences: attachSessionFolderPreferences };
|
package/lib/users.js
CHANGED
|
@@ -8,6 +8,7 @@ var { DEFAULT_PERMISSIONS, ALL_PERMISSIONS, attachPermissions } = require("./use
|
|
|
8
8
|
var { attachPreferences } = require("./users-preferences");
|
|
9
9
|
var { attachHomeDockPreferences } = require("./users-home-dock-preferences");
|
|
10
10
|
var { attachHomeSurfacePreferences } = require("./users-home-surface-preferences");
|
|
11
|
+
var { attachSessionFolderPreferences } = require("./users-session-folder-preferences");
|
|
11
12
|
var { attachExperimentalPreferences } = require("./users-experimental-preferences");
|
|
12
13
|
var { attachScheduledTaskPreferences } = require("./users-scheduled-task-preferences");
|
|
13
14
|
var { attachDefaultAiPreferences } = require("./users-default-ai-preferences");
|
|
@@ -403,6 +404,7 @@ var permissions = attachPermissions({ loadUsers: loadUsers, saveUsers: saveUsers
|
|
|
403
404
|
var preferences = attachPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
404
405
|
var homeDockPreferences = attachHomeDockPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
405
406
|
var homeSurfacePreferences = attachHomeSurfacePreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
407
|
+
var sessionFolderPreferences = attachSessionFolderPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
406
408
|
var experimentalPreferences = attachExperimentalPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
407
409
|
var scheduledTaskPreferences = attachScheduledTaskPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
|
|
408
410
|
var defaultAiPreferences = attachDefaultAiPreferences({ loadUsers: loadUsers, saveUsers: saveUsers, isMultiUser: auth.isMultiUser });
|
|
@@ -462,6 +464,8 @@ var getHomeDockPreference = homeDockPreferences.getHomeDockPreference;
|
|
|
462
464
|
var setHomeDockPreference = homeDockPreferences.setHomeDockPreference;
|
|
463
465
|
var getHomeSurfacePreference = homeSurfacePreferences.getHomeSurfacePreference;
|
|
464
466
|
var setHomeSurfacePreference = homeSurfacePreferences.setHomeSurfacePreference;
|
|
467
|
+
var getSessionFolders = sessionFolderPreferences.getSessionFolders;
|
|
468
|
+
var setSessionFolders = sessionFolderPreferences.setSessionFolders;
|
|
465
469
|
var getScheduledTaskInterviewEngine = scheduledTaskPreferences.get;
|
|
466
470
|
var setScheduledTaskInterviewEngine = scheduledTaskPreferences.set;
|
|
467
471
|
var getDefaultAiPreference = defaultAiPreferences.getDefaultAiPreference;
|
|
@@ -545,6 +549,8 @@ module.exports = {
|
|
|
545
549
|
setHomeDockPreference: setHomeDockPreference,
|
|
546
550
|
getHomeSurfacePreference: getHomeSurfacePreference,
|
|
547
551
|
setHomeSurfacePreference: setHomeSurfacePreference,
|
|
552
|
+
getSessionFolders: getSessionFolders,
|
|
553
|
+
setSessionFolders: setSessionFolders,
|
|
548
554
|
getScheduledTaskInterviewEngine: getScheduledTaskInterviewEngine,
|
|
549
555
|
setScheduledTaskInterviewEngine: setScheduledTaskInterviewEngine,
|
|
550
556
|
getDefaultAiPreference: getDefaultAiPreference,
|
package/lib/ws-schema.js
CHANGED
|
@@ -25,6 +25,17 @@ var schema = {
|
|
|
25
25
|
"rename_session": { direction: "c2s", handler: "lib/project-sessions.js", description: "Rename a session" },
|
|
26
26
|
"set_session_bookmark": { direction: "c2s", handler: "lib/project-sessions.js", description: "Bookmark or unbookmark a session in the sidebar" },
|
|
27
27
|
"reorder_session_bookmarks": { direction: "c2s", handler: "lib/project-sessions.js", description: "Reorder favorited sessions within the favorites area" },
|
|
28
|
+
"session_folders_get": { direction: "c2s", handler: "lib/project-session-folders.js", description: "Request the caller's personal session folder state for this project" },
|
|
29
|
+
"session_folders_op": { direction: "c2s", handler: "lib/project-session-folders.js", description: "Validated folder operation (create/rename/delete/reorder folder, place/reorder session, collapse, view)" },
|
|
30
|
+
"session_folders_delete_preview": { direction: "c2s", handler: "lib/project-session-folder-delete.js", description: "Ask for the authoritative session count and snapshot token before deleting a folder" },
|
|
31
|
+
"session_folders_delete": { direction: "c2s", handler: "lib/project-session-folder-delete.js", description: "Delete a folder, moving its sessions to Unfiled or another folder, or deleting them (snapshot token required)" },
|
|
32
|
+
"session_folders_state": { direction: "s2c", handler: "lib/public/modules/session-folders.js", description: "Authoritative per-user, per-project session folder state; sent on connect and after every change to all of that user's sockets" },
|
|
33
|
+
"new_session_options_get": { direction: "c2s", handler: "lib/project-session-creation.js", description: "Ask for the installed providers, the project's default provider and whether the caller may set it" },
|
|
34
|
+
"new_session_default_set": { direction: "c2s", handler: "lib/project-session-creation.js", description: "Save the project-scoped default provider the inline New session row preselects" },
|
|
35
|
+
"new_session_options": { direction: "s2c", handler: "lib/public/modules/session-create.js", description: "Installed providers, project default provider and default-setting permission for the inline New session row" },
|
|
36
|
+
"new_session_default_result": { direction: "s2c", handler: "lib/public/modules/session-create.js", description: "Outcome of saving the project New session default" },
|
|
37
|
+
"new_session_project_default": { direction: "s2c", handler: "lib/public/modules/session-create.js", description: "The project New session default changed" },
|
|
38
|
+
"session_folders_delete_preview_result": { direction: "s2c", handler: "lib/public/modules/session-folders.js", description: "Server count and snapshot token for a pending folder deletion" },
|
|
28
39
|
"bulk_delete_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "Delete a group of sessions at once" },
|
|
29
40
|
"set_session_visibility": { direction: "c2s", handler: "lib/project-sessions.js", description: "Show or hide a session in the sidebar" },
|
|
30
41
|
"set_project_session_visibility_default": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set private/shared default for new ordinary project sessions" },
|
package/package.json
CHANGED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// Shared, presentation-only session creation controls for desktop and mobile.
|
|
2
|
-
|
|
3
|
-
import { iconHtml } from './icons.js';
|
|
4
|
-
|
|
5
|
-
var SESSION_VENDOR_LABELS = {
|
|
6
|
-
claude: "Claude",
|
|
7
|
-
codex: "Codex",
|
|
8
|
-
antigravity: "Antigravity",
|
|
9
|
-
opencode: "OpenCode",
|
|
10
|
-
kimi: "Kimi",
|
|
11
|
-
grok: "Grok",
|
|
12
|
-
copilot: "Copilot",
|
|
13
|
-
qwen: "Qwen",
|
|
14
|
-
junie: "Junie",
|
|
15
|
-
kiro: "Kiro",
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export function renderSessionCreationCta(options) {
|
|
19
|
-
var row = document.createElement("div");
|
|
20
|
-
row.className = options.rowClass + " session-create-cta";
|
|
21
|
-
|
|
22
|
-
var createButton = document.createElement("button");
|
|
23
|
-
createButton.className = options.createClass + " session-create-primary";
|
|
24
|
-
createButton.type = "button";
|
|
25
|
-
var vendorLabel = SESSION_VENDOR_LABELS[options.vendorKey] || options.vendorName;
|
|
26
|
-
createButton.innerHTML = '<img src="' + options.vendorAvatar +
|
|
27
|
-
'" class="' + options.iconClass + ' session-create-provider-icon" alt="">' +
|
|
28
|
-
'<span>Create new ' + vendorLabel + ' session</span>';
|
|
29
|
-
createButton.title = options.createTitle;
|
|
30
|
-
createButton.setAttribute("aria-label", options.createTitle);
|
|
31
|
-
createButton.addEventListener("click", options.onCreate);
|
|
32
|
-
row.appendChild(createButton);
|
|
33
|
-
|
|
34
|
-
var providerButton = document.createElement("button");
|
|
35
|
-
providerButton.className = options.providerClass + " session-create-provider";
|
|
36
|
-
providerButton.type = "button";
|
|
37
|
-
providerButton.title = options.providerTitle;
|
|
38
|
-
providerButton.setAttribute("aria-label", "Switch AI provider (current: " + options.vendorName + ")");
|
|
39
|
-
providerButton.setAttribute("aria-expanded", "false");
|
|
40
|
-
providerButton.setAttribute("aria-controls", options.menuId);
|
|
41
|
-
providerButton.innerHTML = iconHtml("chevron-down", "session-create-provider-chevron");
|
|
42
|
-
providerButton.addEventListener("click", options.onProvider);
|
|
43
|
-
row.appendChild(providerButton);
|
|
44
|
-
|
|
45
|
-
return { element: row, createButton: createButton, providerButton: providerButton };
|
|
46
|
-
}
|