@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
@@ -1,313 +1,132 @@
1
- // Register the workbench's built-in commands. Commands are the single entry
2
- // point for every action — palette, keybindings, buttons, and menus all call
3
- // `commands.execute(id)`. Handlers here dispatch ngin Actions for anything that
4
- // touches data, and poke the reactive shell services for pure-UI toggles.
1
+ // The workbench's built-in commands.
2
+ //
3
+ // A command is a DESCRIPTION: an id, a title, and `actions(...args)` a pure factory
4
+ // saying what should be dispatched. It does not do anything itself.
5
+ //
6
+ // That is the whole change here, and it matters more than it looks. Commands are the entry
7
+ // point for everything a person actually does: the palette, every keybinding, every menu
8
+ // item, every row button. These were closures over `app` — the whole world — calling
9
+ // services directly, so `ExecCommandAction` routed the intent into the engine and the
10
+ // handler walked straight back out. The engine saw a command being run and nothing of what
11
+ // it did: no feed entry for the delete, the upload, or the collection switch, and nothing
12
+ // able to intercept or observe one.
13
+ //
14
+ // The bodies moved into actions (bl/actions.js, "what commands used to do inline"). What is
15
+ // left is a table, which is what a command registry should have been.
16
+ //
17
+ // `palette: false` where a command is a button on a screen rather than a verb someone would
18
+ // go looking for. Revoking a credential by fuzzy-searching for it is not a thing to make
19
+ // easy.
5
20
 
6
21
  import {
7
- NavigateAction, RefreshAction, DeleteAction, RenameAction,
8
- UploadFilesAction, OpenFileAction, CreateCollectionAction, LoadMoreAction, TrashAction,
9
- SearchAction,
10
- LoadApiKeysAction, MintApiKeyAction, RevokeApiKeyAction,
22
+ NavigateAction, RefreshAction, LoadMoreAction, TrashAction, ShowTrashAction,
23
+ UploadFilesAction, PickAndUploadAction, OpenSubjectAction, DownloadSubjectAction,
24
+ CopyLinkAction, RenameSubjectAction, DeleteSubjectAction, PinAction,
25
+ SearchAction, VoiceSearchAction,
26
+ RebuildIndexAction, ScanCollectionAction, CheckStorageAction, ToggleActivityPanelAction,
27
+ LoadApiKeysAction, RevokeApiKeyAction, MintApiKeyFromDraftAction,
28
+ StartApiKeyDraftAction, CancelApiKeyDraftAction, ClearMintedApiKeyAction,
29
+ OpenPaletteAction, SetActivityAction, CloseOverlaysAction, ShowHomeAction,
30
+ ShowDialogAction, SwitchCollectionAction, ToggleDetailsAction,
31
+ ToggleInboxAction, EnablePushAction,
32
+ InstallPluginFromUrlPromptAction, PickAndInstallPluginAction,
11
33
  } from './actions.js';
12
- import { beginInstallFromFile, beginInstallFromUrl } from './pluginInstall.js';
13
- import { troveUri } from '@3sln/trove/core/links.js';
14
34
 
15
- export function registerCommands(app) {
16
- const { platform, engine, explorer } = app;
17
- const { commands, workbench } = platform;
18
- const go = (a) => engine.dispatch(a);
35
+ // Everything that acts on "the file you mean" — the selection, or the one you have open.
36
+ //
37
+ // Without this the palette offered Rename and Delete on an empty drive, accepted the click,
38
+ // and answered with "Pick a file first". A when-clause greys them instead, which is the
39
+ // difference between an offer the drive cannot honour and an accurate menu.
40
+ //
41
+ // Row menus satisfy it because opening one SELECTS the row first (see openRowMenu), so a
42
+ // command reached that way always has a subject even though the clause cannot see the node
43
+ // argument it was handed.
44
+ const HAS_SUBJECT = 'explorer.hasSelection || editor.open';
19
45
 
20
- const cmd = (id, title, handler, extra = {}) => commands.register({ id, title, handler, ...extra });
46
+ export function registerCommands(app) {
47
+ const cmd = (id, title, actions, extra = {}) =>
48
+ app.platform.commands.register({ id, title, actions, ...extra });
21
49
 
22
50
  // --- navigation / views ----------------------------------------------------
23
- cmd('workbench.showCommandPalette', 'Show All Commands', () => workbench.openPalette('commands'), { category: 'View', icon: 'command' });
24
- cmd('workbench.quickOpen', 'Go to File…', () => workbench.openPalette('files'), { category: 'File', icon: 'search' });
25
- cmd('workbench.view.home', 'Go Home (search & browse)', () => workbench.showHome(), { category: 'View', icon: 'search' });
26
- cmd('workbench.view.plugins', 'Show Plugins', () => workbench.setActivity('plugins'), { category: 'View' });
27
- cmd('workbench.openSettings', 'Open Settings', () => workbench.setActivity('settings'), { category: 'Preferences', icon: 'gear' });
28
- cmd('workbench.closeOverlays', 'Close', () => workbench.closeOverlays(), { palette: false });
29
-
30
- // Speak to search.
31
- //
32
- // On a TV this is mostly NOT about recognising anything: a remote's mic dictates into
33
- // whatever text field the platform keyboard is attached to, and is swallowed by the
34
- // system assistant when there isn't one. So the command's first job is to put the
35
- // search field on screen and focused, which is a feature on every TV whether or not
36
- // the browser can transcribe. Where it can — on-device only, see platform/voice.js —
37
- // it also starts listening and types what it hears.
38
- cmd('search.voice', 'Search by Voice', async () => {
39
- await platform.voice.run({
40
- onText: (text, { final }) => {
41
- if (!text) return;
42
- workbench.setLaunchQuery(text);
43
- // Interim results keep the box in step with the speaker; only the settled
44
- // transcript is worth a round trip to the server.
45
- if (final) go(new SearchAction(text));
46
- },
47
- });
48
- }, { category: 'View', icon: 'search' });
51
+ cmd('workbench.showCommandPalette', 'Show All Commands', () => new OpenPaletteAction('commands'), { category: 'View', icon: 'command' });
52
+ cmd('workbench.quickOpen', 'Go to File…', () => new OpenPaletteAction('files'), { category: 'File', icon: 'search' });
53
+ cmd('workbench.view.home', 'Go Home (search & browse)', () => new ShowHomeAction(), { category: 'View', icon: 'search' });
54
+ cmd('workbench.view.plugins', 'Show Plugins', () => new SetActivityAction('plugins'), { category: 'View' });
55
+ cmd('workbench.view.admin', 'Show Administration', () => new SetActivityAction('admin'), { category: 'View', icon: 'gear' });
56
+ cmd('workbench.openSettings', 'Open Settings', () => new SetActivityAction('settings'), { category: 'Preferences', icon: 'gear' });
57
+ cmd('workbench.closeOverlays', 'Close', () => new CloseOverlaysAction(), { palette: false });
58
+ cmd('search.voice', 'Search by Voice', () => new VoiceSearchAction(), { category: 'View', icon: 'search' });
49
59
 
50
60
  // --- background work + standing problems -----------------------------------
51
61
  cmd('workbench.showActivity', 'Show Activity (running work & problems)',
52
- () => app.activity.togglePanel(true), { category: 'View', icon: 'refresh' });
53
- // The manual scan the drive had no way to ask for. Reports as a task rather than
54
- // blocking, because on a large drive it takes minutes.
62
+ () => new ToggleActivityPanelAction(true), { category: 'View', icon: 'refresh' });
55
63
  cmd('workbench.rebuildIndex', 'Rebuild Search Index',
56
- () => app.activity.rebuildIndex().catch(() => {}), { category: 'View', icon: 'refresh' });
64
+ () => new RebuildIndexAction(), { category: 'View', icon: 'refresh' });
57
65
  // Trove is not the only thing that can write to the bucket. This is how files added,
58
66
  // replaced, or removed by something else get picked up.
59
67
  cmd('workbench.scanCollection', 'Scan Collection for Outside Changes',
60
- () => {
61
- // No collection means no scan. This used to fall back to one called 'default',
62
- // which on a drive that has none scanned a collection that does not exist and
63
- // reported the failure as a scan error.
64
- const id = explorer.state.collectionId;
65
- if (!id) return platform.notifications.info('Open a collection first — a scan is per collection.');
66
- return app.activity.scanCollection(id).catch(() => {});
67
- },
68
- { category: 'Explorer', icon: 'refresh' });
69
- // Whether the backing stores are usable from a browser at all. Separate from a scan:
70
- // a scan asks what the store HOLDS, this asks whether the store can be READ from here,
71
- // which is the failure that makes every file open to a spinner.
68
+ () => new ScanCollectionAction(), { category: 'Explorer', icon: 'refresh' });
72
69
  cmd('workbench.checkStorage', 'Check Storage Configuration',
73
- () => app.activity.checkStorage().catch(() => {}), { category: 'View', icon: 'plug' });
70
+ () => new CheckStorageAction(), { category: 'View', icon: 'plug' });
74
71
 
75
72
  // --- explorer --------------------------------------------------------------
76
- cmd('explorer.refresh', 'Refresh', () => go(new RefreshAction()), { category: 'Explorer', icon: 'refresh' });
77
- cmd('explorer.loadMore', 'Show More Items', () => go(new LoadMoreAction()), { palette: false });
78
- cmd('explorer.upload', 'Upload Files…', () => {
79
- pickFiles((files) => files.length && go(new UploadFilesAction(files, explorer.state.collectionId)));
80
- }, { category: 'Explorer', icon: 'upload' });
81
-
82
- // Copy the item's trove: link, which is how one item references another in markdown.
83
- // "Nothing is selected" is a real answer, and it has to be said out loud: these used
84
- // to return silently, which is indistinguishable from a broken command.
85
- const subject = (fallbackNode) => {
86
- const node = fallbackNode || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
87
- if (!node) platform.notifications.info('Pick a file first highlight one in the list, or open it.');
88
- return node;
89
- };
90
-
91
- cmd('explorer.copyLink', 'Copy Link to Item', async () => {
92
- const node = subject();
93
- if (!node) return;
94
- const uri = troveUri(node);
95
- try {
96
- await navigator.clipboard.writeText(uri);
97
- platform.notifications.success(`Copied ${uri}`);
98
- } catch {
99
- // Clipboard access can be denied; showing the link is still useful.
100
- platform.notifications.info(uri, { sticky: true });
101
- }
102
- }, { category: 'Explorer', icon: 'link' });
73
+ cmd('explorer.refresh', 'Refresh', () => new RefreshAction(), { category: 'Explorer', icon: 'refresh' });
74
+ cmd('explorer.loadMore', 'Show More Items', () => new LoadMoreAction(), { palette: false });
75
+ cmd('explorer.upload', 'Upload Files…', () => new PickAndUploadAction(), { category: 'Explorer', icon: 'upload' });
76
+ // Two spellings of one address, and which you want depends on where it is going.
77
+ // `trove:` is what one document writes to link another; a share link is a URL you can
78
+ // paste into a message. Offered under both names, labelled for the destination rather
79
+ // than for the format, which is the difference between a choice and a riddle.
80
+ cmd('explorer.copyShareLink', 'Copy Shareable Link', (node) => new CopyLinkAction('share', node), { category: 'Explorer', icon: 'link', when: HAS_SUBJECT });
81
+ cmd('explorer.copyLink', 'Copy Link to Item', (node) => new CopyLinkAction('trove', node), { category: 'Explorer', icon: 'link', when: HAS_SUBJECT });
82
+ cmd('explorer.rename', 'Rename', (node) => new RenameSubjectAction(node), { category: 'Explorer', when: HAS_SUBJECT });
83
+ cmd('explorer.delete', 'Delete', () => new DeleteSubjectAction(), { category: 'Explorer', icon: 'trash', when: HAS_SUBJECT });
84
+ cmd('explorer.open', 'Open', (node) => new OpenSubjectAction(node), { palette: false });
85
+ cmd('explorer.download', 'Download', (node) => new DownloadSubjectAction(node), { category: 'Explorer', icon: 'download', when: HAS_SUBJECT });
86
+
87
+ // --- trash -----------------------------------------------------------------
88
+ cmd('explorer.showTrash', 'Show Trash', () => new ShowTrashAction(), { category: 'Explorer', icon: 'trash' });
89
+ cmd('explorer.hideTrash', 'Hide Trash', () => new TrashAction('hide'), { palette: false });
90
+ cmd('explorer.restore', 'Restore from Trash', (id) => (id ? new TrashAction('restore', id) : null), { palette: false });
91
+ cmd('explorer.purgeOne', 'Delete Forever', (id) => (id ? new TrashAction('purge', id) : null), { palette: false });
92
+ // Wholly declarative: the confirmation is data, and what it confirms is an action.
93
+ cmd('explorer.emptyTrash', 'Empty Trash', () => new ShowDialogAction({
94
+ kind: 'confirm', title: 'Empty the trash?',
95
+ body: 'Everything in the trash will be destroyed. This cannot be undone.',
96
+ danger: true, confirmLabel: 'Delete forever',
97
+ confirmActions: [new TrashAction('empty')],
98
+ }), { category: 'Explorer', icon: 'trash' });
103
99
 
104
- cmd('explorer.rename', 'Rename', () => {
105
- const node = subject();
106
- if (!node) return;
107
- workbench.showDialog({
108
- kind: 'prompt', title: 'Rename', label: 'New name', value: node.name, confirmLabel: 'Rename',
109
- onSubmit: (name) => {
110
- workbench.closeDialog();
111
- if (name?.trim() && name !== node.name) go(new RenameAction(node.id, name.trim()));
112
- },
113
- });
114
- }, { category: 'Explorer' });
115
-
116
- cmd('explorer.delete', 'Delete', () => {
117
- const nodes = explorer.selectedNodes();
118
- const fallback = nodes.length ? null : workbench.activeTab()?.node;
119
- if (fallback) nodes.push(fallback); // deleting the file you have open is the obvious intent
120
- if (!nodes.length) {
121
- platform.notifications.info('Pick a file first — highlight one in the list, or open it.');
122
- return;
123
- }
124
- const doDelete = () => go(new DeleteAction(nodes.map((n) => n.id)));
125
- if (platform.settings.get('explorer.confirmDelete')) {
126
- workbench.showDialog({
127
- kind: 'confirm', title: `Move ${nodes.length} item${nodes.length > 1 ? 's' : ''} to the trash?`,
128
- // Say what actually happens. Telling someone a file will be "permanently
129
- // deleted" when it goes to the trash trains them to fear a safe action; the
130
- // reverse — promising recovery that doesn't exist — is worse.
131
- body: nodes.length === 1
132
- ? `"${nodes[0].name}" leaves the drive but is kept, and can be restored from the trash.`
133
- : 'They leave the drive but are kept, and can be restored from the trash.',
134
- confirmLabel: 'Move to trash',
135
- onConfirm: () => {
136
- workbench.closeDialog();
137
- doDelete();
138
- },
139
- });
140
- } else doDelete();
141
- }, { category: 'Explorer', icon: 'trash' });
142
-
143
- cmd('explorer.showTrash', 'Show Trash', () => {
144
- go(new TrashAction('list'));
145
- workbench.showHome();
146
- }, { category: 'Explorer', icon: 'trash' });
147
100
  // --- API keys (admin) ---------------------------------------------------------
148
- // All `palette: false`: these are the settings screen's own buttons, not verbs anyone
149
- // would go looking for in the palette. Revoking a credential by fuzzy-searching for it
150
- // is not a thing to make easy.
151
- cmd('keys.load', 'Load API Keys', () => go(new LoadApiKeysAction()), { palette: false });
152
- cmd('keys.new', 'New API Key', () => app.apiKeys.startDraft(), { palette: false });
153
- cmd('keys.cancel', 'Cancel API Key', () => app.apiKeys.cancelDraft(), { palette: false });
154
- cmd('keys.dismissMinted', 'Dismiss API Key', () => app.apiKeys.clearMinted(), { palette: false });
155
- cmd('keys.revoke', 'Revoke API Key', (id) => id && go(new RevokeApiKeyAction(id)), { palette: false });
156
- cmd('keys.mint', 'Create API Key', () => {
157
- const draft = app.apiKeys.state.draft;
158
- const scopes = app.apiKeys.draftScopes();
159
- if (!draft?.name?.trim() || !scopes) return;
160
- // Days in the form, an absolute instant on the wire: the server compares against its
161
- // own clock, and "30 days from whenever this arrives" is not what was chosen.
162
- const days = Number(draft.expiresInDays);
163
- const expiresAt = draft.expiresInDays !== '' && Number.isFinite(days) && days > 0
164
- ? Date.now() + days * 86400_000
165
- : null;
166
- app.apiKeys.cancelDraft();
167
- return go(new MintApiKeyAction({ name: draft.name.trim(), scopes, expiresAt }));
168
- }, { palette: false });
169
-
170
- cmd('explorer.hideTrash', 'Hide Trash', () => go(new TrashAction('hide')), { palette: false });
171
- cmd('explorer.restore', 'Restore from Trash', (id) => id && go(new TrashAction('restore', id)), { palette: false });
172
- cmd('explorer.purgeOne', 'Delete Forever', (id) => id && go(new TrashAction('purge', id)), { palette: false });
173
- cmd('explorer.emptyTrash', 'Empty Trash', () => {
174
- workbench.showDialog({
175
- kind: 'confirm', title: 'Empty the trash?',
176
- body: 'Everything in the trash will be destroyed. This cannot be undone.',
177
- danger: true, confirmLabel: 'Delete forever',
178
- onConfirm: () => { workbench.closeDialog(); go(new TrashAction('empty')); },
179
- });
180
- }, { category: 'Explorer', icon: 'trash' });
181
-
182
- cmd('explorer.open', 'Open', (node) => go(new OpenFileAction(node || explorer.selectedNodes()[0])), { palette: false });
183
- cmd('explorer.download', 'Download', async (node) => {
184
- const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
185
- if (!target?.id) return;
186
- try {
187
- const { url, revoke } = await platform.api.download(target.id, target.name);
188
- triggerDownload(url, target.name);
189
- // A blob URL pins the bytes until it is released; the click has already happened
190
- // by the time this runs.
191
- if (revoke) setTimeout(() => URL.revokeObjectURL(url), 60_000);
192
- } catch (err) {
193
- platform.notifications.error(`Couldn't download ${target.name}: ${err.message}`);
194
- }
195
- }, { category: 'Explorer', icon: 'download' });
101
+ cmd('keys.load', 'Load API Keys', () => new LoadApiKeysAction(), { palette: false });
102
+ cmd('keys.new', 'New API Key', () => new StartApiKeyDraftAction(), { palette: false });
103
+ cmd('keys.cancel', 'Cancel API Key', () => new CancelApiKeyDraftAction(), { palette: false });
104
+ cmd('keys.dismissMinted', 'Dismiss API Key', () => new ClearMintedApiKeyAction(), { palette: false });
105
+ cmd('keys.revoke', 'Revoke API Key', (id) => (id ? new RevokeApiKeyAction(id) : null), { palette: false });
106
+ cmd('keys.mint', 'Create API Key', () => new MintApiKeyFromDraftAction(), { palette: false });
196
107
 
197
108
  // --- offline ---------------------------------------------------------------
198
- cmd('offline.pin', 'Make Available Offline', (node) => {
199
- const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
200
- if (target?.id) app.offline.pin(target);
201
- }, { category: 'Offline', icon: 'download' });
202
- cmd('offline.unpin', 'Remove from Offline', (node) => {
203
- const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
204
- if (target) app.offline.unpin(target.id);
205
- }, { palette: false });
109
+ cmd('offline.pin', 'Make Available Offline', (node) => new PinAction(node, true), { category: 'Offline', icon: 'download', when: HAS_SUBJECT });
110
+ cmd('offline.unpin', 'Remove from Offline', (node) => new PinAction(node, false), { palette: false });
206
111
 
207
112
  // --- collections -----------------------------------------------------------
208
- // The menu of "where else could I be". Shared by the palette command and the status
209
- // bar's collection segment, so both offer the same list.
210
- const collectionMenu = () => {
211
- // No fallback: this only decides which row gets a tick, and with nothing open the
212
- // answer is that none of them do. `|| 'default'` ticked a collection the user had
213
- // not chosen, and on a drive with one actually called "default", the wrong one.
214
- const current = explorer.state.collectionId;
215
- const items = (explorer.state.collections || []).map((c) => ({
216
- label: c.name || c.id,
217
- icon: c.id === current ? 'check' : 'files',
218
- run: () => commands.execute('collections.switch', c.id),
219
- }));
220
- if (explorer.state.canCreateCollection) {
221
- if (items.length) items.push({ sep: true });
222
- items.push({ label: 'New collection…', icon: 'plus', run: () => commands.execute('collections.create') });
223
- }
224
- return items;
225
- };
226
- app.collectionMenu = collectionMenu;
227
-
228
- // `NavigateAction` takes a single collectionId. This passed ('/', cid) — a leftover
229
- // from a path+collection signature — so every switch navigated to a collection
230
- // literally named "/" and failed to load. It was also unreachable: no caller, and
231
- // hidden from the palette.
232
- cmd('collections.switch', 'Switch Collection…', (cid) => {
233
- if (cid) {
234
- go(new NavigateAction(cid));
235
- workbench.showHome();
236
- return;
237
- }
238
- // From the palette or a keybinding there is no pointer to anchor a menu to.
239
- const items = collectionMenu();
240
- if (!items.length) return platform.notifications.info('This drive has one collection.');
241
- const w = typeof window === 'undefined' ? 800 : window.innerWidth;
242
- workbench.showContextMenu(Math.max(12, Math.round(w / 2) - 110), 120, items);
243
- }, { category: 'Collections', icon: 'files' });
244
- cmd('collections.create', 'New Collection…', () => {
245
- workbench.showDialog({
246
- kind: 'collection', title: 'New collection',
247
- onSubmit: (record) => {
248
- workbench.closeDialog();
249
- if (record) go(new CreateCollectionAction(record));
250
- },
251
- });
252
- }, { category: 'Collections', icon: 'files' });
113
+ cmd('collections.switch', 'Switch Collection…', (cid) => new SwitchCollectionAction(cid), { category: 'Collections', icon: 'files' });
114
+ cmd('collections.create', 'New Collection…', () => new ShowDialogAction({ kind: 'collection', title: 'New collection' }), { category: 'Collections', icon: 'files' });
253
115
 
254
116
  // --- conversations & notifications -----------------------------------------
255
- // The details panel is a view OF a file. With nothing open there is nothing for it to
256
- // show, and flipping the flag silently was indistinguishable from a broken command.
257
- cmd('workbench.toggleInfoPanel', 'Toggle Details & Conversation', () => {
258
- if (!workbench.activeTab()) {
259
- platform.notifications.info('Open a file to see its details and conversation.');
260
- return;
261
- }
262
- workbench.toggleInfoPanel();
263
- }, { category: 'View', icon: 'info' });
264
- cmd('notifications.show', 'Show Notifications', () => app.social.toggleInbox(true), { category: 'View' });
265
- cmd('notifications.enablePush', 'Enable Push Notifications', () => app.social.enablePush(), { category: 'Notifications' });
117
+ cmd('workbench.toggleInfoPanel', 'Toggle Details & Conversation', () => new ToggleDetailsAction(), { category: 'View', icon: 'info' });
118
+ cmd('notifications.show', 'Show Notifications', () => new ToggleInboxAction(true), { category: 'View' });
119
+ cmd('notifications.enablePush', 'Enable Push Notifications', () => new EnablePushAction(), { category: 'Notifications' });
266
120
 
267
121
  // --- plugins ---------------------------------------------------------------
268
- cmd('plugins.installFromUrl', 'Install Plugin from URL…', () => {
269
- workbench.showDialog({
270
- kind: 'prompt', title: 'Install plugin from URL', label: 'Plugin package (.zip) URL',
271
- placeholder: 'https://example.com/plugin.zip', confirmLabel: 'Fetch',
272
- onSubmit: (url) => { workbench.closeDialog(); if (url?.trim()) beginInstallFromUrl(app, url.trim()); },
273
- });
274
- }, { category: 'Plugins', icon: 'plug' });
275
- cmd('plugins.installFromFile', 'Install Plugin from File…', () => {
276
- pickZip((file) => file && beginInstallFromFile(app, file));
277
- }, { category: 'Plugins', icon: 'plug' });
278
- }
279
-
280
- // --- helpers ----------------------------------------------------------------
281
-
282
- // Open a native file picker. The hidden <input> is removed on selection AND on
283
- // cancel — cancelling fires no `change`, so we also clean up on the next window
284
- // focus (which the OS dialog returns) to avoid leaking a growing pile of inputs.
285
- function pick(cb, configure) {
286
- const input = document.createElement('input');
287
- input.type = 'file';
288
- configure(input);
289
- input.style.display = 'none';
290
- document.body.appendChild(input);
291
- const cleanup = () => { input.remove(); window.removeEventListener('focus', onFocus); };
292
- const onFocus = () => setTimeout(() => { if (!input.files.length) cleanup(); }, 300);
293
- input.addEventListener('change', () => { cb(input.files); cleanup(); }, { once: true });
294
- window.addEventListener('focus', onFocus);
295
- input.click();
296
- }
297
- function pickFiles(cb) {
298
- pick((files) => cb(files), (input) => { input.multiple = true; });
299
- }
300
- function pickZip(cb) {
301
- pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
302
- }
303
-
304
- function triggerDownload(url, name) {
305
- const a = document.createElement('a');
306
- a.href = url;
307
- a.download = name || '';
308
- document.body.appendChild(a);
309
- a.click();
310
- a.remove();
122
+ cmd('plugins.installFromUrl', 'Install Plugin from URL…', () => new ShowDialogAction({
123
+ kind: 'prompt', title: 'Install plugin from URL', label: 'Plugin package (.zip) URL',
124
+ placeholder: 'https://example.com/plugin.zip', confirmLabel: 'Fetch',
125
+ confirmActions: [new InstallPluginFromUrlPromptAction()],
126
+ }), { category: 'Plugins', icon: 'plug' });
127
+ cmd('plugins.installFromFile', 'Install Plugin from File…', () => new PickAndInstallPluginAction(), { category: 'Plugins', icon: 'plug' });
311
128
  }
312
129
 
313
- export { pickFiles, triggerDownload };
130
+ // Kept for the one caller that still hands a File list straight to an upload — the drop
131
+ // target, which already has the files and has nothing to pick.
132
+ export { UploadFilesAction, NavigateAction, SearchAction };
@@ -0,0 +1,69 @@
1
+ // The built-in context keys, DERIVED.
2
+ //
3
+ // Every key here is a function of a resource's state. That is not a refactor of where the
4
+ // writes live — it removes the writes. A derived key cannot be forgotten when a new code
5
+ // path changes the thing it summarises, which is the failure the old shape kept producing:
6
+ // `explorer.hasSelection` was set by NavigateAction and by nothing else, so selecting a file
7
+ // in the launcher left it false and the Delete keybinding silently did nothing.
8
+ //
9
+ // Registered in one place because that is what makes the list reviewable. A when-clause is
10
+ // only as trustworthy as the least-maintained key it names, and they were spread across five
11
+ // services.
12
+
13
+ import { derive } from '../runtime.js';
14
+
15
+ /**
16
+ * Register every built-in key against the resources it is derived from.
17
+ *
18
+ * @param {import('../platform/context.js').ContextRegistry} registry
19
+ * @param {object} r the resources these summarise
20
+ * @returns {() => void} unregister them all
21
+ */
22
+ export function registerCoreContext(registry, { workbench, overlay, navigation, explorer }) {
23
+ const wb = workbench.observe();
24
+ const nav = navigation.observe();
25
+ const ov = overlay.observe();
26
+ const ex = explorer.observe();
27
+
28
+ const keys = {
29
+ // Which screen is showing. Seeded as 'home' because that is where the app starts, and a
30
+ // `when: view.active == 'home'` binding — the Delete shortcut among them — was dead from
31
+ // boot until the first click on the rail when nothing had written the key yet.
32
+ 'view.active': derive([wb], (s) => s.activity),
33
+ 'sidebar.visible': derive([wb], (s) => s.sidebarVisible),
34
+ 'searchModal.open': derive([wb], (s) => !!s.searchModal),
35
+ 'sheet.open': derive([wb], (s) => s.sheet || ''),
36
+ 'infoPanel.open': derive([wb], (s) => !!s.infoPanel),
37
+
38
+ 'palette.open': derive([ov], (s) => !!s.palette),
39
+
40
+ // What is open in the viewer, for openers and viewer-scoped shortcuts.
41
+ 'editor.open': derive([nav], (s) => !!s.activeFile),
42
+ 'editor.openerId': derive([nav], (s) => s.stack?.at(-1)?.openerId || ''),
43
+ 'editor.contentType': derive([nav], (s) => s.activeFile?.contentType || ''),
44
+
45
+ // `null` when no collection is open, never the string 'default'. A when-clause reading
46
+ // this is asking WHICH collection is open, and answering with the name of one that may
47
+ // not exist made every such clause true before the user had chosen anything.
48
+ 'explorer.collectionId': derive([ex], (s) => s.collectionId ?? null),
49
+ 'explorer.hasSelection': derive([ex], (s) => (s.selection?.length || 0) > 0),
50
+ };
51
+
52
+ const offs = Object.entries(keys).map(([key, source]) => registry.register(key, source));
53
+ return () => offs.forEach((off) => off());
54
+ }
55
+
56
+ /**
57
+ * The viewport's keys, registered separately because the viewport is measured rather than
58
+ * derived from a resource — it comes from the window, and the service already holds a cell
59
+ * of what it measured.
60
+ */
61
+ export function registerViewportContext(registry, viewport) {
62
+ const vp = viewport.observe();
63
+ const offs = [
64
+ registry.register('viewport.mode', derive([vp], (s) => s.mode)),
65
+ registry.register('viewport.phone', derive([vp], (s) => s.mode === 'phone')),
66
+ registry.register('viewport.tv', derive([vp], (s) => s.mode === 'tv')),
67
+ ];
68
+ return () => offs.forEach((off) => off());
69
+ }