@3sln/trove 0.0.7 → 0.0.9
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/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -5,44 +5,50 @@
|
|
|
5
5
|
// they update flow straight back into the UI via `watch`.
|
|
6
6
|
|
|
7
7
|
import { Action } from '@3sln/ngin';
|
|
8
|
+
import { PROMPT } from './viewState.js';
|
|
9
|
+
import { beginInstallFromUrl } from './pluginInstall.js';
|
|
8
10
|
import { newId } from '@3sln/trove/core/util.js';
|
|
9
11
|
import { matchesTagFilters } from './tagQuery.js';
|
|
10
|
-
import { availableOpeners, rememberedOpenerId } from './openers.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
12
|
+
import { availableOpeners, rememberedOpenerId, withAssociation, ASSOC_KEY } from './openers.js';
|
|
13
|
+
// A share link and a `trove:` URI are the same address in two spellings — see core/links.js.
|
|
14
|
+
import { parseShareUrl, troveUri, shareUrl } from '@3sln/trove/core/links.js';
|
|
15
|
+
import { selectedNodesOf, draftScopesOf, collectionMenuOf } from './services.js';
|
|
16
|
+
import { beginInstallFromFile } from './pluginInstall.js';
|
|
17
|
+
import { pickFiles, pickZip, triggerDownload } from '../platform/pickers.js';
|
|
18
|
+
import { wrapIndex } from './state.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Load a collection's items. There is nothing to navigate INTO any more — a drive is
|
|
18
22
|
* browsed by search and by following links — so this only ever switches which
|
|
19
23
|
* collection is on screen, or refreshes the current one.
|
|
20
24
|
*/
|
|
21
|
-
export class NavigateAction extends
|
|
25
|
+
export class NavigateAction extends Action {
|
|
26
|
+
static deps = ['api', 'explorer', 'notifications', 'settings'];
|
|
27
|
+
|
|
22
28
|
constructor(collectionId) {
|
|
23
29
|
super();
|
|
24
30
|
this.collectionId = collectionId;
|
|
25
31
|
}
|
|
26
|
-
async execute(
|
|
27
|
-
const { explorer,
|
|
32
|
+
async execute(r) {
|
|
33
|
+
const { api, explorer, notifications, settings } = r;
|
|
28
34
|
// No `|| 'default'`. Navigating nowhere in particular used to land on a collection
|
|
29
35
|
// that may not exist and may not be readable; now it is a bug in the caller, said out
|
|
30
36
|
// loud rather than papered over with a guess.
|
|
31
|
-
const collectionId = this.collectionId || explorer.
|
|
37
|
+
const collectionId = this.collectionId || explorer.get().collectionId;
|
|
32
38
|
if (!collectionId) {
|
|
33
39
|
explorer.set({ loading: false, gate: 'choose' });
|
|
34
40
|
return;
|
|
35
41
|
}
|
|
36
|
-
const switching = collectionId !== explorer.
|
|
42
|
+
const switching = collectionId !== explorer.get().collectionId;
|
|
37
43
|
// A collection's trash belongs to that collection. Carrying it across a switch
|
|
38
44
|
// listed the OLD collection's deleted files under the new one — above an "Empty
|
|
39
45
|
// trash" button that purges the new one, so the rows and the button disagreed
|
|
40
46
|
// about which drive they were talking about.
|
|
41
47
|
explorer.set({ loading: true, error: null, collectionId, ...(switching ? { trash: null } : {}) });
|
|
42
48
|
try {
|
|
43
|
-
const sort =
|
|
44
|
-
const order =
|
|
45
|
-
const res = await
|
|
49
|
+
const sort = settings.get('explorer.sort');
|
|
50
|
+
const order = settings.get('explorer.sortOrder');
|
|
51
|
+
const res = await api.list(collectionId, { sort, order });
|
|
46
52
|
explorer.set({
|
|
47
53
|
items: res.items, loading: false, selection: [], sort, order,
|
|
48
54
|
collectionId: res.collectionId || collectionId,
|
|
@@ -53,23 +59,25 @@ export class NavigateAction extends AppAction {
|
|
|
53
59
|
nextCursor: res.nextCursor || null,
|
|
54
60
|
});
|
|
55
61
|
// Remember where they were, so the next visit opens there rather than guessing.
|
|
56
|
-
|
|
62
|
+
settings.set?.('explorer.lastCollection', collectionId);
|
|
57
63
|
explorer.set({ gate: null });
|
|
58
64
|
// Explorer→context projection (collectionId/hasSelection) lives in bl/index.js
|
|
59
65
|
// so it stays in sync with selection too — nothing to mirror here.
|
|
60
66
|
} catch (err) {
|
|
61
67
|
explorer.set({ loading: false, error: err.message });
|
|
62
|
-
|
|
68
|
+
notifications.error(`Couldn't load this collection: ${err.message}`);
|
|
63
69
|
}
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
export class LoadCollectionsAction extends
|
|
68
|
-
|
|
73
|
+
export class LoadCollectionsAction extends Action {
|
|
74
|
+
static deps = ['api', 'explorer'];
|
|
75
|
+
|
|
76
|
+
async execute(r) {
|
|
69
77
|
try {
|
|
70
|
-
const res = await
|
|
78
|
+
const res = await r.api.collections();
|
|
71
79
|
const collections = res.collections || [];
|
|
72
|
-
|
|
80
|
+
r.explorer.set({ collections, canCreateCollection: !!res.canCreate });
|
|
73
81
|
return collections;
|
|
74
82
|
} catch { /* collections disabled */ return []; }
|
|
75
83
|
}
|
|
@@ -84,29 +92,40 @@ export class LoadCollectionsAction extends AppAction {
|
|
|
84
92
|
* is working correctly. So: keep the last collection if it is still readable, otherwise
|
|
85
93
|
* take the first one they can actually see.
|
|
86
94
|
*/
|
|
87
|
-
export class OpenInitialCollectionAction extends
|
|
88
|
-
|
|
95
|
+
export class OpenInitialCollectionAction extends Action {
|
|
96
|
+
static deps = ['api', 'engine', 'explorer', 'notifications', 'settings'];
|
|
97
|
+
|
|
98
|
+
async execute(r) {
|
|
99
|
+
// A share link decides where we land, ahead of everything below.
|
|
100
|
+
//
|
|
101
|
+
// Arriving at a link to a specific item and being asked which collection you would
|
|
102
|
+
// like to open would be absurd — the link already said. So this runs before the
|
|
103
|
+
// remembered choice and before the gate, and only falls through to them when the URL
|
|
104
|
+
// is not a share link or cannot be honoured.
|
|
105
|
+
const shared = parseShareUrl(typeof location !== 'undefined' ? location.pathname : '');
|
|
106
|
+
if (shared) return this.#openShared(r, shared);
|
|
107
|
+
|
|
89
108
|
// Calls the API directly rather than dispatching LoadCollectionsAction: ngin's
|
|
90
109
|
// dispatch() returns an event feed, not the action's value, so awaiting it would
|
|
91
110
|
// hand back an EventTarget and this would fail in a way nothing reports.
|
|
92
111
|
let collections = [];
|
|
93
112
|
let canCreate = false;
|
|
94
113
|
try {
|
|
95
|
-
const res = await
|
|
114
|
+
const res = await r.api.collections();
|
|
96
115
|
collections = res.collections || [];
|
|
97
116
|
canCreate = !!res.canCreate;
|
|
98
|
-
|
|
117
|
+
r.explorer.set({ collections, canCreateCollection: canCreate });
|
|
99
118
|
} catch (err) {
|
|
100
|
-
|
|
119
|
+
r.explorer.set({ loading: false, error: `Couldn't load your collections: ${err.message}` });
|
|
101
120
|
return;
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
const ids = collections.map((c) => c.id);
|
|
105
|
-
const remembered =
|
|
124
|
+
const remembered = r.settings.get('explorer.lastCollection');
|
|
106
125
|
|
|
107
126
|
// Nothing exists yet. Two different situations that used to read as one:
|
|
108
127
|
if (!ids.length) {
|
|
109
|
-
|
|
128
|
+
r.explorer.set({
|
|
110
129
|
loading: false, items: [], collections: [],
|
|
111
130
|
// Someone who may create one is on a fresh drive and should be asked to. Someone
|
|
112
131
|
// who may not has no grants, and telling them to create a collection they cannot
|
|
@@ -124,71 +143,191 @@ export class OpenInitialCollectionAction extends AppAction {
|
|
|
124
143
|
// plenty of people could not read — a permission error presented as their drive.
|
|
125
144
|
// A remembered choice is theirs; anything else is a question.
|
|
126
145
|
if (!remembered || !ids.includes(remembered)) {
|
|
127
|
-
|
|
146
|
+
r.explorer.set({ loading: false, items: [], gate: 'choose', error: null });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
r.explorer.set({ gate: null });
|
|
151
|
+
return r.engine.dispatch(new NavigateAction(remembered));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Open the collection and item a share link names.
|
|
156
|
+
*
|
|
157
|
+
* Every way this can fail says which way it failed. A link to a collection you cannot
|
|
158
|
+
* read, and a link to an item that has been renamed, are different problems with
|
|
159
|
+
* different answers, and both used to be indistinguishable from an empty drive.
|
|
160
|
+
*/
|
|
161
|
+
async #openShared(r, shared) {
|
|
162
|
+
const { api, engine, explorer, notifications } = r;
|
|
163
|
+
// The URL is consumed rather than kept. The app does not otherwise reflect its state
|
|
164
|
+
// in the address bar, so leaving a share path there would go stale the moment the user
|
|
165
|
+
// navigated anywhere — a URL that lies is worse than one that is merely uninformative.
|
|
166
|
+
if (typeof history !== 'undefined') history.replaceState(null, '', '/');
|
|
167
|
+
|
|
168
|
+
let collections = [];
|
|
169
|
+
try {
|
|
170
|
+
const res = await api.collections();
|
|
171
|
+
collections = res.collections || [];
|
|
172
|
+
explorer.set({ collections, canCreateCollection: !!res.canCreate });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
explorer.set({ loading: false, error: `Couldn’t load your collections: ${err.message}` });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!collections.some((c) => c.id === shared.collection)) {
|
|
179
|
+
// Said plainly rather than shown as an empty drive. The recipient may simply not
|
|
180
|
+
// have been granted this collection, and that is worth knowing rather than guessing.
|
|
181
|
+
explorer.set({
|
|
182
|
+
loading: false, items: [], gate: null,
|
|
183
|
+
error: `This link points at a collection you do not have access to (“${shared.collection}”). Ask whoever shared it to grant you access.`,
|
|
184
|
+
});
|
|
128
185
|
return;
|
|
129
186
|
}
|
|
130
187
|
|
|
131
|
-
|
|
132
|
-
|
|
188
|
+
explorer.set({ gate: null });
|
|
189
|
+
await engine.dispatch(new NavigateAction(shared.collection));
|
|
190
|
+
|
|
191
|
+
let node;
|
|
192
|
+
try {
|
|
193
|
+
// `stat` answers `{ node }` rather than the node — see bl/links.js, which unwraps it
|
|
194
|
+
// the same way. Reading `.id` off the envelope silently looks like "not found".
|
|
195
|
+
const res = shared.by === 'id'
|
|
196
|
+
? await api.stat(shared.value)
|
|
197
|
+
: await api.stat(shared.value, { collection: shared.collection });
|
|
198
|
+
node = res?.node || null;
|
|
199
|
+
} catch {
|
|
200
|
+
node = null;
|
|
201
|
+
}
|
|
202
|
+
if (!node?.id) {
|
|
203
|
+
// A link by name breaks on rename, deliberately and visibly. Saying so beats
|
|
204
|
+
// landing in the right collection with no explanation of what was expected.
|
|
205
|
+
notifications.warn(
|
|
206
|
+
shared.by === 'name'
|
|
207
|
+
? `“${shared.value}” is not in this collection any more — it may have been renamed or removed.`
|
|
208
|
+
: 'That item no longer exists.',
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
engine.dispatch(new OpenFileAction(node, { reset: true }));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Uninstall a plugin.
|
|
218
|
+
*
|
|
219
|
+
* An action rather than a closure threaded through the render tree. Uninstalling is engine
|
|
220
|
+
* work — it is not a render concern in any sense — and it had exactly one call site,
|
|
221
|
+
* reached by carrying a function through fourteen modules of components that had no use
|
|
222
|
+
* for it except to hand it to their children.
|
|
223
|
+
*/
|
|
224
|
+
/**
|
|
225
|
+
* Run a command.
|
|
226
|
+
*
|
|
227
|
+
* Commands used to be invoked straight on the command service, which meant every command a
|
|
228
|
+
* user ran from the UI — every menu item, every keybinding, every palette entry — went
|
|
229
|
+
* around the engine rather than through it. Nothing could intercept one, nothing could
|
|
230
|
+
* observe one, and the engine's own view of what the app was doing had a hole in it exactly
|
|
231
|
+
* the shape of everything a person actually did.
|
|
232
|
+
*
|
|
233
|
+
* Running one is an action like any other, so it is one.
|
|
234
|
+
*
|
|
235
|
+
* The command's return value is not available here — `dispatch` answers with an event feed
|
|
236
|
+
* rather than the handler's result. That costs nothing today: every one of the call sites
|
|
237
|
+
* is fire-and-forget, and a command that needs to answer its caller wants a query.
|
|
238
|
+
*/
|
|
239
|
+
export class ExecCommandAction extends Action {
|
|
240
|
+
static deps = ['commands'];
|
|
241
|
+
constructor(id, ...args) {
|
|
242
|
+
super();
|
|
243
|
+
this.id = id;
|
|
244
|
+
this.args = args;
|
|
245
|
+
}
|
|
246
|
+
async execute({ commands }) {
|
|
247
|
+
return commands.execute(this.id, ...this.args);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class UninstallPluginAction extends Action {
|
|
252
|
+
static deps = ['notifications', 'plugins'];
|
|
253
|
+
|
|
254
|
+
constructor(pluginId) {
|
|
255
|
+
super();
|
|
256
|
+
this.pluginId = pluginId;
|
|
257
|
+
}
|
|
258
|
+
async execute(r) {
|
|
259
|
+
try {
|
|
260
|
+
await r.plugins.uninstall(this.pluginId);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
r.notifications.error(`Couldn’t uninstall: ${err.message}`);
|
|
263
|
+
}
|
|
133
264
|
}
|
|
134
265
|
}
|
|
135
266
|
|
|
136
|
-
export class CreateCollectionAction extends
|
|
267
|
+
export class CreateCollectionAction extends Action {
|
|
268
|
+
static deps = ['api', 'engine', 'notifications'];
|
|
269
|
+
|
|
137
270
|
constructor(record) {
|
|
138
271
|
super();
|
|
139
272
|
this.record = record;
|
|
140
273
|
}
|
|
141
|
-
async execute(
|
|
274
|
+
async execute(r) {
|
|
142
275
|
try {
|
|
143
|
-
const res = await
|
|
144
|
-
|
|
145
|
-
await
|
|
146
|
-
|
|
276
|
+
const res = await r.api.createCollection(this.record);
|
|
277
|
+
r.notifications.success(`Created collection “${res.collection.name}”`);
|
|
278
|
+
await r.engine.dispatch(new LoadCollectionsAction());
|
|
279
|
+
r.engine.dispatch(new NavigateAction(res.collection.id));
|
|
147
280
|
} catch (err) {
|
|
148
|
-
|
|
281
|
+
r.notifications.error(`Couldn’t create collection: ${err.message}`);
|
|
149
282
|
}
|
|
150
283
|
}
|
|
151
284
|
}
|
|
152
285
|
|
|
153
286
|
/** Append the next page of a collection to what's already on screen. */
|
|
154
|
-
export class LoadMoreAction extends
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
287
|
+
export class LoadMoreAction extends Action {
|
|
288
|
+
static deps = ['api', 'explorer', 'notifications'];
|
|
289
|
+
|
|
290
|
+
async execute(r) {
|
|
291
|
+
const { api, explorer, notifications } = r;
|
|
292
|
+
const cursor = explorer.get().nextCursor;
|
|
293
|
+
if (!cursor || explorer.get().loadingMore) return;
|
|
159
294
|
explorer.set({ loadingMore: true });
|
|
160
295
|
try {
|
|
161
|
-
const res = await
|
|
162
|
-
sort: explorer.
|
|
296
|
+
const res = await api.list(explorer.get().collectionId, {
|
|
297
|
+
sort: explorer.get().sort, order: explorer.get().order, cursor,
|
|
163
298
|
});
|
|
164
299
|
explorer.set({
|
|
165
|
-
items: [...explorer.
|
|
300
|
+
items: [...explorer.get().items, ...res.items],
|
|
166
301
|
nextCursor: res.nextCursor || null,
|
|
167
302
|
loadingMore: false,
|
|
168
303
|
});
|
|
169
304
|
} catch (err) {
|
|
170
305
|
explorer.set({ loadingMore: false });
|
|
171
|
-
|
|
306
|
+
notifications.error(`Couldn't load more: ${err.message}`);
|
|
172
307
|
}
|
|
173
308
|
}
|
|
174
309
|
}
|
|
175
310
|
|
|
176
|
-
export class RefreshAction extends
|
|
177
|
-
|
|
178
|
-
|
|
311
|
+
export class RefreshAction extends Action {
|
|
312
|
+
static deps = ['engine', 'explorer'];
|
|
313
|
+
|
|
314
|
+
async execute(r) {
|
|
315
|
+
return r.engine.dispatch(new NavigateAction(r.explorer.get().collectionId));
|
|
179
316
|
}
|
|
180
317
|
}
|
|
181
318
|
|
|
182
319
|
/** Show what's been deleted but not destroyed, and act on it. */
|
|
183
|
-
export class TrashAction extends
|
|
320
|
+
export class TrashAction extends Action {
|
|
321
|
+
static deps = ['api', 'engine', 'explorer', 'notifications'];
|
|
322
|
+
|
|
184
323
|
constructor(op = 'list', id = null) {
|
|
185
324
|
super();
|
|
186
325
|
this.op = op;
|
|
187
326
|
this.id = id;
|
|
188
327
|
}
|
|
189
|
-
async execute(
|
|
190
|
-
const { explorer,
|
|
191
|
-
const collection = explorer.
|
|
328
|
+
async execute(r) {
|
|
329
|
+
const { api, explorer, notifications } = r;
|
|
330
|
+
const collection = explorer.get().collectionId;
|
|
192
331
|
// Putting it away is one of the things you can do to the trash. Without this the
|
|
193
332
|
// section, once opened, stayed on screen until a page reload — there was no way
|
|
194
333
|
// back to a plain list of files.
|
|
@@ -198,120 +337,145 @@ export class TrashAction extends AppAction {
|
|
|
198
337
|
}
|
|
199
338
|
try {
|
|
200
339
|
if (this.op === 'restore') {
|
|
201
|
-
const { node } = await
|
|
202
|
-
|
|
340
|
+
const { node } = await api.restore(this.id);
|
|
341
|
+
notifications.success(`Restored “${node.name}”`);
|
|
203
342
|
} else if (this.op === 'purge') {
|
|
204
|
-
await
|
|
343
|
+
await api.purgeTrash({ id: this.id });
|
|
205
344
|
} else if (this.op === 'empty') {
|
|
206
|
-
const { purged } = await
|
|
207
|
-
|
|
345
|
+
const { purged } = await api.purgeTrash({ collection });
|
|
346
|
+
notifications.success(`Deleted ${purged} item${purged === 1 ? '' : 's'} for good`);
|
|
208
347
|
}
|
|
209
|
-
const { items } = await
|
|
348
|
+
const { items } = await api.trash(collection);
|
|
210
349
|
explorer.set({ trash: items });
|
|
211
350
|
// Restoring puts something back in the drive, so the list on screen is now stale.
|
|
212
|
-
if (this.op !== 'list') await
|
|
351
|
+
if (this.op !== 'list') await r.engine.dispatch(new NavigateAction(collection));
|
|
213
352
|
} catch (err) {
|
|
214
|
-
|
|
353
|
+
notifications.error(`Trash: ${err.message}`);
|
|
215
354
|
}
|
|
216
355
|
}
|
|
217
356
|
}
|
|
218
357
|
|
|
219
|
-
export class DeleteAction extends
|
|
358
|
+
export class DeleteAction extends Action {
|
|
359
|
+
// `navigation`, not `workbench`: the panel stack and the recents list are both its, and a
|
|
360
|
+
// deleted file must not survive in either.
|
|
361
|
+
static deps = ['api', 'engine', 'navigation', 'notifications'];
|
|
362
|
+
|
|
220
363
|
constructor(ids) {
|
|
221
364
|
super();
|
|
222
365
|
this.ids = ids;
|
|
223
366
|
}
|
|
224
|
-
async execute(
|
|
367
|
+
async execute(r) {
|
|
225
368
|
try {
|
|
226
|
-
for (const id of this.ids) await
|
|
227
|
-
|
|
228
|
-
for (const id of this.ids)
|
|
369
|
+
for (const id of this.ids) await r.api.remove(id);
|
|
370
|
+
r.notifications.info(`Deleted ${this.ids.length} item${this.ids.length > 1 ? 's' : ''}`);
|
|
371
|
+
for (const id of this.ids) r.navigation.forget(id);
|
|
229
372
|
} catch (err) {
|
|
230
|
-
|
|
373
|
+
r.notifications.error(`Couldn’t delete: ${err.message}`);
|
|
231
374
|
}
|
|
232
|
-
|
|
375
|
+
r.engine.dispatch(new RefreshAction());
|
|
233
376
|
}
|
|
234
377
|
}
|
|
235
378
|
|
|
236
|
-
export class RenameAction extends
|
|
379
|
+
export class RenameAction extends Action {
|
|
380
|
+
// The renamed node has to reach the open panel too, or the title bar keeps the old name
|
|
381
|
+
// until something else refreshes it. That stack belongs to `navigation`.
|
|
382
|
+
static deps = ['api', 'engine', 'navigation', 'notifications'];
|
|
383
|
+
|
|
237
384
|
constructor(id, newName) {
|
|
238
385
|
super();
|
|
239
386
|
this.id = id;
|
|
240
387
|
this.newName = newName;
|
|
241
388
|
}
|
|
242
|
-
async execute(
|
|
389
|
+
async execute(r) {
|
|
243
390
|
try {
|
|
244
|
-
const node = await
|
|
245
|
-
|
|
246
|
-
|
|
391
|
+
const node = await r.api.rename(this.id, this.newName);
|
|
392
|
+
r.navigation.updateTabNode(node.node);
|
|
393
|
+
r.engine.dispatch(new RefreshAction());
|
|
247
394
|
} catch (err) {
|
|
248
|
-
|
|
395
|
+
r.notifications.error(`Couldn’t rename: ${err.message}`);
|
|
249
396
|
}
|
|
250
397
|
}
|
|
251
398
|
}
|
|
252
399
|
|
|
253
400
|
|
|
254
|
-
export class OpenFileAction extends
|
|
401
|
+
export class OpenFileAction extends Action {
|
|
402
|
+
static deps = ['context', 'contributions', 'engine', 'plugins', 'settings'];
|
|
403
|
+
|
|
255
404
|
/** @param {object} node @param {{reset?:boolean}} [opts] reset → start a fresh stack (modal search) */
|
|
256
405
|
constructor(node, opts = {}) {
|
|
257
406
|
super();
|
|
258
407
|
this.node = node;
|
|
259
408
|
this.opts = opts;
|
|
260
409
|
}
|
|
261
|
-
async execute(
|
|
410
|
+
async execute(r) {
|
|
262
411
|
// Guard against being invoked with no target (e.g. a command fired with an empty
|
|
263
412
|
// selection) — dereferencing a null node would throw an unhandled rejection.
|
|
264
413
|
if (!this.node) return;
|
|
265
|
-
const {
|
|
266
|
-
const open = (openerId) => platform.workbench.openFile(this.node, openerId, { reset: !!this.opts.reset });
|
|
414
|
+
const open = (openerId) => r.engine.dispatch(new OpenInPanelAction(this.node, openerId, { reset: !!this.opts.reset }));
|
|
267
415
|
|
|
268
416
|
// An explicit opener (e.g. the switch-opener control) wins outright.
|
|
269
417
|
if (this.opts.openerId) return open(this.opts.openerId);
|
|
270
418
|
|
|
271
419
|
// Only openers available right now (a plugin previewer needing the network while
|
|
272
420
|
// offline is skipped, so we fall back to a built-in one).
|
|
273
|
-
const avail = availableOpeners(
|
|
421
|
+
const avail = availableOpeners(r, this.node);
|
|
274
422
|
|
|
275
423
|
// A remembered choice for this file type, if that opener is still available.
|
|
276
|
-
const remembered = rememberedOpenerId(
|
|
424
|
+
const remembered = rememberedOpenerId(r, this.node);
|
|
277
425
|
if (remembered && avail.some((o) => o.id === remembered)) return open(remembered);
|
|
278
426
|
|
|
279
427
|
// Several openers and no saved preference → let the user choose (with an option to
|
|
280
428
|
// remember). One or none → just open the best (or the download fallback).
|
|
281
429
|
if (avail.length > 1) {
|
|
282
|
-
return
|
|
430
|
+
return r.engine.dispatch(new ShowDialogAction({ kind: 'opener-chooser', node: this.node, openers: avail, reset: !!this.opts.reset }));
|
|
283
431
|
}
|
|
284
432
|
open(avail[0]?.id || 'core.fallback');
|
|
285
433
|
}
|
|
286
434
|
}
|
|
287
435
|
|
|
288
|
-
export class UploadFilesAction extends
|
|
436
|
+
export class UploadFilesAction extends Action {
|
|
437
|
+
static deps = ['api', 'engine', 'explorer', 'notifications', 'settings', 'transfers'];
|
|
438
|
+
|
|
289
439
|
constructor(files, collectionId) {
|
|
290
440
|
super();
|
|
291
441
|
this.files = files;
|
|
292
442
|
this.collectionId = collectionId;
|
|
293
443
|
}
|
|
294
|
-
async execute(
|
|
295
|
-
const collection = this.collectionId ||
|
|
444
|
+
async execute(r) {
|
|
445
|
+
const collection = this.collectionId || r.explorer.get().collectionId;
|
|
296
446
|
// Uploading with no collection open has nowhere to put the bytes. Refused visibly:
|
|
297
447
|
// the old fallback sent them to whatever 'default' happened to be.
|
|
298
448
|
if (!collection) {
|
|
299
|
-
|
|
449
|
+
r.notifications.error('Open a collection before uploading');
|
|
300
450
|
return;
|
|
301
451
|
}
|
|
302
|
-
const concurrency =
|
|
303
|
-
const uploads = [...this.files].map((file) => this.#one(
|
|
452
|
+
const concurrency = r.settings.get('uploads.concurrency');
|
|
453
|
+
const uploads = [...this.files].map((file) => this.#one(r, file, collection, concurrency));
|
|
304
454
|
await Promise.allSettled(uploads);
|
|
305
|
-
|
|
455
|
+
r.engine.dispatch(new NavigateAction(collection));
|
|
306
456
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
457
|
+
/**
|
|
458
|
+
* One file, once — and again, on the same tray row, if the user asks.
|
|
459
|
+
*
|
|
460
|
+
* `existingTid` is what makes a manual retry a second attempt at the row already on
|
|
461
|
+
* screen rather than a new entry beside it. The retry closes over the File, which is why
|
|
462
|
+
* it can run at all without asking the user to find the file again; it is also why the
|
|
463
|
+
* offer does not survive a reload, since nothing here is persisted.
|
|
464
|
+
*/
|
|
465
|
+
async #one(r, file, collection, concurrency, existingTid = null) {
|
|
466
|
+
const { api, notifications, transfers } = r;
|
|
467
|
+
const tid = existingTid || newId('xfer');
|
|
310
468
|
const controller = new AbortController();
|
|
311
|
-
|
|
469
|
+
if (existingTid) {
|
|
470
|
+
transfers.restart(tid, controller);
|
|
471
|
+
} else {
|
|
472
|
+
transfers.start(tid, file.name, file.size, controller, {
|
|
473
|
+
retry: () => this.#one(r, file, collection, concurrency, tid),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
312
476
|
let uploadId = null;
|
|
313
477
|
try {
|
|
314
|
-
const node = await
|
|
478
|
+
const node = await api.upload(file, {
|
|
315
479
|
collection, concurrency, signal: controller.signal,
|
|
316
480
|
onStart: (id) => { uploadId = id; },
|
|
317
481
|
onProgress: (p) => transfers.progress(tid, p),
|
|
@@ -320,16 +484,18 @@ export class UploadFilesAction extends AppAction {
|
|
|
320
484
|
// The server disambiguates a same-name collision rather than overwriting — tell
|
|
321
485
|
// the user when the saved name differs from what they dropped.
|
|
322
486
|
if (node?.name && node.name !== file.name) {
|
|
323
|
-
|
|
487
|
+
notifications.info(`"${file.name}" already existed — saved as "${node.name}".`);
|
|
324
488
|
}
|
|
325
489
|
} catch (err) {
|
|
326
490
|
// Release the server-side session so a cancelled/failed multipart upload doesn't
|
|
327
491
|
// leak an open multipart object (best-effort; the server also sweeps stale ones).
|
|
328
|
-
if (uploadId)
|
|
492
|
+
if (uploadId) api.abortUpload(uploadId).catch(() => {});
|
|
329
493
|
if (err.code === 'aborted') transfers.finish(tid, 'cancelled');
|
|
330
494
|
else {
|
|
331
495
|
transfers.finish(tid, 'error', err.message);
|
|
332
|
-
|
|
496
|
+
// The toast says what happened; the tray row is where it can be acted on, and
|
|
497
|
+
// pointing at it is the difference between a dead end and an offer.
|
|
498
|
+
notifications.error(`Upload failed: ${file.name} — ${err.message}. Retry it from the transfers tray.`);
|
|
333
499
|
}
|
|
334
500
|
}
|
|
335
501
|
}
|
|
@@ -337,26 +503,28 @@ export class UploadFilesAction extends AppAction {
|
|
|
337
503
|
|
|
338
504
|
/** Drive-wide tag/property filter (`#tag`, `#key:op:value`), optionally narrowed
|
|
339
505
|
* by free text. Falls back to filtering the loaded folder when offline. */
|
|
340
|
-
export class FilterAction extends
|
|
506
|
+
export class FilterAction extends Action {
|
|
507
|
+
static deps = ['api', 'explorer', 'offline', 'search'];
|
|
508
|
+
|
|
341
509
|
constructor(filters, text) {
|
|
342
510
|
super();
|
|
343
511
|
this.filters = filters || [];
|
|
344
512
|
this.text = text || '';
|
|
345
513
|
}
|
|
346
|
-
async execute(
|
|
347
|
-
const {
|
|
514
|
+
async execute(r) {
|
|
515
|
+
const { api, search } = r;
|
|
348
516
|
if (!this.filters.length) {
|
|
349
517
|
search.set({ results: [], ran: false, filtered: false });
|
|
350
518
|
return;
|
|
351
519
|
}
|
|
352
520
|
search.set({ query: this.text, loading: true, error: null, filtered: true });
|
|
353
|
-
if (
|
|
354
|
-
const items = (
|
|
521
|
+
if (r.offline && !r.offline.state.online) {
|
|
522
|
+
const items = (r.explorer.get().items || []).filter((n) => matchesTagFilters(n, this.filters));
|
|
355
523
|
search.set({ results: items.map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: true });
|
|
356
524
|
return;
|
|
357
525
|
}
|
|
358
526
|
try {
|
|
359
|
-
const res = await
|
|
527
|
+
const res = await api.tagSearch(this.filters, this.text.trim() || undefined, { limit: 100 });
|
|
360
528
|
search.set({ results: (res.items || []).map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: false });
|
|
361
529
|
} catch (err) {
|
|
362
530
|
search.set({ loading: false, error: err.message, ran: true, filtered: true });
|
|
@@ -366,13 +534,20 @@ export class FilterAction extends AppAction {
|
|
|
366
534
|
|
|
367
535
|
/** Command-palette quick-open: a keyword file search whose results live in the search
|
|
368
536
|
* service (state.se.paletteFiles) instead of ad-hoc state hung off the UI. */
|
|
369
|
-
export class QuickOpenAction extends
|
|
537
|
+
export class QuickOpenAction extends Action {
|
|
538
|
+
static deps = ['api', 'overlay', 'search'];
|
|
539
|
+
|
|
370
540
|
constructor(query) {
|
|
371
541
|
super();
|
|
372
542
|
this.query = query;
|
|
373
543
|
}
|
|
374
|
-
async execute(
|
|
375
|
-
const {
|
|
544
|
+
async execute(r) {
|
|
545
|
+
const { api, overlay, search } = r;
|
|
546
|
+
// The palette's mode as it is NOW, not as it was when the keystroke's timer was set.
|
|
547
|
+
// This guard used to live in the debounce callback in the component, which had to read
|
|
548
|
+
// it back off the service for exactly this reason — a listener belongs to the render
|
|
549
|
+
// that created it, and the mode can change under a pending timer.
|
|
550
|
+
if (overlay.get().palette?.mode !== 'files') return;
|
|
376
551
|
const q = (this.query || '').trim();
|
|
377
552
|
if (!q) { search.set({ paletteFiles: [], paletteQuery: '', paletteError: null, paletteLoading: false }); return; }
|
|
378
553
|
// Keystrokes outrun the network: a slower request for an earlier query must not
|
|
@@ -380,11 +555,11 @@ export class QuickOpenAction extends AppAction {
|
|
|
380
555
|
// whatever the palette input holds now is the only answer worth showing.
|
|
381
556
|
search.set({ paletteQuery: q, paletteLoading: true, paletteError: null });
|
|
382
557
|
try {
|
|
383
|
-
const res = await
|
|
384
|
-
if (search.
|
|
558
|
+
const res = await api.search(q, { mode: 'keyword', limit: 30 });
|
|
559
|
+
if (search.get().paletteQuery !== q) return; // superseded
|
|
385
560
|
search.set({ paletteFiles: res.results || [], paletteLoading: false });
|
|
386
561
|
} catch (err) {
|
|
387
|
-
if (search.
|
|
562
|
+
if (search.get().paletteQuery !== q) return;
|
|
388
563
|
// A failed search must not look like "no files matched" — that reads as a fact
|
|
389
564
|
// about the drive when it's actually a fact about the request.
|
|
390
565
|
search.set({ paletteFiles: [], paletteLoading: false, paletteError: err?.message || 'Search failed' });
|
|
@@ -392,22 +567,24 @@ export class QuickOpenAction extends AppAction {
|
|
|
392
567
|
}
|
|
393
568
|
}
|
|
394
569
|
|
|
395
|
-
export class SearchAction extends
|
|
570
|
+
export class SearchAction extends Action {
|
|
571
|
+
static deps = ['api', 'contributions', 'offline', 'search', 'settings'];
|
|
572
|
+
|
|
396
573
|
constructor(query, mode) {
|
|
397
574
|
super();
|
|
398
575
|
this.query = query;
|
|
399
576
|
this.mode = mode;
|
|
400
577
|
}
|
|
401
|
-
async execute(
|
|
402
|
-
const { search,
|
|
578
|
+
async execute(r) {
|
|
579
|
+
const { api, contributions, search, settings } = r;
|
|
403
580
|
const q = this.query.trim();
|
|
404
581
|
if (!q) {
|
|
405
582
|
search.set({ query: '', results: [], ran: false, error: null, resolved: null });
|
|
406
583
|
return;
|
|
407
584
|
}
|
|
408
|
-
const mode = this.mode ||
|
|
585
|
+
const mode = this.mode || settings.get('search.mode');
|
|
409
586
|
search.set({ query: this.query, mode, loading: true, error: null, resolved: null });
|
|
410
|
-
const offline =
|
|
587
|
+
const offline = r.offline;
|
|
411
588
|
// Offline (or server unreachable) → search the pinned corpus locally.
|
|
412
589
|
if (offline && !offline.state.online) {
|
|
413
590
|
try {
|
|
@@ -426,8 +603,8 @@ export class SearchAction extends AppAction {
|
|
|
426
603
|
// the stack that read the sentence: "photos from the trip last summer" asks for a
|
|
427
604
|
// gallery, and by the time the results are back all anyone can do is guess from
|
|
428
605
|
// content types. It can only name a view from this list.
|
|
429
|
-
const views =
|
|
430
|
-
const res = await
|
|
606
|
+
const views = contributions.ofType('view').map((v) => ({ id: v.id, title: v.title || v.id }));
|
|
607
|
+
const res = await api.query(q, { mode, limit: 40, views });
|
|
431
608
|
search.set({ results: res.results, resolved: res.resolved || null, loading: false, ran: true, offline: false });
|
|
432
609
|
} catch (err) {
|
|
433
610
|
if (offline) {
|
|
@@ -449,14 +626,16 @@ export class SearchAction extends AppAction {
|
|
|
449
626
|
* opening Settings gets a 403 that is the correct answer, not an error worth a toast.
|
|
450
627
|
* The section simply does not render.
|
|
451
628
|
*/
|
|
452
|
-
export class LoadApiKeysAction extends
|
|
453
|
-
|
|
454
|
-
|
|
629
|
+
export class LoadApiKeysAction extends Action {
|
|
630
|
+
static deps = ['api', 'apiKeys'];
|
|
631
|
+
|
|
632
|
+
async execute(r) {
|
|
633
|
+
r.apiKeys.set({ loading: true, error: null });
|
|
455
634
|
try {
|
|
456
|
-
const res = await
|
|
457
|
-
|
|
635
|
+
const res = await r.api.apiKeys();
|
|
636
|
+
r.apiKeys.set({ keys: res.keys || [], loading: false, loaded: true, error: null });
|
|
458
637
|
} catch (err) {
|
|
459
|
-
|
|
638
|
+
r.apiKeys.set({ loading: false, loaded: true, error: err?.message || 'Could not load API keys' });
|
|
460
639
|
}
|
|
461
640
|
}
|
|
462
641
|
}
|
|
@@ -468,42 +647,1196 @@ export class LoadApiKeysAction extends AppAction {
|
|
|
468
647
|
* put into state before anything else can fail. A refresh of the list afterwards is a
|
|
469
648
|
* convenience; losing the secret because the refresh threw would not be.
|
|
470
649
|
*/
|
|
471
|
-
export class MintApiKeyAction extends
|
|
650
|
+
export class MintApiKeyAction extends Action {
|
|
651
|
+
static deps = ['api', 'apiKeys', 'notifications'];
|
|
652
|
+
|
|
472
653
|
constructor(spec) {
|
|
473
654
|
super();
|
|
474
655
|
this.spec = spec;
|
|
475
656
|
}
|
|
476
|
-
async execute(
|
|
477
|
-
|
|
657
|
+
async execute(r) {
|
|
658
|
+
r.apiKeys.set({ busy: 'mint', error: null });
|
|
478
659
|
try {
|
|
479
|
-
const { key, secret } = await
|
|
480
|
-
|
|
481
|
-
const res = await
|
|
482
|
-
if (res)
|
|
660
|
+
const { key, secret } = await r.api.mintApiKey(this.spec);
|
|
661
|
+
r.apiKeys.set({ minted: { key, secret }, busy: null });
|
|
662
|
+
const res = await r.api.apiKeys().catch(() => null);
|
|
663
|
+
if (res) r.apiKeys.set({ keys: res.keys || [] });
|
|
483
664
|
return key;
|
|
484
665
|
} catch (err) {
|
|
485
|
-
|
|
486
|
-
|
|
666
|
+
r.apiKeys.set({ busy: null, error: err?.message || 'Could not create the key' });
|
|
667
|
+
r.notifications.error(err?.message || 'Could not create the key');
|
|
487
668
|
return null;
|
|
488
669
|
}
|
|
489
670
|
}
|
|
490
671
|
}
|
|
491
672
|
|
|
492
|
-
export class RevokeApiKeyAction extends
|
|
673
|
+
export class RevokeApiKeyAction extends Action {
|
|
674
|
+
static deps = ['api', 'apiKeys', 'notifications'];
|
|
675
|
+
|
|
676
|
+
constructor(id) {
|
|
677
|
+
super();
|
|
678
|
+
this.id = id;
|
|
679
|
+
}
|
|
680
|
+
async execute(r) {
|
|
681
|
+
r.apiKeys.set({ busy: this.id, error: null });
|
|
682
|
+
try {
|
|
683
|
+
await r.api.revokeApiKey(this.id);
|
|
684
|
+
const res = await r.api.apiKeys().catch(() => null);
|
|
685
|
+
r.apiKeys.set({ busy: null, ...(res ? { keys: res.keys || [] } : {}) });
|
|
686
|
+
r.notifications.success('Key revoked');
|
|
687
|
+
} catch (err) {
|
|
688
|
+
r.apiKeys.set({ busy: null });
|
|
689
|
+
r.notifications.error(err?.message || 'Could not revoke the key');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Rebind a keyboard shortcut, or clear it with `key: null`.
|
|
697
|
+
*
|
|
698
|
+
* Carries the binding ID rather than the binding, because a view emits descriptions and an
|
|
699
|
+
* action carries an id — the component that dispatches this has never held the binding
|
|
700
|
+
* object, only a row describing it.
|
|
701
|
+
*/
|
|
702
|
+
export class RebindKeyAction extends Action {
|
|
703
|
+
static deps = ['keybindings'];
|
|
704
|
+
constructor(bindingId, key) {
|
|
705
|
+
super();
|
|
706
|
+
this.bindingId = bindingId;
|
|
707
|
+
this.key = key;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async execute({ keybindings: kb }) {
|
|
711
|
+
// `rebind` resolves a binding object or a command id, not a binding id, so look the
|
|
712
|
+
// row back up. Resolving here rather than widening the service keeps the id the only
|
|
713
|
+
// thing that crosses the boundary.
|
|
714
|
+
const binding = kb.resolved().find((b) => b.bindingId === this.bindingId);
|
|
715
|
+
if (!binding) return;
|
|
716
|
+
kb.rebind(binding, this.key);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Copy text to the clipboard, and say so.
|
|
722
|
+
*
|
|
723
|
+
* Four components had their own version of this, each written slightly differently — two
|
|
724
|
+
* with `.then/.catch`, one with `await` in a `try`, one optional-chaining the clipboard and
|
|
725
|
+
* one not. All four ended the same way, and the ending is the part that matters: a copy that
|
|
726
|
+
* FAILS must still put the text where it can be selected by hand, or the user is left with a
|
|
727
|
+
* button that did nothing and no way to get at what it was for. That is what the sticky
|
|
728
|
+
* fallback is, and it is exactly the sort of detail that rots when it lives in four places.
|
|
729
|
+
*
|
|
730
|
+
* Clipboard access is denied outside a user gesture and in an insecure context, so the
|
|
731
|
+
* failure path is ordinary rather than exceptional.
|
|
732
|
+
*/
|
|
733
|
+
export class CopyTextAction extends Action {
|
|
734
|
+
static deps = ['notifications'];
|
|
735
|
+
/**
|
|
736
|
+
* @param {string} text
|
|
737
|
+
* @param {string} [label] what the toast says on success
|
|
738
|
+
*/
|
|
739
|
+
constructor(text, label = 'Copied') {
|
|
740
|
+
super();
|
|
741
|
+
this.text = text;
|
|
742
|
+
this.label = label;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async execute({ notifications: notes }) {
|
|
746
|
+
try {
|
|
747
|
+
await navigator.clipboard.writeText(this.text);
|
|
748
|
+
notes.success(this.label);
|
|
749
|
+
} catch {
|
|
750
|
+
notes.info(this.text, { sticky: true });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Say something to the user. `kind` is one of info, success, warn, error. */
|
|
756
|
+
export class NotifyAction extends Action {
|
|
757
|
+
static deps = ['notifications'];
|
|
758
|
+
constructor(kind, message, opts) {
|
|
759
|
+
super();
|
|
760
|
+
this.kind = kind;
|
|
761
|
+
this.message = message;
|
|
762
|
+
this.opts = opts;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async execute({ notifications: notes }) {
|
|
766
|
+
(notes[this.kind] ?? notes.info).call(notes, this.message, this.opts);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Dismiss one notification, by id. */
|
|
771
|
+
export class DismissNotificationAction extends Action {
|
|
772
|
+
static deps = ['notifications'];
|
|
493
773
|
constructor(id) {
|
|
494
774
|
super();
|
|
495
775
|
this.id = id;
|
|
496
776
|
}
|
|
497
|
-
|
|
498
|
-
|
|
777
|
+
|
|
778
|
+
async execute({ notifications }) {
|
|
779
|
+
notifications.dismiss(this.id);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// --- the shell's own surface ----------------------------------------------------
|
|
784
|
+
//
|
|
785
|
+
// Opening and closing the workbench's overlays. These were direct calls on
|
|
786
|
+
// `platform.workbench`, which meant the engine could not see a person close a dialog or
|
|
787
|
+
// move through the palette — the same hole `ExecCommandAction` closed for commands.
|
|
788
|
+
//
|
|
789
|
+
// One class per operation rather than a single ShellAction carrying a method name. The
|
|
790
|
+
// point of routing these through the engine is that the feed says what happened; a
|
|
791
|
+
// discriminated blob would make every one of them read as "ShellAction" and give back
|
|
792
|
+
// exactly the observability this is for.
|
|
793
|
+
//
|
|
794
|
+
// NOT here yet, deliberately: `showDialog` and `showContextMenu`. Both take callbacks —
|
|
795
|
+
// a dialog carries `onConfirm`, a menu carries an item's `run` — so converting them
|
|
796
|
+
// mechanically would post a closure through the engine and call it an action. They want
|
|
797
|
+
// the outcome to be an ACTION TO DISPATCH rather than a function to call, which is a real
|
|
798
|
+
// change at each call site rather than a swap. See docs/tickets/009.
|
|
799
|
+
|
|
800
|
+
class ShellAction extends Action {
|
|
801
|
+
static deps = ['workbench'];
|
|
802
|
+
async execute({ workbench }) {
|
|
803
|
+
this.apply(workbench);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The overlays: palette, dialog, context menu, plugin panel.
|
|
809
|
+
*
|
|
810
|
+
* Their own lease. Closing a dialog used to go through the workbench facade and therefore
|
|
811
|
+
* leased the entire shell — the panel stack, the launcher cursor and the sheet included —
|
|
812
|
+
* to set one field to null.
|
|
813
|
+
*/
|
|
814
|
+
class OverlayAction extends Action {
|
|
815
|
+
static deps = ['overlay'];
|
|
816
|
+
async execute({ overlay }) {
|
|
817
|
+
this.apply(overlay);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The panel stack. Still a service: it mirrors into browser history and persists recents. */
|
|
822
|
+
class NavAction extends Action {
|
|
823
|
+
static deps = ['navigation'];
|
|
824
|
+
async execute({ navigation }) {
|
|
825
|
+
this.apply(navigation);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export class CloseDialogAction extends OverlayAction {
|
|
830
|
+
apply(o) { o.set({ dialog: null }); }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Merge a patch into the open dialog — a reactive dialog, like the opener chooser. */
|
|
834
|
+
export class UpdateDialogAction extends OverlayAction {
|
|
835
|
+
constructor(patch) { super(); this.patch = patch; }
|
|
836
|
+
apply(o) {
|
|
837
|
+
const dialog = o.get().dialog;
|
|
838
|
+
if (dialog) o.set({ dialog: { ...dialog, ...this.patch } });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export class CloseContextMenuAction extends OverlayAction {
|
|
843
|
+
apply(o) { o.set({ contextMenu: null }); }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export class ClosePaletteAction extends OverlayAction {
|
|
847
|
+
apply(o) { o.set({ palette: null }); }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export class SetPaletteQueryAction extends OverlayAction {
|
|
851
|
+
constructor(query) { super(); this.query = query; }
|
|
852
|
+
apply(o) {
|
|
853
|
+
const palette = o.get().palette;
|
|
854
|
+
// Back to the top: the previous highlight belonged to the previous result set.
|
|
855
|
+
if (palette) o.set({ palette: { ...palette, query: this.query, index: 0 } });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export class SetPaletteIndexAction extends OverlayAction {
|
|
860
|
+
constructor(index) { super(); this.index = index; }
|
|
861
|
+
apply(o) {
|
|
862
|
+
const palette = o.get().palette;
|
|
863
|
+
if (palette) o.set({ palette: { ...palette, index: this.index } });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Move the palette's highlight, wrapping at either end.
|
|
869
|
+
*
|
|
870
|
+
* The wrapping is HERE now. It used to be in the service with this action forwarding two
|
|
871
|
+
* arguments to it — and it forwarded one, so the service could not tell where the end was,
|
|
872
|
+
* returned early, and the arrow keys did nothing at all. Deciding and writing in one place
|
|
873
|
+
* is what removes the chance.
|
|
874
|
+
*/
|
|
875
|
+
export class MovePaletteAction extends OverlayAction {
|
|
876
|
+
constructor(delta, count) { super(); this.delta = delta; this.count = count; }
|
|
877
|
+
apply(o) {
|
|
878
|
+
const palette = o.get().palette;
|
|
879
|
+
if (!palette || !this.count) return;
|
|
880
|
+
o.set({ palette: { ...palette, index: wrapIndex(palette.index, this.delta, this.count) } });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The double-shift overlay: a search that starts empty and resets the stack on a pick. */
|
|
885
|
+
export class OpenSearchModalAction extends ShellAction {
|
|
886
|
+
apply(wb) { wb.set({ searchModal: true, launch: { query: '', index: 0 } }); }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export class CloseSearchModalAction extends ShellAction {
|
|
890
|
+
apply(wb) { wb.set({ searchModal: false }); }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export class SetLaunchQueryAction extends ShellAction {
|
|
894
|
+
constructor(query) { super(); this.query = query; }
|
|
895
|
+
// Back to the top, for the same reason the palette does: the old highlight belonged to
|
|
896
|
+
// the old results.
|
|
897
|
+
apply(wb) { wb.set({ launch: { query: this.query, index: 0 } }); }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export class SetLaunchIndexAction extends ShellAction {
|
|
901
|
+
constructor(index) { super(); this.index = index; }
|
|
902
|
+
apply(wb) { wb.set({ launch: { ...wb.get().launch, index: this.index } }); }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Raise, swap, or drop the phone bottom sheet. Tapping the open one closes it. */
|
|
906
|
+
export class OpenSheetAction extends ShellAction {
|
|
907
|
+
constructor(which) { super(); this.which = which; }
|
|
908
|
+
apply(wb) { wb.set({ sheet: wb.get().sheet === this.which ? null : this.which }); }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export class CloseSheetAction extends ShellAction {
|
|
912
|
+
apply(wb) { wb.set({ sheet: null }); }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export class ToggleInfoPanelAction extends ShellAction {
|
|
916
|
+
constructor(open) { super(); this.open = open; }
|
|
917
|
+
apply(wb) { wb.set({ infoPanel: this.open ?? !wb.get().infoPanel }); }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export class OpenPluginPanelAction extends OverlayAction {
|
|
921
|
+
constructor(pluginId) { super(); this.pluginId = pluginId; }
|
|
922
|
+
apply(o) { o.set({ pluginPanel: this.pluginId }); }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export class ClosePluginPanelAction extends OverlayAction {
|
|
926
|
+
apply(o) { o.set({ pluginPanel: null }); }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Open a node in a panel, with a chosen opener.
|
|
931
|
+
*
|
|
932
|
+
* Three things at once, and they belong together: the panel goes on the stack, the shell
|
|
933
|
+
* switches to the drive, and the modal search — if that is where the pick came from —
|
|
934
|
+
* closes behind it. The workbench service used to do the coordinating because the stack
|
|
935
|
+
* lives in one place and the activity in another; an action is a better home for "these
|
|
936
|
+
* happen together" than a facade over both.
|
|
937
|
+
*/
|
|
938
|
+
export class OpenInPanelAction extends Action {
|
|
939
|
+
static deps = ['navigation', 'workbench'];
|
|
940
|
+
constructor(node, openerId, opts = {}) {
|
|
941
|
+
super();
|
|
942
|
+
this.node = node;
|
|
943
|
+
this.openerId = openerId;
|
|
944
|
+
this.opts = opts;
|
|
945
|
+
}
|
|
946
|
+
async execute({ navigation, workbench }) {
|
|
947
|
+
workbench.set({ activity: 'home', searchModal: false });
|
|
948
|
+
navigation.openFile(this.node, this.openerId, this.opts);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// --- the drive's services --------------------------------------------------------
|
|
953
|
+
//
|
|
954
|
+
// Cancelling a transfer, retrying an issue, tagging a file. These were direct method calls
|
|
955
|
+
// on the service, so the engine saw an upload being cancelled only as the state change that
|
|
956
|
+
// followed, with nothing to say a person had asked for it.
|
|
957
|
+
//
|
|
958
|
+
// Named per operation for the same reason the shell actions are: the feed is the point.
|
|
959
|
+
|
|
960
|
+
class ActivityAction extends Action {
|
|
961
|
+
static deps = ['activity'];
|
|
962
|
+
async execute(r) { this.apply(r.activity); }
|
|
963
|
+
}
|
|
964
|
+
export class ToggleActivityPanelAction extends ActivityAction {
|
|
965
|
+
constructor(which) { super(); this.which = which; }
|
|
966
|
+
apply(s) { s.togglePanel(this.which); }
|
|
967
|
+
}
|
|
968
|
+
export class CancelTaskAction extends ActivityAction {
|
|
969
|
+
constructor(id) { super(); this.id = id; }
|
|
970
|
+
apply(s) { s.cancel(this.id); }
|
|
971
|
+
}
|
|
972
|
+
export class DismissTaskAction extends ActivityAction {
|
|
973
|
+
constructor(id) { super(); this.id = id; }
|
|
974
|
+
apply(s) { s.dismiss(this.id); }
|
|
975
|
+
}
|
|
976
|
+
export class RetryIssueAction extends ActivityAction {
|
|
977
|
+
constructor(id) { super(); this.id = id; }
|
|
978
|
+
apply(s) { s.retryIssue(this.id); }
|
|
979
|
+
}
|
|
980
|
+
export class DismissIssueAction extends ActivityAction {
|
|
981
|
+
constructor(id) { super(); this.id = id; }
|
|
982
|
+
apply(s) { s.dismissIssue(this.id); }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
class TransfersAction extends Action {
|
|
986
|
+
static deps = ['transfers'];
|
|
987
|
+
async execute(r) { this.apply(r.transfers); }
|
|
988
|
+
}
|
|
989
|
+
export class CancelTransferAction extends TransfersAction {
|
|
990
|
+
constructor(id) { super(); this.id = id; }
|
|
991
|
+
apply(s) { s.cancel(this.id); }
|
|
992
|
+
}
|
|
993
|
+
export class RetryTransferAction extends TransfersAction {
|
|
994
|
+
constructor(id) { super(); this.id = id; }
|
|
995
|
+
apply(s) { s.retry(this.id); }
|
|
996
|
+
}
|
|
997
|
+
export class DismissTransferAction extends TransfersAction {
|
|
998
|
+
constructor(id) { super(); this.id = id; }
|
|
999
|
+
apply(s) { s.dismiss(this.id); }
|
|
1000
|
+
}
|
|
1001
|
+
export class ClearFinishedTransfersAction extends TransfersAction {
|
|
1002
|
+
apply(s) { s.clearDone(); }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
class SocialAction extends Action {
|
|
1006
|
+
static deps = ['social'];
|
|
1007
|
+
// Awaited: several of these are network calls, and a dispatch feed that settles before
|
|
1008
|
+
// the work does reports success for something still in flight.
|
|
1009
|
+
async execute(r) { return this.apply(r.social); }
|
|
1010
|
+
}
|
|
1011
|
+
export class ToggleInboxAction extends SocialAction {
|
|
1012
|
+
constructor(open) { super(); this.open = open; }
|
|
1013
|
+
apply(s) { s.toggleInbox(this.open); }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Ask the browser for push permission and register. */
|
|
1017
|
+
export class EnablePushAction extends SocialAction {
|
|
1018
|
+
apply(s) { return s.enablePush(); }
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Post a comment on the open item. */
|
|
1022
|
+
export class PostCommentAction extends SocialAction {
|
|
1023
|
+
constructor(body) { super(); this.body = body; }
|
|
1024
|
+
apply(s) { return s.comment(this.body); }
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export class DeleteCommentAction extends SocialAction {
|
|
1028
|
+
constructor(commentId) { super(); this.commentId = commentId; }
|
|
1029
|
+
apply(s) { return s.deleteComment(this.commentId); }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
export class ReactToCommentAction extends SocialAction {
|
|
1033
|
+
constructor(commentId, emoji) { super(); this.commentId = commentId; this.emoji = emoji; }
|
|
1034
|
+
apply(s) { return s.react(this.commentId, this.emoji); }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** Aim the comment box at a comment, or clear it with `null`. */
|
|
1038
|
+
export class SetReplyToAction extends SocialAction {
|
|
1039
|
+
constructor(target) { super(); this.target = target; }
|
|
1040
|
+
apply(s) { s.setReplyTo(this.target); }
|
|
1041
|
+
}
|
|
1042
|
+
export class AddTagAction extends SocialAction {
|
|
1043
|
+
constructor(name, value) { super(); this.name = name; this.value = value; }
|
|
1044
|
+
apply(s) { s.addTag(this.name, this.value); }
|
|
1045
|
+
}
|
|
1046
|
+
export class RemoveTagAction extends SocialAction {
|
|
1047
|
+
constructor(name) { super(); this.name = name; }
|
|
1048
|
+
apply(s) { s.removeTag(this.name); }
|
|
1049
|
+
}
|
|
1050
|
+
export class LoadSidecarAction extends SocialAction {
|
|
1051
|
+
constructor(nodeId) { super(); this.nodeId = nodeId; }
|
|
1052
|
+
apply(s) { s.loadSidecar(this.nodeId); }
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
class ApiKeysAction extends Action {
|
|
1056
|
+
static deps = ['apiKeys'];
|
|
1057
|
+
async execute(r) { this.apply(r.apiKeys); }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Change one field of the mint form. Does nothing when there is no form open. */
|
|
1061
|
+
export class PatchApiKeyDraftAction extends ApiKeysAction {
|
|
1062
|
+
constructor(patch) { super(); this.patch = patch; }
|
|
1063
|
+
apply(s) {
|
|
1064
|
+
const draft = s.get().draft;
|
|
1065
|
+
if (draft) s.set({ draft: { ...draft, ...this.patch } });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Toggle one capability on one collection in the draft.
|
|
1071
|
+
*
|
|
1072
|
+
* Per COLLECTION and capability: a key is scoped, so a capability without the collection
|
|
1073
|
+
* it applies to is meaningless. This used to be arithmetic inside the service with an
|
|
1074
|
+
* action in front of it forwarding two arguments — and it forwarded one, so the collection
|
|
1075
|
+
* id arrived as the capability and the server refused every mint. Deciding and writing are
|
|
1076
|
+
* one step now, with nothing in between to drop.
|
|
1077
|
+
*
|
|
1078
|
+
* `admin` is not treated specially. It is offered as itself and the server expands it;
|
|
1079
|
+
* pre-ticking read/write/delete alongside it would suggest they are separable afterwards,
|
|
1080
|
+
* and they are not.
|
|
1081
|
+
*/
|
|
1082
|
+
export class ToggleApiKeyCapAction extends ApiKeysAction {
|
|
1083
|
+
constructor(collectionId, capability) { super(); this.collectionId = collectionId; this.capability = capability; }
|
|
1084
|
+
apply(s) {
|
|
1085
|
+
const draft = s.get().draft;
|
|
1086
|
+
if (!draft) return;
|
|
1087
|
+
const caps = { ...draft.caps };
|
|
1088
|
+
const held = new Set(caps[this.collectionId] || []);
|
|
1089
|
+
if (held.has(this.capability)) held.delete(this.capability);
|
|
1090
|
+
else held.add(this.capability);
|
|
1091
|
+
if (held.size) caps[this.collectionId] = [...held];
|
|
1092
|
+
else delete caps[this.collectionId];
|
|
1093
|
+
s.set({ draft: { ...draft, caps } });
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Select items in the explorer.
|
|
1099
|
+
*
|
|
1100
|
+
* `opts` is `{ additive, nodes }`, matching the service — NOT a boolean. It was written as
|
|
1101
|
+
* `additive` first, which worked at the one call site only because the options object passed
|
|
1102
|
+
* through positionally unchanged. `new SelectItemsAction(ids, true)` would have destructured
|
|
1103
|
+
* a boolean into `{additive = false, nodes = null}` and quietly not been additive at all.
|
|
1104
|
+
*
|
|
1105
|
+
* `nodes` matters when the selection cannot be resolved from the loaded page: the launcher's
|
|
1106
|
+
* rows come from search, which is scoped across collections, so the node has to travel with
|
|
1107
|
+
* the id.
|
|
1108
|
+
*/
|
|
1109
|
+
export class SelectItemsAction extends Action {
|
|
1110
|
+
static deps = ['explorer'];
|
|
1111
|
+
|
|
1112
|
+
constructor(ids, opts = {}) { super(); this.ids = ids; this.opts = opts; }
|
|
1113
|
+
|
|
1114
|
+
async execute({ explorer }) {
|
|
1115
|
+
const { additive = false, nodes = null } = this.opts;
|
|
1116
|
+
const current = explorer.get().selection;
|
|
1117
|
+
const next = additive ? Array.from(new Set([...current, ...this.ids])) : this.ids;
|
|
1118
|
+
// Selecting what is already selected must not emit. The launcher syncs the highlighted
|
|
1119
|
+
// row into here on every mouseenter, and a state push per mouse move would re-render
|
|
1120
|
+
// the list under the pointer. The rule lives with the mutation now rather than inside
|
|
1121
|
+
// the resource, so the resource only holds and only `set` writes.
|
|
1122
|
+
const same = next.length === current.length && next.every((id, i) => id === current[i]);
|
|
1123
|
+
if (same) return;
|
|
1124
|
+
explorer.set({ selection: next, selectionNodes: nodes && !additive ? nodes : null });
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Move the launcher's highlight and sync the selection to whatever it landed on.
|
|
1130
|
+
*
|
|
1131
|
+
* ONE action because the two halves are not separable. Moving computes a new index — with
|
|
1132
|
+
* wrapping, so it is not `index + delta` — and the selection has to follow the row that
|
|
1133
|
+
* index now names. Written as two steps in the component it read the index back out of the
|
|
1134
|
+
* store immediately after writing it, which only worked because a direct method call is
|
|
1135
|
+
* synchronous. Dispatching is not: the highlight would move and the selection would sync to
|
|
1136
|
+
* the previously highlighted row, one keystroke behind, forever, without anything throwing.
|
|
1137
|
+
*
|
|
1138
|
+
* Here the read-back is inside the action, where it IS sequenced. `nodes` is the results in
|
|
1139
|
+
* display order — the component knows the running order, the store only knows the index.
|
|
1140
|
+
*/
|
|
1141
|
+
export class MoveLaunchAction extends Action {
|
|
1142
|
+
static deps = ['explorer', 'workbench'];
|
|
1143
|
+
constructor(delta, nodes) { super(); this.delta = delta; this.nodes = nodes; }
|
|
1144
|
+
async execute({ workbench, explorer }) {
|
|
1145
|
+
const count = this.nodes.length;
|
|
1146
|
+
if (!count) return;
|
|
1147
|
+
const index = wrapIndex(workbench.get().launch.index, this.delta, count);
|
|
1148
|
+
workbench.set({ launch: { ...workbench.get().launch, index } });
|
|
1149
|
+
selectNode(explorer, this.nodes[index]);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Highlight a launcher row by position and select it — the pointer/Enter counterpart. */
|
|
1154
|
+
export class SelectLaunchAction extends Action {
|
|
1155
|
+
static deps = ['explorer', 'workbench'];
|
|
1156
|
+
constructor(index, node) { super(); this.index = index; this.node = node; }
|
|
1157
|
+
async execute({ workbench, explorer }) {
|
|
1158
|
+
workbench.set({ launch: { ...workbench.get().launch, index: this.index } });
|
|
1159
|
+
selectNode(explorer, this.node);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Select exactly one node, or nothing. Carries the node itself — see SelectItemsAction. */
|
|
1164
|
+
function selectNode(explorer, node) {
|
|
1165
|
+
const ids = node?.id ? [node.id] : [];
|
|
1166
|
+
const current = explorer.get().selection;
|
|
1167
|
+
if (ids.length === current.length && ids.every((id, i) => id === current[i])) return;
|
|
1168
|
+
explorer.set({ selection: ids, selectionNodes: node ? [node] : null });
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Write one slice of view state — a draft, a capture, a set of ticked boxes.
|
|
1173
|
+
*
|
|
1174
|
+
* Components used to call the store directly, and two of them did it DURING their own
|
|
1175
|
+
* render to install a default. The default is derived now (see `draftFor`), so this only
|
|
1176
|
+
* ever runs because a person did something.
|
|
1177
|
+
*/
|
|
1178
|
+
export class SetViewStateAction extends Action {
|
|
1179
|
+
static deps = ['viewState'];
|
|
1180
|
+
constructor(key, value) { super(); this.key = key; this.value = value; }
|
|
1181
|
+
async execute({ viewState }) { viewState.set({ [this.key]: this.value }); }
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Show a context menu at a point.
|
|
1187
|
+
*
|
|
1188
|
+
* Only possible now that items carry `actions` rather than `run` closures — this used to be
|
|
1189
|
+
* a direct call precisely because dispatching a menu would have meant putting functions on
|
|
1190
|
+
* the feed. See ui/activate.js.
|
|
1191
|
+
*/
|
|
1192
|
+
export class ShowContextMenuAction extends OverlayAction {
|
|
1193
|
+
constructor(x, y, items) { super(); this.x = x; this.y = y; this.items = items; }
|
|
1194
|
+
apply(o) { o.set({ contextMenu: { x: this.x, y: this.y, items: this.items } }); }
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Show a dialog.
|
|
1199
|
+
*
|
|
1200
|
+
* The spec is data. A confirm carries `confirmActions`; so does a prompt, whose actions read
|
|
1201
|
+
* what was typed out of view state rather than being handed it — see PromptAction. Nothing
|
|
1202
|
+
* in a dialog spec is callable, which matters because the spec lives in workbench state.
|
|
1203
|
+
*/
|
|
1204
|
+
export class ShowDialogAction extends OverlayAction {
|
|
1205
|
+
constructor(dialog) { super(); this.dialog = dialog; }
|
|
1206
|
+
apply(o) { o.set({ dialog: this.dialog }); }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Change one setting. */
|
|
1210
|
+
export class SetSettingAction extends Action {
|
|
1211
|
+
static deps = ['settings'];
|
|
1212
|
+
constructor(key, value) { super(); this.key = key; this.value = value; }
|
|
1213
|
+
async execute({ settings }) { settings.set(this.key, this.value); }
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Remember which opener a file type should use, or forget it with a null openerId.
|
|
1218
|
+
*
|
|
1219
|
+
* Not `SetSettingAction` with a pre-merged map, because building that map means reading the
|
|
1220
|
+
* current one — and a component that reads settings to compute what to write is the same
|
|
1221
|
+
* stale-base race as the drafts had (see PatchDraftAction). The merge happens here, against
|
|
1222
|
+
* what is stored.
|
|
1223
|
+
*/
|
|
1224
|
+
export class RememberOpenerAction extends Action {
|
|
1225
|
+
static deps = ['settings'];
|
|
1226
|
+
constructor(typeKey, openerId) { super(); this.typeKey = typeKey; this.openerId = openerId; }
|
|
1227
|
+
async execute({ settings }) {
|
|
1228
|
+
if (!this.typeKey) return;
|
|
1229
|
+
settings.set(ASSOC_KEY, withAssociation(settings.get(ASSOC_KEY), this.typeKey, this.openerId));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Back through the panel stack. */
|
|
1234
|
+
export class NavigateBackAction extends NavAction {
|
|
1235
|
+
apply(nav) { nav.back(); }
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Close the viewer stack and return to the drive. */
|
|
1239
|
+
export class ShowHomeAction extends Action {
|
|
1240
|
+
static deps = ['navigation', 'workbench'];
|
|
1241
|
+
async execute({ navigation, workbench }) {
|
|
1242
|
+
workbench.set({ activity: 'home', searchModal: false });
|
|
1243
|
+
navigation.reset();
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Reload one plugin — re-read its manifest and restart its worker. */
|
|
1248
|
+
export class RefreshPluginAction extends Action {
|
|
1249
|
+
static deps = ['plugins'];
|
|
1250
|
+
constructor(pluginId) { super(); this.pluginId = pluginId; }
|
|
1251
|
+
async execute({ plugins }) { return plugins.refresh(this.pluginId); }
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Store a plugin secret.
|
|
1256
|
+
*
|
|
1257
|
+
* Not a setting: secrets go somewhere settings do not, which is why this is its own action
|
|
1258
|
+
* rather than SetSettingAction with a different key.
|
|
1259
|
+
*/
|
|
1260
|
+
export class SetPluginSecretAction extends Action {
|
|
1261
|
+
static deps = ['plugins'];
|
|
1262
|
+
constructor(pluginId, key, value) { super(); this.pluginId = pluginId; this.key = key; this.value = value; }
|
|
1263
|
+
async execute({ plugins }) { return plugins.setSecret(this.pluginId, this.key, this.value); }
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Open whatever a notification points at.
|
|
1268
|
+
*
|
|
1269
|
+
* The stat, the open, the panel and the failure message are one action because they are one
|
|
1270
|
+
* intent. Spread across a component they were a `.then` chain that had to know the API
|
|
1271
|
+
* answers `{ node }` rather than a node, and had to remember that a notification can
|
|
1272
|
+
* outlive its target — the item may be deleted, or live somewhere the reader has since
|
|
1273
|
+
* lost access to. Either way the click must say so rather than do nothing.
|
|
1274
|
+
*/
|
|
1275
|
+
export class OpenNotificationTargetAction extends Action {
|
|
1276
|
+
static deps = ['api', 'context', 'contributions', 'engine', 'explorer', 'notifications', 'plugins', 'settings', 'workbench'];
|
|
1277
|
+
|
|
1278
|
+
constructor(nodeId) { super(); this.nodeId = nodeId; }
|
|
1279
|
+
|
|
1280
|
+
async execute(r) {
|
|
1281
|
+
let node;
|
|
1282
|
+
try {
|
|
1283
|
+
({ node } = await r.api.stat(this.nodeId));
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
r.notifications.warn(err?.status === 403 || err?.code === 'forbidden'
|
|
1286
|
+
? 'You no longer have access to that item.'
|
|
1287
|
+
: 'That item no longer exists.');
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
await new OpenFileAction(node).execute(r);
|
|
1291
|
+
r.workbench.set({ infoPanel: true });
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* The base for anything a prompt dialog submits.
|
|
1297
|
+
*
|
|
1298
|
+
* The typed value is engine state (see viewState.js), so the action READS it rather than
|
|
1299
|
+
* being handed it by a callback. Subclasses implement `withValue`; the dialog closes first,
|
|
1300
|
+
* because every one of these used to do that by hand and two forgot to do it consistently.
|
|
1301
|
+
*/
|
|
1302
|
+
export class PromptAction extends Action {
|
|
1303
|
+
static deps = ['overlay', 'viewState'];
|
|
1304
|
+
|
|
1305
|
+
async execute(r) {
|
|
1306
|
+
const held = r.viewState.get()[PROMPT];
|
|
1307
|
+
const value = (held?.value ?? '').trim();
|
|
1308
|
+
r.overlay.set({ dialog: null });
|
|
1309
|
+
if (value) await this.withValue(value, r);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** Rename the node this prompt was opened for. */
|
|
1314
|
+
export class RenamePromptAction extends PromptAction {
|
|
1315
|
+
static deps = ['engine', 'overlay', 'viewState'];
|
|
1316
|
+
constructor(node) { super(); this.node = node; }
|
|
1317
|
+
async withValue(name, { engine }) {
|
|
1318
|
+
if (name !== this.node.name) await engine.dispatch(new RenameAction(this.node.id, name));
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Fetch and review a plugin package from the URL that was typed. */
|
|
1323
|
+
export class InstallPluginFromUrlPromptAction extends PromptAction {
|
|
1324
|
+
static deps = ['notifications', 'overlay', 'plugins', 'social', 'viewState', 'workbench'];
|
|
1325
|
+
async withValue(url, r) { await beginInstallFromUrl(r, url); }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Create the collection the dialog's form describes.
|
|
1330
|
+
*
|
|
1331
|
+
* The form is already engine state — the dialog puts it in viewState as it is typed — so
|
|
1332
|
+
* this reads it rather than being handed a record through a callback. The form-to-record
|
|
1333
|
+
* shaping stays in the dialog, which is the thing that knows which fields the chosen
|
|
1334
|
+
* driver declared.
|
|
1335
|
+
*/
|
|
1336
|
+
export class CreateCollectionFromFormAction extends Action {
|
|
1337
|
+
static deps = ['engine', 'overlay'];
|
|
1338
|
+
constructor(record) { super(); this.record = record; }
|
|
1339
|
+
async execute({ engine, overlay }) {
|
|
1340
|
+
overlay.set({ dialog: null });
|
|
1341
|
+
if (this.record) await engine.dispatch(new CreateCollectionAction(this.record));
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Change one field of a draft, merging against what is stored RATHER than against what the
|
|
1347
|
+
* render saw.
|
|
1348
|
+
*
|
|
1349
|
+
* Every field handler used to build `{ ...form, [k]: value }` from the `form` its own render
|
|
1350
|
+
* closed over. Two changes to different fields before the next render therefore both merged
|
|
1351
|
+
* onto the same stale base, and the second silently dropped the first — type a name, switch
|
|
1352
|
+
* the storage driver in the same frame, and the name is gone with the Create button quietly
|
|
1353
|
+
* disabled. Renders are coalesced by rAF, so "in the same frame" is not rare, and a
|
|
1354
|
+
* backgrounded tab makes it certain.
|
|
1355
|
+
*
|
|
1356
|
+
* Reading current state inside the action removes the window entirely.
|
|
1357
|
+
*
|
|
1358
|
+
* @param {string} key which draft
|
|
1359
|
+
* @param {object} ref the dialog instance it belongs to
|
|
1360
|
+
* @param {object} patch the field(s) being changed
|
|
1361
|
+
* @param {object} fallback the draft's default, for when the held one is another dialog's
|
|
1362
|
+
*/
|
|
1363
|
+
export class PatchDraftAction extends Action {
|
|
1364
|
+
static deps = ['viewState'];
|
|
1365
|
+
|
|
1366
|
+
constructor(key, ref, patch, fallback = {}) {
|
|
1367
|
+
super();
|
|
1368
|
+
this.key = key;
|
|
1369
|
+
this.ref = ref;
|
|
1370
|
+
this.patch = patch;
|
|
1371
|
+
this.fallback = fallback;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async execute({ viewState }) {
|
|
1375
|
+
const held = viewState.get()[this.key];
|
|
1376
|
+
const base = held && held.ref === this.ref ? held.form : this.fallback;
|
|
1377
|
+
viewState.set({ [this.key]: { ref: this.ref, form: { ...base, ...this.patch } } });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// --- what commands used to do inline ---------------------------------------------
|
|
1382
|
+
//
|
|
1383
|
+
// bl/commands.js registered 40 commands and 36 of them were CLOSURES over `app` — the whole
|
|
1384
|
+
// world — doing effects outside the engine. That mattered more than the line count suggests,
|
|
1385
|
+
// because commands are the entry point for everything a person actually does: the palette,
|
|
1386
|
+
// every keybinding, every menu item, every row button. `ExecCommandAction` routed the intent
|
|
1387
|
+
// in and the handler walked straight back out, so the engine saw a command being run and
|
|
1388
|
+
// then nothing of what it did.
|
|
1389
|
+
//
|
|
1390
|
+
// A command is a DESCRIPTION now — `{ id, title, actions(...args) }`, where `actions` is a
|
|
1391
|
+
// pure factory. Everything below is where those handler bodies went.
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* The file a command acts on: the one it was handed, else the selection, else what is open.
|
|
1395
|
+
*
|
|
1396
|
+
* Three commands resolved this identically and a fourth got it slightly wrong. "Nothing is
|
|
1397
|
+
* selected" is a real answer and has to be said out loud — returning silently is
|
|
1398
|
+
* indistinguishable from a broken command.
|
|
1399
|
+
*/
|
|
1400
|
+
function subjectOf({ explorer, navigation, notifications }, node = null) {
|
|
1401
|
+
const found = node || selectedNodesOf(explorer.get())[0] || navigation.activeTab()?.node;
|
|
1402
|
+
if (!found) notifications.info('Pick a file first — highlight one in the list, or open it.');
|
|
1403
|
+
return found;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Open the command palette, or quick-open, depending on the mode. */
|
|
1407
|
+
export class OpenPaletteAction extends OverlayAction {
|
|
1408
|
+
constructor(mode = 'commands', query = '') { super(); this.mode = mode; this.query = query; }
|
|
1409
|
+
apply(o) { o.set({ palette: { mode: this.mode, query: this.query, index: 0 } }); }
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Switch the main area between the drive, plugins and settings. */
|
|
1413
|
+
export class SetActivityAction extends ShellAction {
|
|
1414
|
+
constructor(activity) { super(); this.activity = activity; }
|
|
1415
|
+
apply(wb) { wb.set({ activity: this.activity, sidebarVisible: true }); }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Close the topmost transient thing — what Escape does.
|
|
1420
|
+
*
|
|
1421
|
+
* The ORDER is the whole content of this action, and it spans four resources: the menu
|
|
1422
|
+
* over the dialog, the dialog over the phone sheet, the sheet over the modal search, and
|
|
1423
|
+
* only when none of those is up does Escape start popping the panel stack. Getting it wrong
|
|
1424
|
+
* is not a crash — it is Escape closing the wrong thing, which is why it is written out
|
|
1425
|
+
* once, here, rather than distributed over whoever owns each overlay.
|
|
1426
|
+
*/
|
|
1427
|
+
export class CloseOverlaysAction extends Action {
|
|
1428
|
+
static deps = ['activity', 'navigation', 'overlay', 'workbench'];
|
|
1429
|
+
async execute({ activity, navigation, overlay, workbench }) {
|
|
1430
|
+
const o = overlay.get();
|
|
1431
|
+
const wb = workbench.get();
|
|
1432
|
+
if (o.contextMenu) return overlay.set({ contextMenu: null });
|
|
1433
|
+
if (o.dialog) return overlay.set({ dialog: null });
|
|
1434
|
+
if (wb.sheet) return workbench.set({ sheet: null });
|
|
1435
|
+
if (wb.searchModal) return workbench.set({ searchModal: false });
|
|
1436
|
+
if (o.palette) return overlay.set({ palette: null });
|
|
1437
|
+
if (o.pluginPanel) return overlay.set({ pluginPanel: null });
|
|
1438
|
+
// The activity panel floats over the page and carries a close button like everything
|
|
1439
|
+
// else in this ladder, and was the one such surface Escape did not reach. Below the
|
|
1440
|
+
// plugin panel deliberately: it opens BY ITSELF when a storage check finishes, and
|
|
1441
|
+
// Escape should not close what you opened on purpose in order to dismiss what
|
|
1442
|
+
// appeared on its own.
|
|
1443
|
+
if (activity.state?.open) return activity.togglePanel(false);
|
|
1444
|
+
// Nothing floating: Escape pops the top viewer panel instead.
|
|
1445
|
+
if (navigation.state.stack.length > 1) navigation.back();
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Speak to search.
|
|
1451
|
+
*
|
|
1452
|
+
* On a TV this is mostly NOT about recognising anything: a remote's mic dictates into
|
|
1453
|
+
* whatever text field the platform keyboard is attached to, and is swallowed by the system
|
|
1454
|
+
* assistant when there isn't one. So the first job is to put the search field on screen and
|
|
1455
|
+
* focused, which is a feature on every TV whether or not the browser can transcribe. Where
|
|
1456
|
+
* it can — on-device only, see platform/voice.js — it also starts listening and types what
|
|
1457
|
+
* it hears.
|
|
1458
|
+
*/
|
|
1459
|
+
export class VoiceSearchAction extends Action {
|
|
1460
|
+
static deps = ['engine', 'voice'];
|
|
1461
|
+
async execute({ engine, voice }) {
|
|
1462
|
+
await voice.run({
|
|
1463
|
+
onText: (text, { final }) => {
|
|
1464
|
+
if (!text) return;
|
|
1465
|
+
engine.dispatch(new SetLaunchQueryAction(text));
|
|
1466
|
+
// Interim results keep the box in step with the speaker; only the settled
|
|
1467
|
+
// transcript is worth a round trip to the server.
|
|
1468
|
+
if (final) engine.dispatch(new SearchAction(text));
|
|
1469
|
+
},
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** Reindex the whole drive. Reports as a task rather than blocking — it takes minutes. */
|
|
1475
|
+
export class RebuildIndexAction extends Action {
|
|
1476
|
+
static deps = ['activity'];
|
|
1477
|
+
async execute({ activity }) { return activity.rebuildIndex().catch(() => {}); }
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Pick up files added, replaced or removed in the bucket by something other than Trove.
|
|
1482
|
+
*
|
|
1483
|
+
* No collection means no scan. This used to fall back to one called 'default', which on a
|
|
1484
|
+
* drive that has none scanned a collection that does not exist and reported the failure as
|
|
1485
|
+
* a scan error.
|
|
1486
|
+
*/
|
|
1487
|
+
export class ScanCollectionAction extends Action {
|
|
1488
|
+
static deps = ['activity', 'explorer', 'notifications'];
|
|
1489
|
+
async execute({ activity, explorer, notifications }) {
|
|
1490
|
+
const id = explorer.get().collectionId;
|
|
1491
|
+
if (!id) return notifications.info('Open a collection first — a scan is per collection.');
|
|
1492
|
+
return activity.scanCollection(id).catch(() => {});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Whether the backing stores are reachable from a browser at all.
|
|
1498
|
+
*
|
|
1499
|
+
* Separate from a scan: a scan asks what the store HOLDS, this asks whether it can be READ
|
|
1500
|
+
* from here — the failure that makes every file open to a spinner.
|
|
1501
|
+
*/
|
|
1502
|
+
export class CheckStorageAction extends Action {
|
|
1503
|
+
static deps = ['activity'];
|
|
1504
|
+
async execute({ activity }) { return activity.checkStorage().catch(() => {}); }
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Ask for files, then upload them into the open collection. */
|
|
1508
|
+
export class PickAndUploadAction extends Action {
|
|
1509
|
+
static deps = ['engine', 'explorer'];
|
|
1510
|
+
async execute({ engine, explorer }) {
|
|
1511
|
+
const collection = explorer.get().collectionId;
|
|
1512
|
+
pickFiles((files) => files.length && engine.dispatch(new UploadFilesAction(files, collection)));
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Copy a link to the subject, in one of its two spellings.
|
|
1518
|
+
*
|
|
1519
|
+
* `trove:` is what one document writes to link another; it means nothing to a browser. A
|
|
1520
|
+
* share link is a URL you can paste into a message. Both end at CopyTextAction, which is
|
|
1521
|
+
* the thing that knows a failed copy must still leave the text somewhere selectable.
|
|
1522
|
+
*/
|
|
1523
|
+
export class CopyLinkAction extends Action {
|
|
1524
|
+
static deps = ['engine', 'explorer', 'navigation', 'notifications'];
|
|
1525
|
+
/** @param {'trove'|'share'} kind */
|
|
1526
|
+
constructor(kind = 'trove', node = null) { super(); this.kind = kind; this.node = node; }
|
|
1527
|
+
async execute(r) {
|
|
1528
|
+
const node = subjectOf(r, this.node);
|
|
1529
|
+
if (!node) return;
|
|
1530
|
+
return r.engine.dispatch(this.kind === 'share'
|
|
1531
|
+
? new CopyTextAction(shareUrl(node, location.origin),
|
|
1532
|
+
'Link copied — anyone with access to this collection can open it')
|
|
1533
|
+
: new CopyTextAction(troveUri(node), `Copied ${troveUri(node)}`));
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** Ask for a new name for the subject. The prompt's value is read by RenamePromptAction. */
|
|
1538
|
+
export class RenameSubjectAction extends Action {
|
|
1539
|
+
static deps = ['engine', 'explorer', 'navigation', 'notifications'];
|
|
1540
|
+
constructor(node = null) { super(); this.node = node; }
|
|
1541
|
+
async execute(r) {
|
|
1542
|
+
const node = subjectOf(r, this.node);
|
|
1543
|
+
if (!node) return;
|
|
1544
|
+
r.engine.dispatch(new ShowDialogAction({
|
|
1545
|
+
kind: 'prompt', title: 'Rename', label: 'New name', value: node.name, confirmLabel: 'Rename',
|
|
1546
|
+
confirmActions: [new RenamePromptAction(node)],
|
|
1547
|
+
}));
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Move the selection to the trash, confirming first unless that was turned off.
|
|
1553
|
+
*
|
|
1554
|
+
* Deleting the file you have OPEN is the obvious intent when nothing is selected, so it
|
|
1555
|
+
* counts as the subject. The confirmation says what actually happens: telling someone a
|
|
1556
|
+
* file will be "permanently deleted" when it goes to the trash trains them to fear a safe
|
|
1557
|
+
* action, and the reverse — promising recovery that doesn't exist — is worse.
|
|
1558
|
+
*/
|
|
1559
|
+
export class DeleteSubjectAction extends Action {
|
|
1560
|
+
static deps = ['engine', 'explorer', 'navigation', 'notifications', 'settings'];
|
|
1561
|
+
async execute(r) {
|
|
1562
|
+
const nodes = selectedNodesOf(r.explorer.get());
|
|
1563
|
+
const open = nodes.length ? null : r.navigation.activeTab()?.node;
|
|
1564
|
+
if (open) nodes.push(open);
|
|
1565
|
+
if (!nodes.length) {
|
|
1566
|
+
r.notifications.info('Pick a file first — highlight one in the list, or open it.');
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const deleteThem = new DeleteAction(nodes.map((n) => n.id));
|
|
1570
|
+
if (!r.settings.get('explorer.confirmDelete')) return r.engine.dispatch(deleteThem);
|
|
1571
|
+
r.engine.dispatch(new ShowDialogAction({
|
|
1572
|
+
kind: 'confirm',
|
|
1573
|
+
title: `Move ${nodes.length} item${nodes.length > 1 ? 's' : ''} to the trash?`,
|
|
1574
|
+
body: nodes.length === 1
|
|
1575
|
+
? `"${nodes[0].name}" leaves the drive but is kept, and can be restored from the trash.`
|
|
1576
|
+
: 'They leave the drive but are kept, and can be restored from the trash.',
|
|
1577
|
+
confirmLabel: 'Move to trash',
|
|
1578
|
+
confirmActions: [deleteThem],
|
|
1579
|
+
}));
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/** Show the trash, and get back to the drive to see it. */
|
|
1584
|
+
export class ShowTrashAction extends Action {
|
|
1585
|
+
static deps = ['engine'];
|
|
1586
|
+
async execute({ engine }) {
|
|
1587
|
+
engine.dispatch(new TrashAction('list'));
|
|
1588
|
+
engine.dispatch(new ShowHomeAction());
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** Open the subject in a viewer. */
|
|
1593
|
+
export class OpenSubjectAction extends Action {
|
|
1594
|
+
static deps = ['engine', 'explorer', 'navigation', 'notifications'];
|
|
1595
|
+
constructor(node = null) { super(); this.node = node; }
|
|
1596
|
+
async execute(r) {
|
|
1597
|
+
const node = subjectOf(r, this.node);
|
|
1598
|
+
if (node) return r.engine.dispatch(new OpenFileAction(node));
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** Save the subject to the machine. */
|
|
1603
|
+
export class DownloadSubjectAction extends Action {
|
|
1604
|
+
static deps = ['api', 'explorer', 'navigation', 'notifications'];
|
|
1605
|
+
constructor(node = null) { super(); this.node = node; }
|
|
1606
|
+
async execute(r) {
|
|
1607
|
+
const target = subjectOf(r, this.node);
|
|
1608
|
+
if (!target?.id) return;
|
|
1609
|
+
try {
|
|
1610
|
+
const { url, revoke } = await r.api.download(target.id, target.name);
|
|
1611
|
+
triggerDownload(url, target.name);
|
|
1612
|
+
// A blob URL pins the bytes until it is released; the click has already happened by
|
|
1613
|
+
// the time this runs.
|
|
1614
|
+
if (revoke) setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
r.notifications.error(`Couldn't download ${target.name}: ${err.message}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** Keep a copy of the subject for offline use, or stop keeping one. */
|
|
1622
|
+
export class PinAction extends Action {
|
|
1623
|
+
static deps = ['explorer', 'navigation', 'notifications', 'offline'];
|
|
1624
|
+
constructor(node = null, pinned = true) { super(); this.node = node; this.pinned = pinned; }
|
|
1625
|
+
async execute(r) {
|
|
1626
|
+
const target = subjectOf(r, this.node);
|
|
1627
|
+
if (!target?.id) return;
|
|
1628
|
+
return this.pinned ? r.offline.pin(target) : r.offline.unpin(target.id);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// --- API keys (admin) ------------------------------------------------------------
|
|
1633
|
+
|
|
1634
|
+
class ApiKeyDraftAction extends Action {
|
|
1635
|
+
static deps = ['apiKeys'];
|
|
1636
|
+
async execute({ apiKeys }) { this.apply(apiKeys); }
|
|
1637
|
+
}
|
|
1638
|
+
export class StartApiKeyDraftAction extends ApiKeyDraftAction {
|
|
1639
|
+
apply(s) { s.set({ draft: { name: '', expiresInDays: '', caps: {} }, error: null }); }
|
|
1640
|
+
}
|
|
1641
|
+
export class CancelApiKeyDraftAction extends ApiKeyDraftAction {
|
|
1642
|
+
apply(s) { s.set({ draft: null }); }
|
|
1643
|
+
}
|
|
1644
|
+
/** Forget the freshly minted secret. On dismiss, and after a copy. */
|
|
1645
|
+
export class ClearMintedApiKeyAction extends ApiKeyDraftAction {
|
|
1646
|
+
apply(s) { if (s.get().minted) s.set({ minted: null }); }
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Mint the key the draft describes.
|
|
1651
|
+
*
|
|
1652
|
+
* The days-to-instant conversion is here rather than in the form because the server compares
|
|
1653
|
+
* against its own clock: "30 days from whenever this arrives" is not what was chosen. The
|
|
1654
|
+
* draft is cleared BEFORE the mint so the form cannot be submitted twice while it is in
|
|
1655
|
+
* flight.
|
|
1656
|
+
*/
|
|
1657
|
+
export class MintApiKeyFromDraftAction extends Action {
|
|
1658
|
+
static deps = ['apiKeys', 'engine'];
|
|
1659
|
+
async execute({ apiKeys, engine }) {
|
|
1660
|
+
const draft = apiKeys.get().draft;
|
|
1661
|
+
const scopes = draftScopesOf(apiKeys.get());
|
|
1662
|
+
if (!draft?.name?.trim() || !scopes) return;
|
|
1663
|
+
const days = Number(draft.expiresInDays);
|
|
1664
|
+
const expiresAt = draft.expiresInDays !== '' && Number.isFinite(days) && days > 0
|
|
1665
|
+
? Date.now() + days * 86400_000
|
|
1666
|
+
: null;
|
|
1667
|
+
apiKeys.set({ draft: null });
|
|
1668
|
+
return engine.dispatch(new MintApiKeyAction({ name: draft.name.trim(), scopes, expiresAt }));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// --- collections -----------------------------------------------------------------
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Switch to a collection, or offer the choice when none was named.
|
|
1676
|
+
*
|
|
1677
|
+
* From the palette or a keybinding there is no pointer to anchor a menu to, so it opens
|
|
1678
|
+
* centred near the top.
|
|
1679
|
+
*/
|
|
1680
|
+
export class SwitchCollectionAction extends Action {
|
|
1681
|
+
static deps = ['engine', 'explorer', 'notifications'];
|
|
1682
|
+
constructor(collectionId = null) { super(); this.collectionId = collectionId; }
|
|
1683
|
+
async execute({ engine, explorer, notifications }) {
|
|
1684
|
+
if (this.collectionId) {
|
|
1685
|
+
engine.dispatch(new NavigateAction(this.collectionId));
|
|
1686
|
+
engine.dispatch(new ShowHomeAction());
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const items = collectionMenuOf(explorer.get(),
|
|
1690
|
+
(id) => new ExecCommandAction('collections.switch', id),
|
|
1691
|
+
() => new ExecCommandAction('collections.create'));
|
|
1692
|
+
if (!items.length) return notifications.info('This drive has one collection.');
|
|
1693
|
+
const w = typeof window === 'undefined' ? 800 : window.innerWidth;
|
|
1694
|
+
engine.dispatch(new ShowContextMenuAction(Math.max(12, Math.round(w / 2) - 110), 120, items));
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Show the details panel — but only when there is a file for it to be about.
|
|
1700
|
+
*
|
|
1701
|
+
* With nothing open there is nothing to show, and flipping the flag silently was
|
|
1702
|
+
* indistinguishable from a broken command.
|
|
1703
|
+
*/
|
|
1704
|
+
export class ToggleDetailsAction extends Action {
|
|
1705
|
+
static deps = ['navigation', 'notifications', 'workbench'];
|
|
1706
|
+
async execute({ navigation, notifications, workbench }) {
|
|
1707
|
+
if (!navigation.activeTab()) {
|
|
1708
|
+
notifications.info('Open a file to see its details and conversation.');
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
workbench.set({ infoPanel: !workbench.get().infoPanel });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// --- plugins ---------------------------------------------------------------------
|
|
1716
|
+
|
|
1717
|
+
/** Ask for a .zip and take it through the install review. */
|
|
1718
|
+
export class PickAndInstallPluginAction extends Action {
|
|
1719
|
+
static deps = ['notifications', 'plugins', 'social', 'workbench'];
|
|
1720
|
+
async execute(r) {
|
|
1721
|
+
pickZip((file) => file && beginInstallFromFile(r, file));
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Run a command a PLUGIN registered.
|
|
1727
|
+
*
|
|
1728
|
+
* Plugin commands used to be handlers proxying straight over RPC, which left the same hole
|
|
1729
|
+
* for them that `ExecCommandAction` closed for the host's own: the engine could see a plugin
|
|
1730
|
+
* command being invoked only as whatever state changed afterwards. Now every command in the
|
|
1731
|
+
* registry — host or plugin — resolves to actions.
|
|
1732
|
+
*/
|
|
1733
|
+
export class InvokePluginCommandAction extends Action {
|
|
1734
|
+
static deps = ['plugins'];
|
|
1735
|
+
constructor(pluginId, name, args = []) {
|
|
1736
|
+
super();
|
|
1737
|
+
this.pluginId = pluginId;
|
|
1738
|
+
this.name = name;
|
|
1739
|
+
this.args = args;
|
|
1740
|
+
}
|
|
1741
|
+
async execute({ plugins }) {
|
|
1742
|
+
return plugins.invokeCommand(this.pluginId, this.name, this.args);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Forget the open item's conversation — but only if it is still the one named.
|
|
1748
|
+
*
|
|
1749
|
+
* Dispatched when the last observer of a `sidecarFor` query goes away. Scoped to the node
|
|
1750
|
+
* because kill and boot are not ordered against each other: switching from A to B can kill
|
|
1751
|
+
* A's query after B's has booted, and an unscoped clear would wipe what B just loaded.
|
|
1752
|
+
*/
|
|
1753
|
+
export class ClearSidecarAction extends Action {
|
|
1754
|
+
static deps = ['social'];
|
|
1755
|
+
constructor(nodeId) { super(); this.nodeId = nodeId; }
|
|
1756
|
+
async execute({ social }) {
|
|
1757
|
+
if (social.state.sidecar?.nodeId === this.nodeId) social.loadSidecar(null);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// --- key rotation ---------------------------------------------------------------
|
|
1762
|
+
//
|
|
1763
|
+
// Rotation moves a collection onto a fresh key, object by object, bounded by wall-clock
|
|
1764
|
+
// time per slice. It runs on the server; what the UI needs is to see where it is, what it
|
|
1765
|
+
// would cost before anyone starts, and a way to stop it.
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Read where the rotation stands, and what one would cost.
|
|
1769
|
+
*
|
|
1770
|
+
* The estimate comes with the state rather than only before starting: on a metered store
|
|
1771
|
+
* this is a real bill, and the number is worth having in front of the button.
|
|
1772
|
+
*/
|
|
1773
|
+
export class LoadRotationAction extends Action {
|
|
1774
|
+
static deps = ['api', 'explorer', 'notifications', 'rotation'];
|
|
1775
|
+
async execute({ api, explorer, notifications, rotation }) {
|
|
1776
|
+
const collectionId = explorer.get().collectionId;
|
|
1777
|
+
if (!collectionId) return;
|
|
1778
|
+
rotation.set({ collectionId, loading: true, error: null });
|
|
1779
|
+
try {
|
|
1780
|
+
// Both together: the estimate is meaningless without knowing whether one is already
|
|
1781
|
+
// running, and a screen showing one without the other invites starting a second.
|
|
1782
|
+
const [state, estimate] = await Promise.all([
|
|
1783
|
+
api.rotationState(collectionId),
|
|
1784
|
+
api.rotationEstimate(collectionId).catch(() => null),
|
|
1785
|
+
]);
|
|
1786
|
+
rotation.set({ rotation: state?.rotation ?? null, estimate: estimate ?? null, loading: false });
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
// A non-admin gets a 403 here and that is the correct answer, not an error worth a
|
|
1789
|
+
// toast — the section simply does not render.
|
|
1790
|
+
rotation.set({ loading: false, error: err?.message || 'Could not read the rotation state' });
|
|
1791
|
+
if (err?.status !== 403) notifications.error?.(err?.message || 'Could not read the rotation state');
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/** Start moving this collection onto a new key. */
|
|
1797
|
+
export class BeginRotationAction extends Action {
|
|
1798
|
+
static deps = ['api', 'engine', 'explorer', 'notifications', 'rotation'];
|
|
1799
|
+
async execute({ api, engine, explorer, notifications, rotation }) {
|
|
1800
|
+
const collectionId = explorer.get().collectionId;
|
|
1801
|
+
if (!collectionId) return;
|
|
1802
|
+
rotation.set({ busy: true });
|
|
1803
|
+
try {
|
|
1804
|
+
const res = await api.beginRotation(collectionId);
|
|
1805
|
+
rotation.set({ rotation: res?.rotation ?? null, busy: false });
|
|
1806
|
+
// The new key becomes current immediately, before any file moves — so the
|
|
1807
|
+
// fingerprint on screen is already out of date. Reload the collections or it keeps
|
|
1808
|
+
// showing the key the collection is moving OFF, which is the one thing this field
|
|
1809
|
+
// exists to get right.
|
|
1810
|
+
await engine.dispatch(new LoadCollectionsAction());
|
|
1811
|
+
notifications.info('Key rotation started. It runs in the background and survives a reload.');
|
|
1812
|
+
} catch (err) {
|
|
1813
|
+
rotation.set({ busy: false });
|
|
1814
|
+
notifications.error(err?.message || 'Could not start the rotation');
|
|
1815
|
+
}
|
|
1816
|
+
return engine.dispatch(new LoadRotationAction());
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* Stop one.
|
|
1822
|
+
*
|
|
1823
|
+
* What has already moved stays moved and the new key stays current, so this is untidy
|
|
1824
|
+
* rather than destructive — worth saying, because "cancel" on a job that has rewritten
|
|
1825
|
+
* half a collection sounds like it might undo something.
|
|
1826
|
+
*/
|
|
1827
|
+
export class CancelRotationAction extends Action {
|
|
1828
|
+
static deps = ['api', 'engine', 'explorer', 'notifications', 'rotation'];
|
|
1829
|
+
async execute({ api, engine, explorer, notifications, rotation }) {
|
|
1830
|
+
const collectionId = explorer.get().collectionId;
|
|
1831
|
+
if (!collectionId) return;
|
|
1832
|
+
rotation.set({ busy: true });
|
|
499
1833
|
try {
|
|
500
|
-
await
|
|
501
|
-
|
|
502
|
-
app.apiKeys.set({ busy: null, ...(res ? { keys: res.keys || [] } : {}) });
|
|
503
|
-
app.platform.notifications.success('Key revoked');
|
|
1834
|
+
await api.cancelRotation(collectionId);
|
|
1835
|
+
notifications.info('Rotation stopped. Files already moved stay on the new key.');
|
|
504
1836
|
} catch (err) {
|
|
505
|
-
|
|
506
|
-
app.platform.notifications.error(err?.message || 'Could not revoke the key');
|
|
1837
|
+
notifications.error(err?.message || 'Could not stop the rotation');
|
|
507
1838
|
}
|
|
1839
|
+
rotation.set({ busy: false });
|
|
1840
|
+
return engine.dispatch(new LoadRotationAction());
|
|
508
1841
|
}
|
|
509
1842
|
}
|