@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.
- package/package.json +1 -1
- package/packages/core/src/encryption/envelope.js +46 -7
- package/packages/core/src/encryption/rotation.js +187 -68
- package/packages/core/src/uploads.js +130 -29
- package/packages/core/src/vfs.js +13 -1
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +12 -0
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +59 -26
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1454 -137
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +77 -141
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +31 -102
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +74 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +158 -45
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-y778bpte.js +0 -356
- package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
- package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -1,313 +1,132 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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,
|
|
8
|
-
UploadFilesAction,
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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', () =>
|
|
24
|
-
cmd('workbench.quickOpen', 'Go to File…', () =>
|
|
25
|
-
cmd('workbench.view.home', 'Go Home (search & browse)', () =>
|
|
26
|
-
cmd('workbench.view.plugins', 'Show Plugins', () =>
|
|
27
|
-
cmd('workbench.
|
|
28
|
-
cmd('workbench.
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
70
|
+
() => new CheckStorageAction(), { category: 'View', icon: 'plug' });
|
|
74
71
|
|
|
75
72
|
// --- explorer --------------------------------------------------------------
|
|
76
|
-
cmd('explorer.refresh', 'Refresh', () =>
|
|
77
|
-
cmd('explorer.loadMore', 'Show More Items', () =>
|
|
78
|
-
cmd('explorer.upload', 'Upload Files…', () => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
cmd('explorer.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
cmd('keys.
|
|
152
|
-
cmd('keys.
|
|
153
|
-
cmd('keys.
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
cmd('
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
+
}
|