@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
@@ -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
- navigator.clipboard?.writeText(text)
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.app.activity.cancel(task.id) })
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.app.activity.dismiss(task.id) })
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.app.activity.retryIssue(issue.id) })
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.app.activity.dismissIssue(issue.id) }),
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.app.activity.togglePanel(false) }),
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.exec('workbench.rebuildIndex') }),
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.exec('workbench.scanCollection') }),
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.exec('workbench.checkStorage') }),
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.exec('collections.create') }),
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.exec('collections.switch', c.id) })),
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.exec('collections.create') }),
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 { prettyKey } from '../../platform/keybindings.js';
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
- : filterCommands(state, ui, pal.query);
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
- wb.closePalette();
26
- if (pal.mode === 'files') ui.go(new OpenFileAction(item.node));
27
- else ui.exec(item.id);
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: () => wb.closePalette() }),
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 = () => wb.setPaletteIndex(i);
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
- const key = ui.platform.keybindings.labelFor(cmd.id);
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
- key ? span({ className: 'kbd' }, dd.h('kbd', prettyKey(keyRaw(ui, cmd.id)))) : null,
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
- wb.movePalette(1, items.length);
101
+ ui.engine.dispatch(new MovePaletteAction(1, items.length));
131
102
  } else if (e.key === 'ArrowUp') {
132
103
  e.preventDefault();
133
- wb.movePalette(-1, items.length);
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
- wb.closePalette();
110
+ ui.engine.dispatch(new ClosePaletteAction());
140
111
  }
141
112
  }
142
113
 
143
114
  let fileTimer = null;
144
115
  function onQuery(ui, value) {
145
- const wb = ui.platform.workbench;
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
- if (wb.overlay.state.palette?.mode !== 'files') return;
151
- // Route the file search through an action; results land in the reactive search
152
- // service (state.se.paletteFiles), so the palette re-renders without ad-hoc state.
153
- fileTimer = setTimeout(() => ui.go(new QuickOpenAction(value)), 200);
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 { availableOpeners } from '../../bl/openers.js';
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: () => w.back() }),
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: () => w.showHome() }),
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: () => w.openFile(p.node, p.openerId) })),
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: () => w.toggleInfoPanel() }),
45
+ .on({ click: () => ui.engine.dispatch(new ToggleInfoPanelAction()) }),
46
46
  button({ className: 'iconbtn', title: 'Close (Esc)' }, icon('close', { size: 15 }))
47
- .on({ click: () => w.showHome() }),
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
- const openers = availableOpeners(ui.platform, active.node);
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.platform.workbench.showDialog({ kind: 'opener-chooser', node: active.node, openers, current: active.openerId }) });
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) {