@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
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { dd } from '../../runtime.js';
|
|
12
12
|
import { icon } from '../icon.js';
|
|
13
13
|
import { bytes } from '../format.js';
|
|
14
|
+
import { CancelTaskAction, CopyTextAction, DismissIssueAction, DismissTaskAction, ExecCommandAction, RetryIssueAction, ToggleActivityPanelAction } from '../../bl/actions.js';
|
|
14
15
|
|
|
15
16
|
const { div, button, span, h3, p, pre } = dd;
|
|
16
17
|
|
|
@@ -24,9 +25,7 @@ const STATUS_ICON = { running: 'refresh', done: 'check', failed: 'close', cancel
|
|
|
24
25
|
* of the app and into a terminal.
|
|
25
26
|
*/
|
|
26
27
|
function copyText(text, ui) {
|
|
27
|
-
|
|
28
|
-
.then(() => ui.platform.notifications.success('Copied'))
|
|
29
|
-
.catch(() => ui.platform.notifications.info(text, { sticky: true }));
|
|
28
|
+
ui.engine.dispatch(new CopyTextAction(text));
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
function amount(task) {
|
|
@@ -51,9 +50,9 @@ function taskRow(task, ui) {
|
|
|
51
50
|
task.error ? div({ className: 'act-error' }, task.error) : null,
|
|
52
51
|
),
|
|
53
52
|
running && task.cancellable
|
|
54
|
-
? button({ className: 'act-action', title: 'Cancel' }, icon('close', { size: 12 })).on({ click: () => ui.
|
|
53
|
+
? button({ className: 'act-action', title: 'Cancel' }, icon('close', { size: 12 })).on({ click: () => ui.engine.dispatch(new CancelTaskAction(task.id)) })
|
|
55
54
|
: !running
|
|
56
|
-
? button({ className: 'act-action', title: 'Dismiss' }, icon('close', { size: 12 })).on({ click: () => ui.
|
|
55
|
+
? button({ className: 'act-action', title: 'Dismiss' }, icon('close', { size: 12 })).on({ click: () => ui.engine.dispatch(new DismissTaskAction(task.id)) })
|
|
57
56
|
: null,
|
|
58
57
|
),
|
|
59
58
|
running
|
|
@@ -89,10 +88,10 @@ function issueRow(issue, ui) {
|
|
|
89
88
|
// Retry only when the server said pressing it will actually do something.
|
|
90
89
|
issue.retryable
|
|
91
90
|
? button({ className: 'btn small act-retry' }, icon('refresh', { size: 12 }), span('Retry'))
|
|
92
|
-
.on({ click: () => ui.
|
|
91
|
+
.on({ click: () => ui.engine.dispatch(new RetryIssueAction(issue.id)) })
|
|
93
92
|
: null,
|
|
94
93
|
button({ className: 'btn small ghost act-dismiss' }, 'Dismiss')
|
|
95
|
-
.on({ click: () => ui.
|
|
94
|
+
.on({ click: () => ui.engine.dispatch(new DismissIssueAction(issue.id)) }),
|
|
96
95
|
),
|
|
97
96
|
);
|
|
98
97
|
}
|
|
@@ -107,7 +106,7 @@ export default function activityPanel(state, ui) {
|
|
|
107
106
|
div({ className: 'act-head' },
|
|
108
107
|
h3('Activity'),
|
|
109
108
|
button({ className: 'act-action', title: 'Close' }, icon('close', { size: 13 }))
|
|
110
|
-
.on({ click: () => ui.
|
|
109
|
+
.on({ click: () => ui.engine.dispatch(new ToggleActivityPanelAction(false)) }),
|
|
111
110
|
),
|
|
112
111
|
|
|
113
112
|
// "Nothing is wrong" and "we couldn't ask" look identical unless one says so. Both
|
|
@@ -145,15 +144,15 @@ export default function activityPanel(state, ui) {
|
|
|
145
144
|
|
|
146
145
|
div({ className: 'act-foot act-actions' },
|
|
147
146
|
button({ className: 'btn small ghost act-rebuild' }, icon('refresh', { size: 12 }), span('Rebuild search index'))
|
|
148
|
-
.on({ click: () => ui.
|
|
147
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.rebuildIndex')) }),
|
|
149
148
|
button({ className: 'btn small ghost act-scan' }, icon('refresh', { size: 12 }), span('Scan for outside changes'))
|
|
150
|
-
.on({ click: () => ui.
|
|
149
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.scanCollection')) }),
|
|
151
150
|
// On demand as well as on a schedule, because the moment an admin wants to know
|
|
152
151
|
// whether they fixed the bucket is right after they changed it — not up to five
|
|
153
152
|
// minutes later. It also checks against THIS browser's origin, which the scheduled
|
|
154
153
|
// run can only do if the deployment configured one.
|
|
155
154
|
button({ className: 'btn small ghost act-storage' }, icon('plug', { size: 12 }), span('Check storage'))
|
|
156
|
-
.on({ click: () => ui.
|
|
155
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.checkStorage')) }),
|
|
157
156
|
),
|
|
158
157
|
);
|
|
159
158
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The admin console.
|
|
2
|
+
//
|
|
3
|
+
// Drive-level administration had accumulated in three places and no place: API keys in
|
|
4
|
+
// Settings, storage diagnostics behind a status-bar item, ACLs in a dialog, and the driver
|
|
5
|
+
// registry, the indexers and the identity provider only through the API. Each landed where
|
|
6
|
+
// it did for a local reason, and the result was that there was nowhere to send someone who
|
|
7
|
+
// had just been made an administrator.
|
|
8
|
+
//
|
|
9
|
+
// A DESTINATION, not a route. 006 introduced share links, and what it actually established
|
|
10
|
+
// is that this app does not reflect its state in the address bar — it consumes a share path
|
|
11
|
+
// at boot and immediately replaces it with `/`, because a URL that goes stale the moment
|
|
12
|
+
// you navigate is worse than one that says nothing. So there is no router to hang a route
|
|
13
|
+
// on, and an activity-bar destination beside Plugins and Settings is the consistent answer.
|
|
14
|
+
//
|
|
15
|
+
// Almost nothing here is new capability. It is gathering: every value below already comes
|
|
16
|
+
// from `/api/capabilities`, which the app fetches at startup, or from a query that already
|
|
17
|
+
// exists. What was missing was somewhere to look.
|
|
18
|
+
|
|
19
|
+
import { dd } from '../../runtime.js';
|
|
20
|
+
import { icon } from '../icon.js';
|
|
21
|
+
import { bytes } from '../format.js';
|
|
22
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
23
|
+
|
|
24
|
+
const { div, h2, h3, p, span, button } = dd;
|
|
25
|
+
|
|
26
|
+
export default function adminView(state, ui) {
|
|
27
|
+
const caps = state.caps;
|
|
28
|
+
return div({ className: 'editor' },
|
|
29
|
+
div({ className: 'stage' },
|
|
30
|
+
div({ className: 'settings admin' },
|
|
31
|
+
h2('Administration'),
|
|
32
|
+
p({ className: 'sub' }, 'How this drive is configured, and what it can do. Most of it is decided by the deployment rather than here.'),
|
|
33
|
+
accessSection(caps, state, ui),
|
|
34
|
+
storageSection(caps, state, ui),
|
|
35
|
+
extensionsSection(caps, state),
|
|
36
|
+
workSection(state, ui),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A label/value row, matching the settings screen so the two read as one thing. */
|
|
43
|
+
function row(title, detail, value) {
|
|
44
|
+
return div({ className: 'setting' },
|
|
45
|
+
div({ className: 'info' },
|
|
46
|
+
div({ className: 't' }, title),
|
|
47
|
+
detail ? div({ className: 'd' }, detail) : null,
|
|
48
|
+
),
|
|
49
|
+
div({ className: 'control' }, typeof value === 'string' ? span({ className: 'mono' }, value) : value),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Who this drive thinks you are, and who it lets in.
|
|
55
|
+
*
|
|
56
|
+
* The identity driver and whether authentication is required are deployment decisions —
|
|
57
|
+
* environment variables, not preferences — so they are reported rather than offered. Which
|
|
58
|
+
* is the point: an admin needs to know what is in force, and previously could only find out
|
|
59
|
+
* by reading the server's configuration or calling the API.
|
|
60
|
+
*/
|
|
61
|
+
function accessSection(caps, state, ui) {
|
|
62
|
+
const auth = caps?.auth || {};
|
|
63
|
+
const me = caps?.principal || state.so?.me || {};
|
|
64
|
+
const servers = auth.authorizationServers || [];
|
|
65
|
+
return div({ className: 'group' },
|
|
66
|
+
h3('Access'),
|
|
67
|
+
p({ className: 'sub' }, 'Who can reach this drive. Set by the deployment, not here.'),
|
|
68
|
+
row('Signed in as', me.anonymous ? 'This drive does not require a sign-in.' : (me.email || me.id),
|
|
69
|
+
me.anonymous ? 'anonymous' : (me.name || me.id || '—')),
|
|
70
|
+
row('Sign-in required', 'Whether a caller must present a token.',
|
|
71
|
+
caps?.mcp?.requiresAuth ? 'Yes' : 'No'),
|
|
72
|
+
row('Authorization server', 'Where clients are sent to sign in.',
|
|
73
|
+
servers.length ? servers.join(', ') : 'not set'),
|
|
74
|
+
// API keys stay in Settings for now and are LINKED rather than duplicated: two places
|
|
75
|
+
// to revoke a credential is worse than one place in the wrong screen.
|
|
76
|
+
row('API keys', 'Credentials for scripts and services, scoped per collection.',
|
|
77
|
+
button({ className: 'btn small' }, 'In Settings')
|
|
78
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.openSettings')) })),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What this deployment can actually do with bytes.
|
|
84
|
+
*
|
|
85
|
+
* The driver registry is the list a collection form offers, and it is deployment
|
|
86
|
+
* configuration: `TROVE_STORAGE_DRIVERS` narrows it, and a driver missing from here is the
|
|
87
|
+
* reason a collection cannot be made on it. The capability flags below explain behaviour
|
|
88
|
+
* that otherwise looks arbitrary — why a download is proxied rather than direct, why a
|
|
89
|
+
* usage figure is missing.
|
|
90
|
+
*/
|
|
91
|
+
function storageSection(caps, state, ui) {
|
|
92
|
+
const s = caps?.storage || {};
|
|
93
|
+
const drivers = caps?.storageDrivers || [];
|
|
94
|
+
const usage = state.ex?.usage;
|
|
95
|
+
const can = (on) => span({ className: `mono ${on ? '' : 'muted'}` }, on ? 'yes' : 'no');
|
|
96
|
+
return div({ className: 'group' },
|
|
97
|
+
h3('Storage'),
|
|
98
|
+
p({ className: 'sub' }, 'The backing stores this deployment offers, and what they support.'),
|
|
99
|
+
row('Drivers offered', 'What a new collection may be created on.',
|
|
100
|
+
drivers.length ? drivers.map((d) => d.label || d.key).join(', ') : 'none registered'),
|
|
101
|
+
row('Direct downloads', 'A presigned URL sends bytes from the store, not through the drive.', can(s.presignDownload)),
|
|
102
|
+
row('Direct uploads', 'A presigned PUT sends bytes to the store, not through the drive.', can(s.presignUpload)),
|
|
103
|
+
row('Multipart', 'Large files go up in parts, resumably.', can(s.multipart)),
|
|
104
|
+
row('Range requests', 'Seeking inside a file without fetching the whole thing.', can(s.range)),
|
|
105
|
+
usage?.total
|
|
106
|
+
? row('Space', 'Reported by the backing store.', `${bytes(usage.available)} free of ${bytes(usage.total)}`)
|
|
107
|
+
: row('Space', 'This store does not report a total, which object stores generally cannot.', 'not reported'),
|
|
108
|
+
// The check and its remedy already exist as a command — this is where someone would
|
|
109
|
+
// look for it, which is the whole complaint the ticket makes.
|
|
110
|
+
div({ className: 'setting' },
|
|
111
|
+
div({ className: 'info' },
|
|
112
|
+
div({ className: 't' }, 'Check the backing stores'),
|
|
113
|
+
div({ className: 'd' }, 'Asks whether each store can actually be reached from here — the failure that makes every file open to a spinner.'),
|
|
114
|
+
),
|
|
115
|
+
div({ className: 'control' },
|
|
116
|
+
button({ className: 'btn small' }, 'Run check')
|
|
117
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.checkStorage')) })),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What extends this drive, and what each extension is permitted.
|
|
124
|
+
*
|
|
125
|
+
* Indexers matter here for a reason that is easy to miss: an indexer reads file CONTENTS,
|
|
126
|
+
* including from an encrypted collection, because indexing happens on the drive's side of
|
|
127
|
+
* the encryption. Anyone weighing what encryption protects needs to know which ones are
|
|
128
|
+
* installed, so the same list appears on the collection form.
|
|
129
|
+
*/
|
|
130
|
+
function extensionsSection(caps, state) {
|
|
131
|
+
const indexers = caps?.indexers || [];
|
|
132
|
+
const plugins = state.plugins || [];
|
|
133
|
+
return div({ className: 'group' },
|
|
134
|
+
h3('Extensions'),
|
|
135
|
+
p({ className: 'sub' }, 'What reads and adds to this drive.'),
|
|
136
|
+
div({ className: 'setting' },
|
|
137
|
+
div({ className: 'info' },
|
|
138
|
+
div({ className: 't' }, 'Indexers'),
|
|
139
|
+
div({ className: 'd' }, 'They read file contents to make them searchable — including in an encrypted collection, since indexing happens on the drive’s side of the encryption.'),
|
|
140
|
+
),
|
|
141
|
+
div({ className: 'control' },
|
|
142
|
+
span({ className: 'mono' }, indexers.length ? indexers.map((i) => i.displayName || i.id).join(', ') : 'none')),
|
|
143
|
+
),
|
|
144
|
+
plugins.length
|
|
145
|
+
? div({}, ...plugins.map((pl) => div({ className: 'setting' },
|
|
146
|
+
div({ className: 'info' },
|
|
147
|
+
div({ className: 't' }, pl.manifest?.displayName || pl.id),
|
|
148
|
+
div({ className: 'd' }, (pl.grants || []).length
|
|
149
|
+
? `May: ${(pl.grants || []).join(', ')}`
|
|
150
|
+
: 'Granted nothing — it runs in its sandbox and can only draw.'),
|
|
151
|
+
),
|
|
152
|
+
div({ className: 'control' }, span({ className: `mono ${pl.status === 'active' ? '' : 'muted'}` }, pl.status || 'idle')),
|
|
153
|
+
)))
|
|
154
|
+
: row('Plugins', 'Installed by an administrator, from a signed package.', 'none installed'),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What the drive is doing, and what it has not finished.
|
|
160
|
+
*
|
|
161
|
+
* The panel that shows running work already exists and is reached from the status bar,
|
|
162
|
+
* which is fine when something is running and undiscoverable when nothing is. The counts
|
|
163
|
+
* here are the discoverable version; the panel is still where the detail lives.
|
|
164
|
+
*/
|
|
165
|
+
function workSection(state, ui) {
|
|
166
|
+
const act = state.act || { tasks: [], issues: [] };
|
|
167
|
+
const running = (act.tasks || []).filter((t) => t.status === 'running');
|
|
168
|
+
return div({ className: 'group' },
|
|
169
|
+
h3('Work'),
|
|
170
|
+
p({ className: 'sub' }, 'Background jobs and anything that needs attention.'),
|
|
171
|
+
row('Running now', 'Scans, reindexes and rotations happening on the server.',
|
|
172
|
+
running.length ? `${running.length}` : 'nothing running'),
|
|
173
|
+
row('Needs attention', 'Standing problems — they persist until fixed, unlike a toast.',
|
|
174
|
+
act.issues?.length ? `${act.issues.length}` : 'none'),
|
|
175
|
+
// `stacked`: three buttons do not fit the 200px control column — see styles.css.
|
|
176
|
+
div({ className: 'setting stacked' },
|
|
177
|
+
div({ className: 'info' },
|
|
178
|
+
div({ className: 't' }, 'Maintenance'),
|
|
179
|
+
div({ className: 'd' }, 'A scan picks up files changed in the bucket by something other than Trove. A reindex rebuilds search from scratch.'),
|
|
180
|
+
),
|
|
181
|
+
div({ className: 'control admin-actions' },
|
|
182
|
+
button({ className: 'btn small' }, 'Activity panel')
|
|
183
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.showActivity')) }),
|
|
184
|
+
button({ className: 'btn small' }, 'Scan')
|
|
185
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.scanCollection')) }),
|
|
186
|
+
button({ className: 'btn small' }, 'Reindex')
|
|
187
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.rebuildIndex')) }),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { dd } from '../../runtime.js';
|
|
15
15
|
import { icon } from '../icon.js';
|
|
16
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
16
17
|
|
|
17
18
|
const { div, h2, p, button, span } = dd;
|
|
18
19
|
|
|
@@ -44,7 +45,7 @@ function createPrompt(ui) {
|
|
|
44
45
|
+ 'there is somewhere to put it.'),
|
|
45
46
|
div({ className: 'gate-actions' },
|
|
46
47
|
button({ className: 'btn primary' }, icon('plus', { size: 14 }), 'New collection')
|
|
47
|
-
.on({ click: () => ui.
|
|
48
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.create')) }),
|
|
48
49
|
),
|
|
49
50
|
);
|
|
50
51
|
}
|
|
@@ -69,12 +70,12 @@ function choosePrompt(ex, ui) {
|
|
|
69
70
|
// What they may do, from the server's own answer — so a read-only collection says
|
|
70
71
|
// so before they open it and find the Upload button missing.
|
|
71
72
|
span({ className: 'gate-caps' }, (c.capabilities || []).join(' · ')),
|
|
72
|
-
).on({ click: () => ui.
|
|
73
|
+
).on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.switch', c.id)) })),
|
|
73
74
|
),
|
|
74
75
|
ex.canCreateCollection
|
|
75
76
|
? div({ className: 'gate-actions' },
|
|
76
77
|
button({ className: 'btn' }, icon('plus', { size: 14 }), 'New collection')
|
|
77
|
-
.on({ click: () => ui.
|
|
78
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.create')) }),
|
|
78
79
|
)
|
|
79
80
|
: null,
|
|
80
81
|
);
|
|
@@ -4,31 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
import { dd } from '../../runtime.js';
|
|
6
6
|
import { icon, iconForNode } from '../icon.js';
|
|
7
|
-
import {
|
|
8
|
-
import { OpenFileAction, QuickOpenAction } from '../../bl/actions.js';
|
|
7
|
+
import { ClosePaletteAction, ExecCommandAction, MovePaletteAction, OpenFileAction, QuickOpenAction, SetPaletteIndexAction, SetPaletteQueryAction } from '../../bl/actions.js';
|
|
9
8
|
|
|
10
9
|
const { div, input, span } = dd;
|
|
11
10
|
|
|
12
11
|
export default function commandPalette(state, ui) {
|
|
13
12
|
const pal = state.overlay.palette;
|
|
14
13
|
if (!pal) return null;
|
|
15
|
-
const wb = ui.platform.workbench;
|
|
16
14
|
// With no query there is nothing to show — and showing the PREVIOUS session's hits
|
|
17
15
|
// would be worse than nothing, since they don't match what the field now says.
|
|
16
|
+
// Already ranked — see the `paletteMatches` query. The palette used to score and sort
|
|
17
|
+
// here, with its own fuzzy algorithm that disagreed with the launcher's.
|
|
18
18
|
const items = pal.mode === 'files'
|
|
19
19
|
? (pal.query.trim() ? state.se.paletteFiles || [] : [])
|
|
20
|
-
:
|
|
20
|
+
: state.matches || [];
|
|
21
21
|
const index = Math.min(pal.index, Math.max(0, items.length - 1));
|
|
22
22
|
|
|
23
23
|
const run = (item) => {
|
|
24
24
|
if (!item) return;
|
|
25
|
-
|
|
26
|
-
if (pal.mode === 'files') ui.
|
|
27
|
-
else ui.
|
|
25
|
+
ui.engine.dispatch(new ClosePaletteAction());
|
|
26
|
+
if (pal.mode === 'files') ui.engine.dispatch(new OpenFileAction(item.node));
|
|
27
|
+
else ui.engine.dispatch(new ExecCommandAction(item.id));
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
return div({},
|
|
31
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
31
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new ClosePaletteAction()) }),
|
|
32
32
|
div({ className: 'palette' },
|
|
33
33
|
div({ className: 'search' },
|
|
34
34
|
icon(pal.mode === 'files' ? 'search' : 'command', { size: 18 }),
|
|
@@ -54,7 +54,7 @@ export default function commandPalette(state, ui) {
|
|
|
54
54
|
div({ className: 'results' },
|
|
55
55
|
items.length
|
|
56
56
|
? items.map((item, i) => {
|
|
57
|
-
const hover = () =>
|
|
57
|
+
const hover = () => ui.engine.dispatch(new SetPaletteIndexAction(i));
|
|
58
58
|
return pal.mode === 'files' ? fileOpt(item, i === index, run, hover) : cmdOpt(item, i === index, ui, run, hover);
|
|
59
59
|
})
|
|
60
60
|
: emptyResults(pal, state),
|
|
@@ -72,23 +72,19 @@ function emptyResults(pal, state) {
|
|
|
72
72
|
return div({ className: 'none' }, pal.query.trim() ? 'No files found' : 'Type to search files by name');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Everything here arrives decided — see the paletteCommands view query. This used to ask
|
|
76
|
+
// the keybinding service for a label, then throw the label away and re-resolve the raw key
|
|
77
|
+
// to prettify it again, which was two lookups to render one `<kbd>`.
|
|
75
78
|
function cmdOpt(cmd, active, ui, run, hover) {
|
|
76
|
-
|
|
77
|
-
const available = ui.platform.commands.isAvailable(cmd);
|
|
78
|
-
return div({ className: `opt ${active ? 'active' : ''} ${available ? '' : 'unavailable'}` },
|
|
79
|
+
return div({ className: `opt ${active ? 'active' : ''} ${cmd.available ? '' : 'unavailable'}` },
|
|
79
80
|
span({ className: 'ico' }, icon(cmd.icon || 'command', { size: 16 })),
|
|
80
81
|
cmd.category ? span({ className: 'cat' }, cmd.category + ' ›') : null,
|
|
81
82
|
span({ className: 'title' }, cmd.title),
|
|
82
|
-
!available ? span({ className: 'offline-tag' }, 'offline') : null,
|
|
83
|
-
|
|
83
|
+
!cmd.available ? span({ className: 'offline-tag' }, 'offline') : null,
|
|
84
|
+
cmd.keybinding ? span({ className: 'kbd' }, dd.h('kbd', cmd.keybinding)) : null,
|
|
84
85
|
).on({ click: () => run(cmd), mouseenter: hover });
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function keyRaw(ui, id) {
|
|
88
|
-
const b = ui.platform.keybindings.resolved().find((x) => x.command === id);
|
|
89
|
-
return b ? b.key : '';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
88
|
function fileOpt(item, active, run, hover) {
|
|
93
89
|
return div({ className: `opt ${active ? 'active' : ''}` },
|
|
94
90
|
span({ className: 'ico' }, icon(iconForNode(item.node), { size: 16 })),
|
|
@@ -97,58 +93,31 @@ function fileOpt(item, active, run, hover) {
|
|
|
97
93
|
).on({ click: () => run(item), mouseenter: hover });
|
|
98
94
|
}
|
|
99
95
|
|
|
100
|
-
function filterCommands(state, ui, query) {
|
|
101
|
-
const all = ui.platform.commands.paletteCommands();
|
|
102
|
-
const q = query.trim().toLowerCase();
|
|
103
|
-
if (!q) return all.slice(0, 60);
|
|
104
|
-
const scored = [];
|
|
105
|
-
for (const c of all) {
|
|
106
|
-
const hay = `${c.category || ''} ${c.title}`.toLowerCase();
|
|
107
|
-
const s = fuzzyScore(hay, q);
|
|
108
|
-
if (s > 0) scored.push([s, c]);
|
|
109
|
-
}
|
|
110
|
-
return scored.sort((a, b) => b[0] - a[0]).slice(0, 60).map((x) => x[1]);
|
|
111
|
-
}
|
|
112
96
|
|
|
113
|
-
function fuzzyScore(hay, q) {
|
|
114
|
-
let score = 0;
|
|
115
|
-
let hi = 0;
|
|
116
|
-
for (const ch of q) {
|
|
117
|
-
const idx = hay.indexOf(ch, hi);
|
|
118
|
-
if (idx < 0) return 0;
|
|
119
|
-
score += idx === hi ? 3 : 1;
|
|
120
|
-
hi = idx + 1;
|
|
121
|
-
}
|
|
122
|
-
if (hay.includes(q)) score += 10;
|
|
123
|
-
return score;
|
|
124
|
-
}
|
|
125
97
|
|
|
126
98
|
function onKey(e, ui, items, index, run) {
|
|
127
|
-
const wb = ui.platform.workbench;
|
|
128
99
|
if (e.key === 'ArrowDown') {
|
|
129
100
|
e.preventDefault();
|
|
130
|
-
|
|
101
|
+
ui.engine.dispatch(new MovePaletteAction(1, items.length));
|
|
131
102
|
} else if (e.key === 'ArrowUp') {
|
|
132
103
|
e.preventDefault();
|
|
133
|
-
|
|
104
|
+
ui.engine.dispatch(new MovePaletteAction(-1, items.length));
|
|
134
105
|
} else if (e.key === 'Enter') {
|
|
135
106
|
e.preventDefault();
|
|
136
107
|
run(items[index]);
|
|
137
108
|
} else if (e.key === 'Escape') {
|
|
138
109
|
e.preventDefault();
|
|
139
|
-
|
|
110
|
+
ui.engine.dispatch(new ClosePaletteAction());
|
|
140
111
|
}
|
|
141
112
|
}
|
|
142
113
|
|
|
143
114
|
let fileTimer = null;
|
|
144
115
|
function onQuery(ui, value) {
|
|
145
|
-
|
|
146
|
-
wb.setPaletteQuery(value);
|
|
147
|
-
// The palette's CURRENT mode, not whichever one was on screen when this listener
|
|
148
|
-
// was created — see the note at the input.
|
|
116
|
+
ui.engine.dispatch(new SetPaletteQueryAction(value));
|
|
149
117
|
clearTimeout(fileTimer);
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
// service (state.se.paletteFiles), so the palette re-renders
|
|
153
|
-
|
|
118
|
+
// The mode guard moved into QuickOpenAction, where "current" means current: the timer
|
|
119
|
+
// below outlives the render that set it, so checking here would test a stale mode.
|
|
120
|
+
// Results land in the search service (state.se.paletteFiles), so the palette re-renders
|
|
121
|
+
// without ad-hoc state.
|
|
122
|
+
fileTimer = setTimeout(() => ui.engine.dispatch(new QuickOpenAction(value)), 200);
|
|
154
123
|
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { dd } from '../../runtime.js';
|
|
9
9
|
import { icon, iconForNode } from '../icon.js';
|
|
10
10
|
import { renderOpener } from './openers/index.js';
|
|
11
|
-
import {
|
|
11
|
+
import { openersFor } from '../../bl/openers.js';
|
|
12
|
+
import { NavigateBackAction, OpenInPanelAction, ShowDialogAction, ShowHomeAction, ToggleInfoPanelAction } from '../../bl/actions.js';
|
|
12
13
|
|
|
13
14
|
const { div, span, button } = dd;
|
|
14
15
|
|
|
@@ -21,30 +22,29 @@ export default function editorArea(state, ui) {
|
|
|
21
22
|
const active = files[files.length - 1];
|
|
22
23
|
if (!active) return div({ className: 'editor' });
|
|
23
24
|
return div({ className: 'editor' },
|
|
24
|
-
navBar(files, active, ui),
|
|
25
|
+
navBar(files, active, state, ui),
|
|
25
26
|
div({ className: 'stage' }, openerHost(active, ui)),
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function navBar(files, active, ui) {
|
|
30
|
-
const w = ui.platform.workbench;
|
|
30
|
+
function navBar(files, active, state, ui) {
|
|
31
31
|
return div({ className: 'viewer-nav' },
|
|
32
32
|
button({ className: 'vn-back', title: 'Back (Esc)' }, icon('chevron-left', { size: 16 }))
|
|
33
|
-
.on({ click: () =>
|
|
33
|
+
.on({ click: () => ui.engine.dispatch(new NavigateBackAction()) }),
|
|
34
34
|
div({ className: 'vn-trail' },
|
|
35
35
|
button({ className: 'vn-crumb' }, icon('search', { size: 13 }), span('Search'))
|
|
36
|
-
.on({ click: () =>
|
|
36
|
+
.on({ click: () => ui.engine.dispatch(new ShowHomeAction()) }),
|
|
37
37
|
...files.map((p) =>
|
|
38
38
|
button({ className: `vn-crumb ${p.id === active.id ? 'active' : ''}`, title: p.node.name },
|
|
39
39
|
icon(iconForNode(p.node), { size: 13 }), span({ className: 'label' }, p.node.name))
|
|
40
|
-
.on({ click: () =>
|
|
40
|
+
.on({ click: () => ui.engine.dispatch(new OpenInPanelAction(p.node, p.openerId)) })),
|
|
41
41
|
),
|
|
42
42
|
div({ className: 'vn-actions' },
|
|
43
|
-
openerSwitch(active, ui),
|
|
43
|
+
openerSwitch(active, state, ui),
|
|
44
44
|
button({ className: 'iconbtn', title: 'Details & comments' }, icon('info', { size: 15 }))
|
|
45
|
-
.on({ click: () =>
|
|
45
|
+
.on({ click: () => ui.engine.dispatch(new ToggleInfoPanelAction()) }),
|
|
46
46
|
button({ className: 'iconbtn', title: 'Close (Esc)' }, icon('close', { size: 15 }))
|
|
47
|
-
.on({ click: () =>
|
|
47
|
+
.on({ click: () => ui.engine.dispatch(new ShowHomeAction()) }),
|
|
48
48
|
),
|
|
49
49
|
);
|
|
50
50
|
}
|
|
@@ -52,11 +52,14 @@ function navBar(files, active, ui) {
|
|
|
52
52
|
// "Open with…" — shown only when more than one opener can handle this file. Opens
|
|
53
53
|
// the chooser (pre-selected to the current opener) so the user can switch viewers,
|
|
54
54
|
// and optionally make the choice their default for this file type.
|
|
55
|
-
function openerSwitch(active, ui) {
|
|
56
|
-
|
|
55
|
+
function openerSwitch(active, state, ui) {
|
|
56
|
+
// `state.openers` has already had its when-clauses and plugin health resolved by the
|
|
57
|
+
// query; matching them against THIS file is pure. That split is why this no longer
|
|
58
|
+
// reaches for the contribution registry mid-render.
|
|
59
|
+
const openers = openersFor(state.openers, active.node);
|
|
57
60
|
if (openers.length <= 1) return null;
|
|
58
61
|
return button({ className: 'iconbtn', title: 'Open with…' }, icon('dots', { size: 15 }))
|
|
59
|
-
.on({ click: () => ui.
|
|
62
|
+
.on({ click: () => ui.engine.dispatch(new ShowDialogAction({ kind: 'opener-chooser', node: active.node, openers, current: active.openerId })) });
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function openerHost(panel, ui) {
|