clay-server 5.0.0-beta.1 → 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-document.js +9 -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/sdk-bridge.js +21 -14
- 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/lib/yoke/adapters/codex.js +2 -2
- 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
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.
|
|
272
|
-
fromIndex = sm.findTurnBoundary(active.history, Math.max(0, total - sm.
|
|
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
|
-
|
|
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 };
|
|
@@ -84,13 +84,19 @@ function attachSessionDocument(ctx) {
|
|
|
84
84
|
function presentWireframe(args, caller) {
|
|
85
85
|
if (!caller) return toolError("present_wireframe requires a session-bound tool server");
|
|
86
86
|
if (typeof args.source !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(args.id || "")) return toolError("Provide source and a short stable id using letters, digits, hyphens or underscores.");
|
|
87
|
+
var source;
|
|
87
88
|
try {
|
|
88
|
-
|
|
89
|
-
require("./salt-parser").parseSalt(source);
|
|
89
|
+
source = wireframeRenderer.prepareSource(args.source, "diagram.puml");
|
|
90
90
|
if (!/^\s*@end(?:salt|uml)\b/im.test(source)) return toolError("Provide a complete @startsalt/@endsalt or @startuml/@enduml diagram.");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return toolError(e.message || String(e));
|
|
93
|
+
}
|
|
94
|
+
return wireframeRenderer.renderPlantUml(source).then(function () {
|
|
91
95
|
sendToSession(caller.localId, { type: "wireframe_present", source: source, id: args.id, title: String(args.title || "clay-sketch").slice(0, 100), sessionId: caller.localId });
|
|
92
96
|
return toolResult({ ready: true, id: args.id, saved: false });
|
|
93
|
-
}
|
|
97
|
+
}).catch(function (e) {
|
|
98
|
+
return toolError(e && e.message ? e.message : String(e));
|
|
99
|
+
});
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
function getToolDefs(boundSession) {
|
|
@@ -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 };
|