@3sln/trove 0.0.8 → 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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/encryption/envelope.js +46 -7
  3. package/packages/core/src/encryption/rotation.js +187 -68
  4. package/packages/core/src/uploads.js +130 -29
  5. package/packages/core/src/vfs.js +13 -1
  6. package/packages/server/src/adapters/staticAssets.js +14 -5
  7. package/packages/server/src/engine/index.js +1 -1
  8. package/packages/server/src/engine/providers/core.js +12 -0
  9. package/packages/server/src/index.js +45 -2
  10. package/packages/server/src/routes.js +59 -26
  11. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  12. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  13. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  14. package/packages/web/dist/index.html +2 -2
  15. package/packages/web/dist/sw.js +1 -1
  16. package/packages/web/src/bl/actions.js +1454 -137
  17. package/packages/web/src/bl/commands.js +103 -284
  18. package/packages/web/src/bl/context.js +69 -0
  19. package/packages/web/src/bl/index.js +136 -40
  20. package/packages/web/src/bl/intern.js +129 -0
  21. package/packages/web/src/bl/launcher.js +251 -0
  22. package/packages/web/src/bl/links.js +1 -1
  23. package/packages/web/src/bl/match.js +71 -0
  24. package/packages/web/src/bl/mentions.js +39 -0
  25. package/packages/web/src/bl/offline.js +0 -4
  26. package/packages/web/src/bl/openers.js +65 -33
  27. package/packages/web/src/bl/pluginInstall.js +42 -21
  28. package/packages/web/src/bl/queries.js +669 -0
  29. package/packages/web/src/bl/services.js +77 -141
  30. package/packages/web/src/bl/social.js +3 -1
  31. package/packages/web/src/bl/state.js +144 -0
  32. package/packages/web/src/bl/status.js +96 -0
  33. package/packages/web/src/bl/trust.js +109 -0
  34. package/packages/web/src/bl/viewState.js +60 -0
  35. package/packages/web/src/bl/views.js +64 -0
  36. package/packages/web/src/bl/watchQuery.js +73 -0
  37. package/packages/web/src/platform/api.js +31 -102
  38. package/packages/web/src/platform/commands.js +49 -9
  39. package/packages/web/src/platform/context.js +146 -51
  40. package/packages/web/src/platform/index.js +27 -20
  41. package/packages/web/src/platform/navigation.js +34 -4
  42. package/packages/web/src/platform/pickers.js +46 -0
  43. package/packages/web/src/platform/pluginHost.js +43 -10
  44. package/packages/web/src/platform/pluginRpc.js +6 -2
  45. package/packages/web/src/platform/spatialNav.js +0 -2
  46. package/packages/web/src/platform/viewport.js +1 -6
  47. package/packages/web/src/platform/voiceSearch.js +1 -3
  48. package/packages/web/src/platform/whenclause.js +22 -3
  49. package/packages/web/src/styles.css +74 -4
  50. package/packages/web/src/ui/activate.js +22 -0
  51. package/packages/web/src/ui/components/activityBar.js +7 -3
  52. package/packages/web/src/ui/components/activityPanel.js +10 -11
  53. package/packages/web/src/ui/components/adminView.js +191 -0
  54. package/packages/web/src/ui/components/collectionGate.js +4 -3
  55. package/packages/web/src/ui/components/commandPalette.js +24 -55
  56. package/packages/web/src/ui/components/editorArea.js +16 -13
  57. package/packages/web/src/ui/components/launcher.js +39 -225
  58. package/packages/web/src/ui/components/openers/index.js +7 -4
  59. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  60. package/packages/web/src/ui/components/overlays.js +158 -45
  61. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  62. package/packages/web/src/ui/components/pluginReview.js +62 -23
  63. package/packages/web/src/ui/components/pluginsView.js +30 -28
  64. package/packages/web/src/ui/components/settingsView.js +151 -58
  65. package/packages/web/src/ui/components/social.js +42 -62
  66. package/packages/web/src/ui/components/statusBar.js +30 -74
  67. package/packages/web/src/ui/components/views/grid.js +3 -2
  68. package/packages/web/src/ui/components/views/index.js +7 -62
  69. package/packages/web/src/ui/components/views/list.js +3 -2
  70. package/packages/web/src/ui/components/views/parts.js +15 -4
  71. package/packages/web/src/ui/compositions/workbench.js +166 -58
  72. package/packages/web/src/ui/region.js +53 -0
  73. package/packages/web/src/workbench.js +17 -15
  74. package/packages/web/dist/assets/main-y778bpte.js +0 -356
  75. package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
  76. package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
  77. package/packages/web/src/platform/overlay.js +0 -81
  78. 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
- class AppAction extends Action {
13
- static deps = ['app'];
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 AppAction {
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({ app }) {
27
- const { explorer, platform } = app;
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.state.collectionId;
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.state.collectionId;
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 = platform.settings.get('explorer.sort');
44
- const order = platform.settings.get('explorer.sortOrder');
45
- const res = await platform.api.list(collectionId, { sort, order });
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
- platform.settings.set?.('explorer.lastCollection', collectionId);
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
- platform.notifications.error(`Couldn't load this collection: ${err.message}`);
68
+ notifications.error(`Couldn't load this collection: ${err.message}`);
63
69
  }
64
70
  }
65
71
  }
66
72
 
67
- export class LoadCollectionsAction extends AppAction {
68
- async execute({ app }) {
73
+ export class LoadCollectionsAction extends Action {
74
+ static deps = ['api', 'explorer'];
75
+
76
+ async execute(r) {
69
77
  try {
70
- const res = await app.platform.api.collections();
78
+ const res = await r.api.collections();
71
79
  const collections = res.collections || [];
72
- app.explorer.set({ collections, canCreateCollection: !!res.canCreate });
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 AppAction {
88
- async execute({ app }) {
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 app.platform.api.collections();
114
+ const res = await r.api.collections();
96
115
  collections = res.collections || [];
97
116
  canCreate = !!res.canCreate;
98
- app.explorer.set({ collections, canCreateCollection: canCreate });
117
+ r.explorer.set({ collections, canCreateCollection: canCreate });
99
118
  } catch (err) {
100
- app.explorer.set({ loading: false, error: `Couldn't load your collections: ${err.message}` });
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 = app.platform.settings.get('explorer.lastCollection');
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
- app.explorer.set({
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
- app.explorer.set({ loading: false, items: [], gate: 'choose', error: null });
146
+ r.explorer.set({ loading: false, items: [], gate: 'choose', error: null });
128
147
  return;
129
148
  }
130
149
 
131
- app.explorer.set({ gate: null });
132
- return app.engine.dispatch(new NavigateAction(remembered));
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
+ });
185
+ return;
186
+ }
187
+
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 }));
133
213
  }
134
214
  }
135
215
 
136
- export class CreateCollectionAction extends AppAction {
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
+ }
264
+ }
265
+ }
266
+
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({ app }) {
274
+ async execute(r) {
142
275
  try {
143
- const res = await app.platform.api.createCollection(this.record);
144
- app.platform.notifications.success(`Created collection “${res.collection.name}”`);
145
- await app.engine.dispatch(new LoadCollectionsAction());
146
- app.engine.dispatch(new NavigateAction(res.collection.id));
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
- app.platform.notifications.error(`Couldn’t create collection: ${err.message}`);
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 AppAction {
155
- async execute({ app }) {
156
- const { explorer, platform } = app;
157
- const cursor = explorer.state.nextCursor;
158
- if (!cursor || explorer.state.loadingMore) return;
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 platform.api.list(explorer.state.collectionId, {
162
- sort: explorer.state.sort, order: explorer.state.order, cursor,
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.state.items, ...res.items],
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
- platform.notifications.error(`Couldn't load more: ${err.message}`);
306
+ notifications.error(`Couldn't load more: ${err.message}`);
172
307
  }
173
308
  }
174
309
  }
175
310
 
176
- export class RefreshAction extends AppAction {
177
- async execute({ app }) {
178
- return app.engine.dispatch(new NavigateAction(app.explorer.state.collectionId));
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 AppAction {
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({ app }) {
190
- const { explorer, platform } = app;
191
- const collection = explorer.state.collectionId;
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,111 +337,122 @@ export class TrashAction extends AppAction {
198
337
  }
199
338
  try {
200
339
  if (this.op === 'restore') {
201
- const { node } = await platform.api.restore(this.id);
202
- platform.notifications.success(`Restored “${node.name}”`);
340
+ const { node } = await api.restore(this.id);
341
+ notifications.success(`Restored “${node.name}”`);
203
342
  } else if (this.op === 'purge') {
204
- await platform.api.purgeTrash({ id: this.id });
343
+ await api.purgeTrash({ id: this.id });
205
344
  } else if (this.op === 'empty') {
206
- const { purged } = await platform.api.purgeTrash({ collection });
207
- platform.notifications.success(`Deleted ${purged} item${purged === 1 ? '' : 's'} for good`);
345
+ const { purged } = await api.purgeTrash({ collection });
346
+ notifications.success(`Deleted ${purged} item${purged === 1 ? '' : 's'} for good`);
208
347
  }
209
- const { items } = await platform.api.trash(collection);
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 app.engine.dispatch(new NavigateAction(collection));
351
+ if (this.op !== 'list') await r.engine.dispatch(new NavigateAction(collection));
213
352
  } catch (err) {
214
- platform.notifications.error(`Trash: ${err.message}`);
353
+ notifications.error(`Trash: ${err.message}`);
215
354
  }
216
355
  }
217
356
  }
218
357
 
219
- export class DeleteAction extends AppAction {
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({ app }) {
367
+ async execute(r) {
225
368
  try {
226
- for (const id of this.ids) await app.platform.api.remove(id);
227
- app.platform.notifications.info(`Deleted ${this.ids.length} item${this.ids.length > 1 ? 's' : ''}`);
228
- for (const id of this.ids) app.platform.workbench.closeTab(id);
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
- app.platform.notifications.error(`Couldn’t delete: ${err.message}`);
373
+ r.notifications.error(`Couldn’t delete: ${err.message}`);
231
374
  }
232
- app.engine.dispatch(new RefreshAction());
375
+ r.engine.dispatch(new RefreshAction());
233
376
  }
234
377
  }
235
378
 
236
- export class RenameAction extends AppAction {
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({ app }) {
389
+ async execute(r) {
243
390
  try {
244
- const node = await app.platform.api.rename(this.id, this.newName);
245
- app.platform.workbench.updateTabNode(node.node);
246
- app.engine.dispatch(new RefreshAction());
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
- app.platform.notifications.error(`Couldn’t rename: ${err.message}`);
395
+ r.notifications.error(`Couldn’t rename: ${err.message}`);
249
396
  }
250
397
  }
251
398
  }
252
399
 
253
400
 
254
- export class OpenFileAction extends AppAction {
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({ app }) {
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 { platform } = app;
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(platform, this.node);
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(platform, this.node);
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 platform.workbench.showDialog({ kind: 'opener-chooser', node: this.node, openers: avail, reset: !!this.opts.reset });
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 AppAction {
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({ app }) {
295
- const collection = this.collectionId || app.explorer.state.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
- app.platform.notifications.error('Open a collection before uploading');
449
+ r.notifications.error('Open a collection before uploading');
300
450
  return;
301
451
  }
302
- const concurrency = app.platform.settings.get('uploads.concurrency');
303
- const uploads = [...this.files].map((file) => this.#one(app, file, collection, concurrency));
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
- app.engine.dispatch(new NavigateAction(collection));
455
+ r.engine.dispatch(new NavigateAction(collection));
306
456
  }
307
457
  /**
308
458
  * One file, once — and again, on the same tray row, if the user asks.
@@ -312,20 +462,20 @@ export class UploadFilesAction extends AppAction {
312
462
  * it can run at all without asking the user to find the file again; it is also why the
313
463
  * offer does not survive a reload, since nothing here is persisted.
314
464
  */
315
- async #one(app, file, collection, concurrency, existingTid = null) {
316
- const { transfers, platform } = app;
465
+ async #one(r, file, collection, concurrency, existingTid = null) {
466
+ const { api, notifications, transfers } = r;
317
467
  const tid = existingTid || newId('xfer');
318
468
  const controller = new AbortController();
319
469
  if (existingTid) {
320
470
  transfers.restart(tid, controller);
321
471
  } else {
322
472
  transfers.start(tid, file.name, file.size, controller, {
323
- retry: () => this.#one(app, file, collection, concurrency, tid),
473
+ retry: () => this.#one(r, file, collection, concurrency, tid),
324
474
  });
325
475
  }
326
476
  let uploadId = null;
327
477
  try {
328
- const node = await platform.api.upload(file, {
478
+ const node = await api.upload(file, {
329
479
  collection, concurrency, signal: controller.signal,
330
480
  onStart: (id) => { uploadId = id; },
331
481
  onProgress: (p) => transfers.progress(tid, p),
@@ -334,18 +484,18 @@ export class UploadFilesAction extends AppAction {
334
484
  // The server disambiguates a same-name collision rather than overwriting — tell
335
485
  // the user when the saved name differs from what they dropped.
336
486
  if (node?.name && node.name !== file.name) {
337
- platform.notifications.info(`"${file.name}" already existed — saved as "${node.name}".`);
487
+ notifications.info(`"${file.name}" already existed — saved as "${node.name}".`);
338
488
  }
339
489
  } catch (err) {
340
490
  // Release the server-side session so a cancelled/failed multipart upload doesn't
341
491
  // leak an open multipart object (best-effort; the server also sweeps stale ones).
342
- if (uploadId) platform.api.abortUpload(uploadId).catch(() => {});
492
+ if (uploadId) api.abortUpload(uploadId).catch(() => {});
343
493
  if (err.code === 'aborted') transfers.finish(tid, 'cancelled');
344
494
  else {
345
495
  transfers.finish(tid, 'error', err.message);
346
496
  // The toast says what happened; the tray row is where it can be acted on, and
347
497
  // pointing at it is the difference between a dead end and an offer.
348
- platform.notifications.error(`Upload failed: ${file.name} — ${err.message}. Retry it from the transfers tray.`);
498
+ notifications.error(`Upload failed: ${file.name} — ${err.message}. Retry it from the transfers tray.`);
349
499
  }
350
500
  }
351
501
  }
@@ -353,26 +503,28 @@ export class UploadFilesAction extends AppAction {
353
503
 
354
504
  /** Drive-wide tag/property filter (`#tag`, `#key:op:value`), optionally narrowed
355
505
  * by free text. Falls back to filtering the loaded folder when offline. */
356
- export class FilterAction extends AppAction {
506
+ export class FilterAction extends Action {
507
+ static deps = ['api', 'explorer', 'offline', 'search'];
508
+
357
509
  constructor(filters, text) {
358
510
  super();
359
511
  this.filters = filters || [];
360
512
  this.text = text || '';
361
513
  }
362
- async execute({ app }) {
363
- const { search, platform } = app;
514
+ async execute(r) {
515
+ const { api, search } = r;
364
516
  if (!this.filters.length) {
365
517
  search.set({ results: [], ran: false, filtered: false });
366
518
  return;
367
519
  }
368
520
  search.set({ query: this.text, loading: true, error: null, filtered: true });
369
- if (app.offline && !app.offline.state.online) {
370
- const items = (app.explorer.state.items || []).filter((n) => matchesTagFilters(n, this.filters));
521
+ if (r.offline && !r.offline.state.online) {
522
+ const items = (r.explorer.get().items || []).filter((n) => matchesTagFilters(n, this.filters));
371
523
  search.set({ results: items.map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: true });
372
524
  return;
373
525
  }
374
526
  try {
375
- const res = await platform.api.tagSearch(this.filters, this.text.trim() || undefined, { limit: 100 });
527
+ const res = await api.tagSearch(this.filters, this.text.trim() || undefined, { limit: 100 });
376
528
  search.set({ results: (res.items || []).map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: false });
377
529
  } catch (err) {
378
530
  search.set({ loading: false, error: err.message, ran: true, filtered: true });
@@ -382,13 +534,20 @@ export class FilterAction extends AppAction {
382
534
 
383
535
  /** Command-palette quick-open: a keyword file search whose results live in the search
384
536
  * service (state.se.paletteFiles) instead of ad-hoc state hung off the UI. */
385
- export class QuickOpenAction extends AppAction {
537
+ export class QuickOpenAction extends Action {
538
+ static deps = ['api', 'overlay', 'search'];
539
+
386
540
  constructor(query) {
387
541
  super();
388
542
  this.query = query;
389
543
  }
390
- async execute({ app }) {
391
- const { search, platform } = app;
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;
392
551
  const q = (this.query || '').trim();
393
552
  if (!q) { search.set({ paletteFiles: [], paletteQuery: '', paletteError: null, paletteLoading: false }); return; }
394
553
  // Keystrokes outrun the network: a slower request for an earlier query must not
@@ -396,11 +555,11 @@ export class QuickOpenAction extends AppAction {
396
555
  // whatever the palette input holds now is the only answer worth showing.
397
556
  search.set({ paletteQuery: q, paletteLoading: true, paletteError: null });
398
557
  try {
399
- const res = await platform.api.search(q, { mode: 'keyword', limit: 30 });
400
- if (search.state.paletteQuery !== q) return; // superseded
558
+ const res = await api.search(q, { mode: 'keyword', limit: 30 });
559
+ if (search.get().paletteQuery !== q) return; // superseded
401
560
  search.set({ paletteFiles: res.results || [], paletteLoading: false });
402
561
  } catch (err) {
403
- if (search.state.paletteQuery !== q) return;
562
+ if (search.get().paletteQuery !== q) return;
404
563
  // A failed search must not look like "no files matched" — that reads as a fact
405
564
  // about the drive when it's actually a fact about the request.
406
565
  search.set({ paletteFiles: [], paletteLoading: false, paletteError: err?.message || 'Search failed' });
@@ -408,22 +567,24 @@ export class QuickOpenAction extends AppAction {
408
567
  }
409
568
  }
410
569
 
411
- export class SearchAction extends AppAction {
570
+ export class SearchAction extends Action {
571
+ static deps = ['api', 'contributions', 'offline', 'search', 'settings'];
572
+
412
573
  constructor(query, mode) {
413
574
  super();
414
575
  this.query = query;
415
576
  this.mode = mode;
416
577
  }
417
- async execute({ app }) {
418
- const { search, platform } = app;
578
+ async execute(r) {
579
+ const { api, contributions, search, settings } = r;
419
580
  const q = this.query.trim();
420
581
  if (!q) {
421
582
  search.set({ query: '', results: [], ran: false, error: null, resolved: null });
422
583
  return;
423
584
  }
424
- const mode = this.mode || platform.settings.get('search.mode');
585
+ const mode = this.mode || settings.get('search.mode');
425
586
  search.set({ query: this.query, mode, loading: true, error: null, resolved: null });
426
- const offline = app.offline;
587
+ const offline = r.offline;
427
588
  // Offline (or server unreachable) → search the pinned corpus locally.
428
589
  if (offline && !offline.state.online) {
429
590
  try {
@@ -442,8 +603,8 @@ export class SearchAction extends AppAction {
442
603
  // the stack that read the sentence: "photos from the trip last summer" asks for a
443
604
  // gallery, and by the time the results are back all anyone can do is guess from
444
605
  // content types. It can only name a view from this list.
445
- const views = platform.contributions.ofType('view').map((v) => ({ id: v.id, title: v.title || v.id }));
446
- const res = await platform.api.query(q, { mode, limit: 40, views });
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 });
447
608
  search.set({ results: res.results, resolved: res.resolved || null, loading: false, ran: true, offline: false });
448
609
  } catch (err) {
449
610
  if (offline) {
@@ -465,14 +626,16 @@ export class SearchAction extends AppAction {
465
626
  * opening Settings gets a 403 that is the correct answer, not an error worth a toast.
466
627
  * The section simply does not render.
467
628
  */
468
- export class LoadApiKeysAction extends AppAction {
469
- async execute({ app }) {
470
- app.apiKeys.set({ loading: true, error: null });
629
+ export class LoadApiKeysAction extends Action {
630
+ static deps = ['api', 'apiKeys'];
631
+
632
+ async execute(r) {
633
+ r.apiKeys.set({ loading: true, error: null });
471
634
  try {
472
- const res = await app.platform.api.apiKeys();
473
- app.apiKeys.set({ keys: res.keys || [], loading: false, loaded: true, error: null });
635
+ const res = await r.api.apiKeys();
636
+ r.apiKeys.set({ keys: res.keys || [], loading: false, loaded: true, error: null });
474
637
  } catch (err) {
475
- app.apiKeys.set({ loading: false, loaded: true, error: err?.message || 'Could not load API keys' });
638
+ r.apiKeys.set({ loading: false, loaded: true, error: err?.message || 'Could not load API keys' });
476
639
  }
477
640
  }
478
641
  }
@@ -484,42 +647,1196 @@ export class LoadApiKeysAction extends AppAction {
484
647
  * put into state before anything else can fail. A refresh of the list afterwards is a
485
648
  * convenience; losing the secret because the refresh threw would not be.
486
649
  */
487
- export class MintApiKeyAction extends AppAction {
650
+ export class MintApiKeyAction extends Action {
651
+ static deps = ['api', 'apiKeys', 'notifications'];
652
+
488
653
  constructor(spec) {
489
654
  super();
490
655
  this.spec = spec;
491
656
  }
492
- async execute({ app }) {
493
- app.apiKeys.set({ busy: 'mint', error: null });
657
+ async execute(r) {
658
+ r.apiKeys.set({ busy: 'mint', error: null });
494
659
  try {
495
- const { key, secret } = await app.platform.api.mintApiKey(this.spec);
496
- app.apiKeys.set({ minted: { key, secret }, busy: null });
497
- const res = await app.platform.api.apiKeys().catch(() => null);
498
- if (res) app.apiKeys.set({ keys: res.keys || [] });
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 || [] });
499
664
  return key;
500
665
  } catch (err) {
501
- app.apiKeys.set({ busy: null, error: err?.message || 'Could not create the key' });
502
- app.platform.notifications.error(err?.message || 'Could not create the key');
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');
503
668
  return null;
504
669
  }
505
670
  }
506
671
  }
507
672
 
508
- export class RevokeApiKeyAction extends AppAction {
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'];
509
773
  constructor(id) {
510
774
  super();
511
775
  this.id = id;
512
776
  }
513
- async execute({ app }) {
514
- app.apiKeys.set({ busy: this.id, error: null });
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 });
515
1833
  try {
516
- await app.platform.api.revokeApiKey(this.id);
517
- const res = await app.platform.api.apiKeys().catch(() => null);
518
- app.apiKeys.set({ busy: null, ...(res ? { keys: res.keys || [] } : {}) });
519
- app.platform.notifications.success('Key revoked');
1834
+ await api.cancelRotation(collectionId);
1835
+ notifications.info('Rotation stopped. Files already moved stay on the new key.');
520
1836
  } catch (err) {
521
- app.apiKeys.set({ busy: null });
522
- app.platform.notifications.error(err?.message || 'Could not revoke the key');
1837
+ notifications.error(err?.message || 'Could not stop the rotation');
523
1838
  }
1839
+ rotation.set({ busy: false });
1840
+ return engine.dispatch(new LoadRotationAction());
524
1841
  }
525
1842
  }