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
package/lib/daemon.js CHANGED
@@ -23,6 +23,7 @@ delete process.env.CLAUDECODE;
23
23
 
24
24
  var fs = require("fs");
25
25
  var path = require("path");
26
+ var newSessionDefault = require("./new-session-default");
26
27
  var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, removeFromClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo, isPidAlive, clearStaleConfig, REAL_HOME } = require("./config");
27
28
  var { createIPCServer } = require("./ipc");
28
29
  var { createServer, generateAuthToken } = require("./server");
@@ -719,6 +720,14 @@ var relay = createServer({
719
720
  }
720
721
  return { vendor: null };
721
722
  },
723
+ // The provider the project's inline New session row preselects. Stored on
724
+ // the project's own config entry only.
725
+ onGetProjectNewSessionDefault: function (slug) {
726
+ return newSessionDefault.getNewSessionDefault(config, slug);
727
+ },
728
+ onSetProjectNewSessionDefault: function (slug, preference) {
729
+ return newSessionDefault.setNewSessionDefault(config, slug, preference, saveConfig);
730
+ },
722
731
  onSetProjectLastVendor: function (slug, vendor) {
723
732
  for (var i = 0; i < config.projects.length; i++) {
724
733
  if (config.projects[i].slug === slug) {
@@ -0,0 +1,45 @@
1
+ // The project-scoped New session default (the provider only), kept on the
2
+ // project's own config entry. Worktrees (slug "<parent>--<name>") read their
3
+ // parent project's default and cannot set their own. Pure over a config object
4
+ // and an injected save function so the daemon callbacks are directly testable.
5
+
6
+ var access = require("./daemon-project-access");
7
+
8
+ function findProject(config, slug) {
9
+ var projects = config && Array.isArray(config.projects) ? config.projects : [];
10
+ for (var i = 0; i < projects.length; i++) if (projects[i].slug === slug) return projects[i];
11
+ return null;
12
+ }
13
+
14
+ function clean(preference) {
15
+ if (!preference || typeof preference.vendor !== "string" || !preference.vendor) return null;
16
+ return { vendor: preference.vendor };
17
+ }
18
+
19
+ function getNewSessionDefault(config, slug) {
20
+ var owner = findProject(config, access.parentSlugFor(slug) || slug);
21
+ return { preference: owner ? clean(owner.newSessionDefault) : null };
22
+ }
23
+
24
+ // A failed save leaves the in-memory config exactly as it was, so the daemon
25
+ // never reports as saved something that is not on disk.
26
+ function setNewSessionDefault(config, slug, preference, save) {
27
+ if (access.parentSlugFor(slug)) return { ok: false, error: "Worktrees inherit the project default from their parent project." };
28
+ var project = findProject(config, slug);
29
+ if (!project) return { ok: false, error: "Project not found" };
30
+ var had = Object.prototype.hasOwnProperty.call(project, "newSessionDefault");
31
+ var previous = project.newSessionDefault;
32
+ var next = clean(preference);
33
+ if (next) project.newSessionDefault = next;
34
+ else delete project.newSessionDefault;
35
+ try {
36
+ save(config);
37
+ } catch (error) {
38
+ if (had) project.newSessionDefault = previous;
39
+ else delete project.newSessionDefault;
40
+ return { ok: false, error: "The project default could not be saved." };
41
+ }
42
+ return { ok: true };
43
+ }
44
+
45
+ module.exports = { getNewSessionDefault: getNewSessionDefault, setNewSessionDefault: setNewSessionDefault };
@@ -83,6 +83,7 @@ function attachConnection(ctx) {
83
83
  var _vendorLogin = ctx._vendorLogin;
84
84
  var hydrateImageRefs = ctx.hydrateImageRefs;
85
85
  var resolveSessionForView = ctx.resolveSessionForView;
86
+ var sendSessionFoldersState = ctx.sendSessionFoldersState;
86
87
  var broadcastClientCount = ctx.broadcastClientCount;
87
88
  var broadcastPresence = ctx.broadcastPresence;
88
89
  var getProjectList = ctx.getProjectList;
@@ -233,6 +234,7 @@ function attachConnection(ctx) {
233
234
  sessions: sm.mapSessionsForClient(allSessions, undefined, undefined, wsUser ? wsUser.id : null),
234
235
  });
235
236
  if (_splitGroups) _splitGroups.sendConnectionState(ws);
237
+ if (sendSessionFoldersState) sendSessionFoldersState(ws);
236
238
 
237
239
  // Restore active session for this client from server-side presence
238
240
  var active = restoredState.active;
@@ -268,8 +270,8 @@ function attachConnection(ctx) {
268
270
 
269
271
  var total = active.history.length;
270
272
  var fromIndex = 0;
271
- if (total > sm.HISTORY_PAGE_SIZE) {
272
- fromIndex = sm.findTurnBoundary(active.history, Math.max(0, total - sm.HISTORY_PAGE_SIZE));
273
+ if (total > sm.INITIAL_HISTORY_SIZE) {
274
+ fromIndex = sm.findTurnBoundary(active.history, Math.max(0, total - sm.INITIAL_HISTORY_SIZE));
273
275
  }
274
276
  sendTo(ws, { type: "history_meta", total: total, from: fromIndex });
275
277
  for (var i = fromIndex; i < total; i++) {
@@ -0,0 +1,107 @@
1
+ // Inline session creation for the project sidebar. The draft row's provider
2
+ // dropdown lists every registered provider the server knows, flagging which are
3
+ // installed and authorized for the caller; only those can be created. The model
4
+ // and effort stay whatever the provider's existing defaults produce. This module
5
+ // serves that list together with the project-scoped default provider, and saves
6
+ // that default on request.
7
+ //
8
+ // The default is stored per project (daemon config, see new-session-default.js)
9
+ // and holds a provider id only. It is never a per-user or global preference.
10
+
11
+ var MAX_REQUEST_ID = 200;
12
+
13
+ function cleanRequestId(value) {
14
+ return typeof value === "string" ? value.substring(0, MAX_REQUEST_ID) : null;
15
+ }
16
+
17
+ function attachSessionCreation(ctx) {
18
+ var slug = ctx.slug;
19
+ var usersModule = ctx.usersModule;
20
+ var opts = ctx.opts || {};
21
+ var sendTo = ctx.sendTo;
22
+
23
+ function actor(ws) {
24
+ if (!usersModule.isMultiUser()) return { ok: true, user: null };
25
+ var user = ws && ws._clayUser && usersModule.findUserById(ws._clayUser.id);
26
+ return user ? { ok: true, user: user } : { ok: false, user: null };
27
+ }
28
+
29
+ // Single-user installs own their projects. Otherwise project settings
30
+ // permission plus project ownership (or admin) is required, like the other
31
+ // project-wide defaults. Mate projects never get one; worktrees read their
32
+ // parent's default and cannot set their own.
33
+ function defaultPermission(ws) {
34
+ if (ctx.isMate === true) return { ok: false, error: "Not supported" };
35
+ var who = actor(ws);
36
+ if (!who.ok) return { ok: false, error: "Your account is no longer available." };
37
+ var access = ctx.getProjectAccess ? ctx.getProjectAccess() : null;
38
+ if (access && access.isWorktree === true) return { ok: false, error: "Worktrees use the default of their parent project." };
39
+ if (!usersModule.isMultiUser()) return { ok: true };
40
+ var permissions = usersModule.getEffectivePermissions ? usersModule.getEffectivePermissions(who.user, ctx.osUsers) : {};
41
+ var isOwner = !!(access && access.ownerId && access.ownerId === who.user.id);
42
+ var isAdmin = who.user.role === "admin";
43
+ if (!permissions.projectSettings || (!isOwner && !isAdmin)) return { ok: false, error: "Project settings access is not permitted" };
44
+ return { ok: true };
45
+ }
46
+
47
+ function readProjectDefault() {
48
+ if (typeof opts.onGetProjectNewSessionDefault !== "function") return null;
49
+ var stored = opts.onGetProjectNewSessionDefault(slug);
50
+ var value = stored && stored.preference;
51
+ return value && typeof value.vendor === "string" && value.vendor ? value.vendor : null;
52
+ }
53
+
54
+ function installed(ws) {
55
+ return (ctx.getVendorAvailability(ws) || []).filter(function (item) { return item.installed; });
56
+ }
57
+
58
+ function optionsMessage(ws, requestId) {
59
+ return {
60
+ type: "new_session_options", slug: slug, requestId: requestId,
61
+ vendors: (ctx.getVendorAvailability(ws) || []).map(function (item) { return { id: item.id, displayName: item.displayName, installed: item.installed === true }; }),
62
+ projectDefault: readProjectDefault(),
63
+ canSetProjectDefault: defaultPermission(ws).ok,
64
+ };
65
+ }
66
+
67
+ function wrongProject(ws, msg, type, requestId) {
68
+ if (msg.slug === undefined || msg.slug === slug) return false;
69
+ sendTo(ws, { type: type, slug: slug, requestId: requestId, ok: false, error: "This request belongs to a different project." });
70
+ return true;
71
+ }
72
+
73
+ function setDefault(ws, msg) {
74
+ var requestId = cleanRequestId(msg.requestId);
75
+ function reply(extra) { sendTo(ws, Object.assign({ type: "new_session_default_result", slug: slug, requestId: requestId }, extra)); }
76
+ if (wrongProject(ws, msg, "new_session_default_result", requestId)) return;
77
+ var permission = defaultPermission(ws);
78
+ if (!permission.ok) return reply({ ok: false, error: permission.error });
79
+ if (typeof opts.onSetProjectNewSessionDefault !== "function") return reply({ ok: false, error: "Not supported" });
80
+ var vendor = typeof msg.vendor === "string" ? msg.vendor : "";
81
+ if (!vendor || !installed(ws).some(function (item) { return item.id === vendor; })) return reply({ ok: false, error: "That provider is not installed or authorized." });
82
+ var result = opts.onSetProjectNewSessionDefault(slug, { vendor: vendor });
83
+ if (!result || result.ok !== true) return reply({ ok: false, error: result && result.error || "The project default could not be saved." });
84
+ reply({ ok: true, projectDefault: vendor });
85
+ if (typeof ctx.send === "function") ctx.send({ type: "new_session_project_default", slug: slug, projectDefault: vendor });
86
+ }
87
+
88
+ function handleMessage(ws, msg) {
89
+ if (msg.type === "new_session_options_get") {
90
+ var requestId = cleanRequestId(msg.requestId);
91
+ if (wrongProject(ws, msg, "new_session_options", requestId)) return true;
92
+ sendTo(ws, optionsMessage(ws, requestId));
93
+ return true;
94
+ }
95
+ if (msg.type === "new_session_default_set") { setDefault(ws, msg); return true; }
96
+ return false;
97
+ }
98
+
99
+ return { handleMessage: handleMessage, isInstalled: function (ws, vendor) { return isInstalledFor(ctx, ws, vendor); } };
100
+ }
101
+
102
+ function isInstalledFor(ctx, ws, vendor) {
103
+ if (typeof ctx.getVendorAvailability !== "function") return true;
104
+ return (ctx.getVendorAvailability(ws) || []).some(function (item) { return item.id === vendor && item.installed; });
105
+ }
106
+
107
+ module.exports = { attachSessionCreation: attachSessionCreation };
@@ -78,6 +78,25 @@ function attachSessionDelete(ctx) {
78
78
  else sm.deleteSessionsBulk(scope.ids, ws);
79
79
  }
80
80
 
81
+ // Authorizes a whole deletion before anything is removed: permission, access
82
+ // to every requested session, and access to every Split Worker a Driver owns
83
+ // (hidden sessions and previous generations included).
84
+ function planDeletion(ws, targets, access) {
85
+ if (!hasDeletePermission(ws)) return { ok: false, error: "You do not have permission to delete sessions" };
86
+ var canDelete = accessCheck(ws, access);
87
+ for (var i = 0; i < targets.length; i++) {
88
+ if (!canDelete(targets[i])) return { ok: false, error: "You cannot delete one of these sessions" };
89
+ }
90
+ return resolveDeletionScope(sm.sessions, targets, canDelete);
91
+ }
92
+
93
+ // Removes an authorized plan: runtimes are released first, then the records.
94
+ function executeDeletion(ws, plan) {
95
+ if (!plan || !plan.ok || plan.ids.length === 0) return;
96
+ releaseRuntime(plan.ids);
97
+ sm.deleteSessionsBulk(plan.ids, ws);
98
+ }
99
+
81
100
  function bulkDeleteSessions(ws, msg) {
82
101
  if (!Array.isArray(msg.sessionIds) || msg.sessionIds.length === 0) return;
83
102
  if (!hasDeletePermission(ws)) return refuse(ws, "You do not have permission to delete sessions");
@@ -91,11 +110,10 @@ function attachSessionDelete(ctx) {
91
110
  if (requested.length === 0) return;
92
111
  var scope = resolveDeletionScope(sm.sessions, requested, canDelete);
93
112
  if (!scope.ok) return refuse(ws, scope.error);
94
- releaseRuntime(scope.ids);
95
- sm.deleteSessionsBulk(scope.ids, ws);
113
+ executeDeletion(ws, scope);
96
114
  }
97
115
 
98
- return { deleteSession: deleteSession, bulkDeleteSessions: bulkDeleteSessions };
116
+ return { deleteSession: deleteSession, bulkDeleteSessions: bulkDeleteSessions, planDeletion: planDeletion, executeDeletion: executeDeletion, hasDeletePermission: hasDeletePermission };
99
117
  }
100
118
 
101
119
  module.exports = { attachSessionDelete: attachSessionDelete, resolveDeletionScope: resolveDeletionScope };
@@ -0,0 +1,169 @@
1
+ // Folder deletion with a choice for the folder's sessions: move them to
2
+ // Unfiled, move them to another folder, or delete them with the folder.
3
+ //
4
+ // The flow is two requests so a confirmation always describes what will happen:
5
+ // session_folders_delete_preview -> the server's authoritative membership
6
+ // count and an opaque snapshot token
7
+ // session_folders_delete -> the chosen mode plus that token
8
+ // The token is a keyed digest of the folder, its current members and the full
9
+ // deletion scope. If the contents changed in between (a session was filed into
10
+ // the folder, moved out, or gained a Worker) the request is refused untouched
11
+ // and a fresh preview is sent. Membership always comes from the caller's stored
12
+ // state, never from what a filtered sidebar happens to render, and no origin
13
+ // key ever leaves the server.
14
+
15
+ var crypto = require("crypto");
16
+ var folders = require("./session-folders");
17
+
18
+ var SECRET = crypto.randomBytes(32);
19
+ var MODES = ["unfiled", "move", "delete"];
20
+
21
+ function attachFolderDelete(ctx) {
22
+ var slug = ctx.slug;
23
+ var sendTo = ctx.sendTo;
24
+
25
+ // The live members of a folder: every existing session the caller filed
26
+ // there, whatever the sidebar search currently shows. Keys whose session is
27
+ // gone are skipped (they are pruned when the folder state is saved).
28
+ function snapshot(userId, env, state, folderId, ws) {
29
+ var members = [];
30
+ Object.keys(state.assignments).forEach(function (key) {
31
+ if (state.assignments[key] === folderId && env.exists(key)) members.push(env.byKey[key]);
32
+ });
33
+ members.sort(function (a, b) { return a.localId - b.localId; });
34
+ var plan = ctx.sessionDelete.planDeletion(ws, members, ctx.getProjectAccess());
35
+ var scopeIds = plan.ok ? plan.ids.slice().sort(function (a, b) { return a - b; }) : [];
36
+ var workerIds = plan.ok ? plan.cascadedIds.slice().sort(function (a, b) { return a - b; }) : [];
37
+ var memberIds = members.map(function (s) { return s.localId; });
38
+ var digest = crypto.createHmac("sha256", SECRET)
39
+ .update(JSON.stringify([userId, slug, folderId, memberIds, scopeIds, plan.ok]))
40
+ .digest("hex").slice(0, 32);
41
+ return {
42
+ members: members, plan: plan, memberCount: memberIds.length,
43
+ workerCount: workerIds.length, totalCount: plan.ok ? scopeIds.length : memberIds.length,
44
+ token: digest,
45
+ };
46
+ }
47
+
48
+ function previewMessage(folderId, requestId, snap, hasPermission) {
49
+ return {
50
+ type: "session_folders_delete_preview_result", slug: slug, requestId: requestId || null, folderId: folderId,
51
+ sessionCount: snap.memberCount, workerCount: snap.workerCount, totalCount: snap.totalCount, token: snap.token,
52
+ canDeleteSessions: hasPermission && snap.plan.ok,
53
+ deleteBlockedReason: !hasPermission ? "You do not have permission to delete sessions" : (snap.plan.ok ? null : snap.plan.error),
54
+ };
55
+ }
56
+
57
+ // Persisting can throw (the multi-user store writes a file); that must come
58
+ // back as a correlated refusal, never as an acknowledgement or an exception.
59
+ function saveState(userId, nextState) {
60
+ try {
61
+ var saved = ctx.usersModule.setSessionFolders(userId, slug, nextState);
62
+ return saved && saved.error ? { error: saved.error } : { state: saved && saved.state ? saved.state : nextState };
63
+ } catch (e) {
64
+ console.error("[folders] saving folder state failed:", e && e.message);
65
+ return { error: "Could not save the folders" };
66
+ }
67
+ }
68
+
69
+ function reload(userId, env, fallback) {
70
+ try { return ctx.loadState(userId, env); } catch (e) { return fallback; }
71
+ }
72
+
73
+ function knownFolder(state, folderId) {
74
+ if (typeof folderId !== "string" || folderId === folders.FAVORITES_ID || folderId === folders.UNFILED_ID) return false;
75
+ return state.folders.some(function (f) { return f.id === folderId; });
76
+ }
77
+
78
+ function preview(ws, msg) {
79
+ var userId = ctx.userIdFor(ws);
80
+ if (!userId) return;
81
+ var rid = ctx.cleanRequestId(msg.requestId);
82
+ if (ctx.wrongProject(ws, msg.slug, rid)) return;
83
+ var env = ctx.buildEnv(userId);
84
+ var state = ctx.loadState(userId, env);
85
+ if (!knownFolder(state, msg.folderId)) {
86
+ sendTo(ws, ctx.stateMessage(state, env, { error: "Folder not found", requestId: rid }));
87
+ return;
88
+ }
89
+ var snap = snapshot(userId, env, state, msg.folderId, ws);
90
+ sendTo(ws, previewMessage(msg.folderId, rid, snap, ctx.sessionDelete.hasDeletePermission(ws)));
91
+ }
92
+
93
+ function refuse(ws, userId, env, state, rid, text, snapForRefresh, folderId) {
94
+ sendTo(ws, ctx.stateMessage(state, env, { error: text, requestId: rid, stale: !!snapForRefresh }));
95
+ if (snapForRefresh) sendTo(ws, previewMessage(folderId, rid, snapForRefresh, ctx.sessionDelete.hasDeletePermission(ws)));
96
+ }
97
+
98
+ function run(ws, msg) {
99
+ var userId = ctx.userIdFor(ws);
100
+ if (!userId) return;
101
+ var rid = ctx.cleanRequestId(msg.requestId);
102
+ if (ctx.wrongProject(ws, msg.slug, rid)) return;
103
+ var env = ctx.buildEnv(userId);
104
+ var state = ctx.loadState(userId, env);
105
+ var folderId = msg.folderId;
106
+ var mode = msg.mode;
107
+
108
+ // Everything below is validated before the first mutation.
109
+ if (!knownFolder(state, folderId)) return refuse(ws, userId, env, state, rid, "Folder not found");
110
+ if (MODES.indexOf(mode) === -1) return refuse(ws, userId, env, state, rid, "Choose what happens to the sessions");
111
+ if (mode === "move") {
112
+ var dest = msg.destinationId;
113
+ if (typeof dest !== "string" || dest === folderId || dest === folders.UNFILED_ID ||
114
+ !knownFolder(state, dest)) {
115
+ return refuse(ws, userId, env, state, rid, "Choose another folder for these sessions");
116
+ }
117
+ }
118
+ var snap = snapshot(userId, env, state, folderId, ws);
119
+ if (typeof msg.token !== "string" || msg.token !== snap.token) {
120
+ return refuse(ws, userId, env, state, rid, "This folder changed while you were deciding. Review it and try again.", snap, folderId);
121
+ }
122
+ if (mode === "delete" && msg.confirmDelete !== true) {
123
+ return refuse(ws, userId, env, state, rid, "Confirm that you want to delete these sessions");
124
+ }
125
+ if (mode === "delete" && !snap.plan.ok) {
126
+ return refuse(ws, userId, env, state, rid, snap.plan.error);
127
+ }
128
+
129
+ if (mode === "delete" && snap.members.length) {
130
+ try {
131
+ ctx.sessionDelete.executeDeletion(ws, snap.plan);
132
+ } catch (e) {
133
+ // The folder is left in place; whatever was already removed is pruned
134
+ // from it on the next state load.
135
+ console.error("[folders] session deletion failed:", e && e.message);
136
+ // Removal is not transactional: some sessions may already be gone.
137
+ var after = ctx.buildEnv(userId);
138
+ return refuse(ws, userId, after, reload(userId, after, state), rid,
139
+ "Deleting stopped part way. Some of these sessions may already have been deleted, and the folder was kept. Review the folder and try again.");
140
+ }
141
+ env = ctx.buildEnv(userId);
142
+ }
143
+
144
+ var op = mode === "move"
145
+ ? { op: "delete_folder", folderId: folderId, mode: "move", destinationId: msg.destinationId }
146
+ : { op: "delete_folder", folderId: folderId, mode: "unfiled" };
147
+ var result = folders.applyOperation(state, op, env);
148
+ if (result.error) return refuse(ws, userId, env, state, rid, result.error);
149
+ var saved = saveState(userId, result.state);
150
+ if (saved.error) {
151
+ var current = mode === "delete" ? reload(userId, env, state) : state;
152
+ return refuse(ws, userId, env, current, rid, mode === "delete"
153
+ ? "The sessions were deleted but the folder could not be removed. Try deleting it again."
154
+ : "The folder could not be updated, so nothing was changed. " + saved.error + ".");
155
+ }
156
+ ctx.pushToUser(userId, saved.state, env);
157
+ sendTo(ws, ctx.stateMessage(saved.state, env, { requestId: rid, folderDeleted: folderId }));
158
+ }
159
+
160
+ function handleMessage(ws, msg) {
161
+ if (msg.type === "session_folders_delete_preview") { preview(ws, msg); return true; }
162
+ if (msg.type === "session_folders_delete") { run(ws, msg); return true; }
163
+ return false;
164
+ }
165
+
166
+ return { handleMessage: handleMessage };
167
+ }
168
+
169
+ module.exports = { attachFolderDelete: attachFolderDelete };