@3sln/trove 0.0.7 → 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 (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -7,29 +7,45 @@
7
7
  import { dd } from '../../runtime.js';
8
8
  import { icon } from '../icon.js';
9
9
  import { bytes as fmtBytes } from '../format.js';
10
+ import { draftFor } from '../../bl/viewState.js';
11
+ import { CloseDialogAction, SetViewStateAction } from '../../bl/actions.js';
12
+ import { describeTrust } from '../../bl/trust.js';
10
13
 
11
14
  const { div, span, button, h3, p, label, input } = dd;
12
15
 
13
- // Grant selection persists across re-renders, keyed to the dialog instance.
14
- let sel = { ref: null, grants: null };
16
+ // The ticked capabilities, keyed to the dialog instance. Engine state — it decides which
17
+ // boxes render checked. See bl/viewState.js.
18
+ const KEY = 'pluginReview';
15
19
 
16
- export function pluginReview(d, ui) {
17
- const wb = ui.platform.workbench;
20
+ export function pluginReview(d, ui, view) {
18
21
  const s = d.summary;
19
- if (sel.ref !== d) {
22
+ // Derived, not written: the default used to be installed during the render that noticed
23
+ // the dialog had changed, which made rendering a side-effecting operation.
24
+ const sel = draftFor(view, KEY, d, {
20
25
  // Default: request everything the user is allowed to grant.
21
- sel = { ref: d, grants: new Set(s.capabilities.filter((c) => !c.adminOnly || d.isAdmin).map((c) => c.id)) };
22
- }
23
- const toggle = (cap) => { sel.grants.has(cap) ? sel.grants.delete(cap) : sel.grants.add(cap); ui.rerender?.(); };
26
+ grants: s.capabilities.filter((c) => !c.adminOnly || d.isAdmin).map((c) => c.id),
27
+ // Never defaulted true. An acknowledgement that arrives pre-ticked is not one.
28
+ acknowledged: false,
29
+ });
30
+ const policy = d.policy || { requiresAcknowledgement: false };
31
+ // Install stays out of reach until the risk has actually been acknowledged. A package
32
+ // this applies to is unsigned — see installPolicyFor.
33
+ const blocked = policy.requiresAcknowledgement && !sel.acknowledged;
34
+ // A new array rather than a mutated Set: the write has to be a different value for the
35
+ // cell to see it as a change at all.
36
+ const patch = (fields) => ui.engine.dispatch(new SetViewStateAction(KEY, { ...sel, ...fields }));
37
+ const toggle = (cap) => patch({
38
+ grants: sel.grants.includes(cap) ? sel.grants.filter((c) => c !== cap) : [...sel.grants, cap],
39
+ });
24
40
 
25
41
  return div({},
26
- div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
42
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
27
43
  div({ className: 'dialog review', $styling: { width: 'min(560px, 96vw)' } },
28
44
  header(s),
29
45
  div({ className: 'review-body' },
30
46
  s.description ? p({ className: 'review-desc' }, s.description) : null,
31
47
  section('Capabilities it requests', s.capabilities.length
32
- ? div({ className: 'cap-list' }, ...s.capabilities.map((c) => capRow(c, d.isAdmin, sel.grants.has(c.id), () => toggle(c.id))))
48
+ ? div({ className: 'cap-list' }, ...s.capabilities.map((c) => capRow(c, d.isAdmin, sel.grants.includes(c.id), () => toggle(c.id))))
33
49
  : muted('None — this plugin only runs in its sandbox.')),
34
50
  (s.network || []).length ? section('Network access', div({ className: 'contrib-list' }, ...s.network.map(endpointRow))) : null,
35
51
  (s.commands || []).length
@@ -40,14 +56,38 @@ export function pluginReview(d, ui) {
40
56
  s.settings.length ? section('Settings', div({ className: 'contrib-list' }, ...s.settings.map(settingRow))) : null,
41
57
  div({ className: 'review-meta' }, `${s.fileCount} files · ${fmtBytes(s.sizeBytes)} · id ${s.id}`),
42
58
  ),
59
+ policy.headline ? trustWarning(policy, sel, patch) : null,
43
60
  div({ className: 'row-actions' },
44
- button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
45
- button({ className: 'btn primary' }, icon('plug', { size: 15 }), 'Install').on({ click: () => d.onInstall([...sel.grants]) }),
61
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
62
+ button({ className: `btn primary ${blocked ? 'disabled' : ''}`, disabled: blocked },
63
+ icon('plug', { size: 15 }), 'Install')
64
+ .on({ click: () => !blocked && d.onInstall([...sel.grants]) }),
46
65
  ),
47
66
  ),
48
67
  );
49
68
  }
50
69
 
70
+ /**
71
+ * What is wrong with this package's provenance, said before the Install button rather than
72
+ * as a badge beside the name.
73
+ *
74
+ * A badge is something you can read past. This sits between the review and the action, and
75
+ * where an acknowledgement is required it holds the button until the box is ticked — which
76
+ * is the difference between telling someone and asking them.
77
+ */
78
+ function trustWarning(policy, sel, patch) {
79
+ return div({ className: `review-warning ${policy.status}` },
80
+ div({ className: 'rw-head' }, icon('warn', { size: 15 }), span(policy.headline)),
81
+ div({ className: 'rw-detail' }, policy.detail),
82
+ policy.requiresAcknowledgement
83
+ ? label({ className: 'rw-ack' },
84
+ input({ type: 'checkbox', checked: !!sel.acknowledged })
85
+ .on({ change: (e) => patch({ acknowledged: !!e.target.checked }) }),
86
+ span('I know this package is unsigned and I trust where it came from'))
87
+ : null,
88
+ );
89
+ }
90
+
51
91
  function header(s) {
52
92
  const t = s.trust || { status: 'unverified' };
53
93
  return div({ className: 'review-head' },
@@ -60,17 +100,16 @@ function header(s) {
60
100
  );
61
101
  }
62
102
 
63
- function trustBadge(t) {
64
- if (t.status === 'verified') {
65
- return span({ className: 'trust verified', title: `Signed by a key published at ${t.domain}` }, icon('check', { size: 13 }), 'Verified · ' + t.domain);
66
- }
67
- if (t.status === 'signed') {
68
- return span({ className: 'trust signed', title: t.reason || 'Signed, but the domain does not vouch for the key' }, icon('info', { size: 13 }), 'Signed');
69
- }
70
- if (t.status === 'invalid') {
71
- return span({ className: 'trust invalid', title: t.reason || 'Invalid signature — the package may have been tampered with' }, icon('warn', { size: 13 }), 'Invalid signature');
72
- }
73
- return span({ className: 'trust unverified', title: t.reason || 'This plugin is not signed' }, icon('warn', { size: 13 }), 'Unverified');
103
+ // The full badge, for the dialog that asks whether to install at all. Same classification
104
+ // as the list's — see bl/trust.js — with room to spell it out.
105
+ function trustBadge(trust) {
106
+ const t = describeTrust(trust);
107
+ const label = t.status === 'verified' && t.domain ? `Verified · ${t.domain}`
108
+ : t.status === 'invalid' ? 'Invalid signature'
109
+ : t.status === 'signed' ? 'Signed'
110
+ : 'Unverified';
111
+ return span({ className: `trust ${t.tone}`, title: t.explanation },
112
+ icon(t.icon, { size: 13 }), label);
74
113
  }
75
114
 
76
115
  function capRow(cap, isAdmin, checked, onToggle) {
@@ -1,5 +1,7 @@
1
1
  import { dd } from '../../runtime.js';
2
2
  import { icon } from '../icon.js';
3
+ import { CloseDialogAction, ExecCommandAction, OpenPluginPanelAction, RefreshPluginAction, SetPluginSecretAction, SetSettingAction, ShowDialogAction, UninstallPluginAction } from '../../bl/actions.js';
4
+ import { describeTrust } from '../../bl/trust.js';
3
5
 
4
6
  const { div, span, p, button, h2, input, label } = dd;
5
7
 
@@ -13,9 +15,9 @@ export default function pluginsView(state, ui) {
13
15
  'Plugins are sandboxed packages you install from a file or URL. They run in an isolated iframe and reach Trove only through a message channel, using the capabilities you granted.'),
14
16
  div({ className: 'plugin-install' },
15
17
  button({ className: 'btn primary' }, icon('upload', { size: 15 }), 'Install from file…')
16
- .on({ click: () => ui.exec('plugins.installFromFile') }),
18
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('plugins.installFromFile')) }),
17
19
  button({ className: 'btn' }, icon('plug', { size: 15 }), 'Install from URL…')
18
- .on({ click: () => ui.exec('plugins.installFromUrl') }),
20
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('plugins.installFromUrl')) }),
19
21
  ),
20
22
  plugins.length
21
23
  ? div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '12px', 'margin-top': '14px' } }, ...plugins.map((pl) => installedCard(pl, state, ui)))
@@ -52,60 +54,60 @@ function installedCard(pl, state, ui) {
52
54
  : div({ className: 'muted', $styling: { 'font-size': '12px' } }, pl.responsive ? 'No contributions announced.' : 'No manifest received — the plugin may not be running.'),
53
55
  )
54
56
  : null,
55
- (pl.settingsSchema || []).length ? settingsSection(pl, ui) : null,
57
+ (pl.settingsSchema || []).length ? settingsSection(pl, ui, state) : null,
56
58
  div({ className: 'actions' },
57
- pl.hasUi ? button({ className: 'btn' }, icon('command', { size: 14 }), 'Open panel').on({ click: () => ui.platform.workbench.openPluginPanel(pl.id) }) : null,
58
- button({ className: 'btn' }, icon('refresh', { size: 14 }), 'Refresh').on({ click: () => ui.platform.plugins.refresh(pl.id) }),
59
+ pl.hasUi ? button({ className: 'btn' }, icon('command', { size: 14 }), 'Open panel').on({ click: () => ui.engine.dispatch(new OpenPluginPanelAction(pl.id)) }) : null,
60
+ button({ className: 'btn' }, icon('refresh', { size: 14 }), 'Refresh').on({ click: () => ui.engine.dispatch(new RefreshPluginAction(pl.id)) }),
59
61
  button({ className: 'btn danger' }, 'Uninstall').on({
60
- click: () => ui.platform.workbench.showDialog({
62
+ click: () => ui.engine.dispatch(new ShowDialogAction({
61
63
  kind: 'confirm', title: `Uninstall ${pl.name}?`, danger: true, confirmLabel: 'Uninstall',
62
64
  body: 'The plugin and all data it stored will be removed.',
63
- onConfirm: () => { ui.platform.workbench.closeDialog(); ui.uninstallPlugin(pl.id); },
64
- }),
65
+ confirmActions: [new UninstallPluginAction(pl.id)],
66
+ })),
65
67
  }),
66
68
  ),
67
69
  );
68
70
  }
69
71
 
70
- function settingsSection(pl, ui) {
72
+ function settingsSection(pl, ui, state) {
71
73
  return div({ className: 'plugin-features' },
72
74
  div({ className: 'pf-head' }, span('Settings')),
73
75
  div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '8px' } },
74
- ...pl.settingsSchema.map((s) => settingRow(pl, s, ui)),
76
+ ...pl.settingsSchema.map((s) => settingRow(pl, s, ui, state)),
75
77
  ),
76
78
  );
77
79
  }
78
80
 
79
- function settingRow(pl, s, ui) {
81
+ function settingRow(pl, s, ui, state) {
80
82
  const key = `${pl.id}.${s.key}`;
81
- const value = s.secret ? '' : (ui.platform.settings.get(key) ?? s.default ?? '');
83
+ const value = s.secret ? '' : (state.settings?.[key] ?? s.default ?? '');
82
84
  return div({ className: 'setting', $styling: { padding: '6px 0' } },
83
85
  div({ className: 'info' }, div({ className: 't' }, s.title || s.key), s.description ? div({ className: 'd' }, s.description) : null),
84
86
  div({ className: 'control' },
85
87
  s.secret
86
88
  ? input({ className: 'input', type: 'password', placeholder: 'Set secret…' }).on({
87
- change: (e) => { if (e.target.value) { ui.platform.plugins.setSecret(pl.id, s.key, e.target.value); e.target.value = ''; e.target.placeholder = 'Saved ✓'; } },
89
+ change: (e) => { if (e.target.value) { ui.engine.dispatch(new SetPluginSecretAction(pl.id, s.key, e.target.value)); e.target.value = ''; e.target.placeholder = 'Saved ✓'; } },
88
90
  })
89
91
  : s.type === 'boolean'
90
- ? label({ className: 'switch' }, input({ type: 'checkbox', checked: !!value }).on({ change: (e) => ui.platform.settings.set(key, e.target.checked) }), span({ className: 'track' }))
91
- : input({ className: 'input', value }).on({ change: (e) => ui.platform.settings.set(key, e.target.value) }),
92
+ ? label({ className: 'switch' }, input({ type: 'checkbox', checked: !!value }).on({ change: (e) => ui.engine.dispatch(new SetSettingAction(key, e.target.checked)) }), span({ className: 'track' }))
93
+ : input({ className: 'input', value }).on({ change: (e) => ui.engine.dispatch(new SetSettingAction(key, e.target.value)) }),
92
94
  ),
93
95
  );
94
96
  }
95
97
 
96
- function trustBadge(t) {
97
- if (!t) return null;
98
- if (t.status === 'verified') return span({ className: 'trust verified', title: 'Signed by ' + t.domain }, icon('check', { size: 12 }), t.domain);
99
- if (t.status === 'signed') return span({ className: 'trust signed' }, icon('info', { size: 12 }), 'signed');
100
- // A BROKEN signature is not the same as no signature. The install dialog says so in
101
- // red; this list used to fall through to the same amber "unverified" an ordinary
102
- // unsigned plugin gets, so a package whose signature failed to verify — the one case
103
- // that means someone tampered with it — looked like the common, benign one.
104
- if (t.status === 'invalid') {
105
- return span({ className: 'trust invalid', title: t.reason || 'The signature did not verify — this package may have been altered' },
106
- icon('warn', { size: 12 }), 'invalid signature');
107
- }
108
- return span({ className: 'trust unverified' }, icon('warn', { size: 12 }), 'unverified');
98
+ // The compact badge. WHAT the signature means is decided in bl/trust.js — this only
99
+ // chooses how few characters to say it in, which is the part that is genuinely a question
100
+ // about a crowded row. The two badges used to be two `if` ladders maintained separately,
101
+ // and this one was missing the branch that distinguishes a tampered package from an
102
+ // unsigned one.
103
+ function trustBadge(trust) {
104
+ if (!trust) return null;
105
+ const t = describeTrust(trust);
106
+ const label = t.status === 'verified' ? t.domain
107
+ : t.status === 'invalid' ? 'invalid signature'
108
+ : t.status;
109
+ return span({ className: `trust ${t.tone}`, title: t.explanation },
110
+ icon(t.icon, { size: 12 }), label);
109
111
  }
110
112
 
111
113
  const KIND_ICON = { command: 'command', opener: 'file', indexer: 'search', statusItem: 'info' };
@@ -1,21 +1,26 @@
1
1
  import { dd } from '../../runtime.js';
2
- import { prettyKey, eventToKey } from '../../platform/keybindings.js';
2
+ import { eventToKey } from '../../platform/keybindings.js';
3
3
  import { icon } from '../icon.js';
4
- import { listAssociations, rememberOpener } from '../../bl/openers.js';
4
+ import { region } from '../region.js';
5
+ import * as q from '../../bl/queries.js';
6
+ import { rotationFor } from '../../bl/queries.js';
7
+ import { watchQuery } from '../../bl/watchQuery.js';
8
+ import { BeginRotationAction, CancelRotationAction, CopyTextAction, ExecCommandAction, PatchApiKeyDraftAction, RebindKeyAction, RememberOpenerAction, SetSettingAction, SetViewStateAction, ToggleApiKeyCapAction } from '../../bl/actions.js';
5
9
 
6
10
  const { div, h2, h3, p, span, select, option, input, label, button, ul, li, code } = dd;
7
11
 
8
12
  export default function settingsView(state, ui) {
9
- const groups = ui.platform.settings.grouped();
13
+ const groups = state.settingsGroups || new Map();
10
14
  return div({ className: 'editor' },
11
15
  div({ className: 'stage' },
12
16
  div({ className: 'settings' },
13
17
  h2('Settings'),
14
18
  p({ className: 'sub' }, 'Preferences are stored in this browser. Plugins contribute their own settings here too.'),
15
19
  ...groups.map((g) => group(g, ui)),
16
- mcpSection(ui),
20
+ mcpSection(ui, state.caps),
21
+ encryptionSection(state, ui),
17
22
  apiKeysSection(state, ui),
18
- openersSection(ui),
23
+ openersSection(state.assoc, ui),
19
24
  keybindingsSection(ui),
20
25
  ),
21
26
  ),
@@ -40,7 +45,7 @@ function setting(s, ui) {
40
45
  }
41
46
 
42
47
  function control(s, ui) {
43
- const set = (v) => ui.platform.settings.set(s.key, v);
48
+ const set = (v) => ui.engine.dispatch(new SetSettingAction(s.key, v));
44
49
  if (s.type === 'boolean') {
45
50
  return label({ className: 'switch' },
46
51
  input({ type: 'checkbox', checked: !!s.value }).on({ change: (e) => set(e.target.checked) }),
@@ -87,8 +92,7 @@ function control(s, ui) {
87
92
  //
88
93
  // It reads straight off `capabilities`, which the app already fetched at startup, so
89
94
  // there is no loading state and nothing to go stale.
90
- function mcpSection(ui) {
91
- const caps = ui.platform.capabilities;
95
+ function mcpSection(ui, caps) {
92
96
  if (!caps) return null; // capabilities never arrived; the shell has bigger problems
93
97
  const mcp = caps.mcp;
94
98
  const auth = caps.auth || { authorizationServers: [], source: 'none' };
@@ -97,13 +101,9 @@ function mcpSection(ui) {
97
101
  p({ className: 'sub' }, 'MCP is switched off on this server. Set TROVE_MCP=on to enable it.'));
98
102
  }
99
103
 
100
- const copy = (text) => () => {
101
- navigator.clipboard?.writeText(text)
102
- .then(() => ui.platform.notifications.success('Copied'))
103
- // Clipboard access is denied often enough that failing silently would look like a
104
- // broken button; showing the value still gets it into the user's hands.
105
- .catch(() => ui.platform.notifications.info(text, { sticky: true }));
106
- };
104
+ // The "denied clipboard still has to hand the value over" reasoning moved into
105
+ // CopyTextAction, which is where the other three copies of it went too.
106
+ const copy = (text) => () => ui.engine.dispatch(new CopyTextAction(text));
107
107
 
108
108
  const servers = auth.authorizationServers || [];
109
109
  // Where the value came from matters when it is wrong: "we took this from your JWT
@@ -192,7 +192,7 @@ function apiKeysSection(state, ui) {
192
192
  // sessions never need it, so it is not worth a request at boot.
193
193
  if (!keysRequested && !keys.loaded && !keys.loading) {
194
194
  keysRequested = true;
195
- ui.exec('keys.load');
195
+ ui.engine.dispatch(new ExecCommandAction('keys.load'));
196
196
  }
197
197
 
198
198
  // A non-admin gets a 403 here, which is the correct answer rather than an error worth
@@ -215,7 +215,7 @@ function apiKeysSection(state, ui) {
215
215
  !keys.draft && !keys.minted
216
216
  ? div({ className: 'keys-actions' },
217
217
  button({ className: 'btn' }, icon('plus', { size: 14 }), 'New key')
218
- .on({ click: () => ui.exec('keys.new') }))
218
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.new')) }))
219
219
  : null,
220
220
 
221
221
  keys.keys?.length
@@ -232,11 +232,7 @@ function apiKeysSection(state, ui) {
232
232
  * table would be dismissed without being copied.
233
233
  */
234
234
  function mintedBanner(minted, ui) {
235
- const copy = () => {
236
- navigator.clipboard?.writeText(minted.secret)
237
- .then(() => ui.platform.notifications.success('Key copied'))
238
- .catch(() => ui.platform.notifications.info(minted.secret, { sticky: true }));
239
- };
235
+ const copy = () => ui.engine.dispatch(new CopyTextAction(minted.secret, 'Key copied'));
240
236
  return div({ className: 'key-minted' },
241
237
  div({ className: 'key-minted-head' },
242
238
  icon('warn', { size: 15 }),
@@ -250,14 +246,16 @@ function mintedBanner(minted, ui) {
250
246
  button({ className: 'btn small', title: 'Copy' }, icon('link', { size: 13 })).on({ click: copy }),
251
247
  ),
252
248
  div({ className: 'keys-actions' },
253
- button({ className: 'btn' }, 'Done').on({ click: () => ui.exec('keys.dismissMinted') }),
249
+ button({ className: 'btn' }, 'Done').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.dismissMinted')) }),
254
250
  ),
255
251
  );
256
252
  }
257
253
 
258
254
  function draftForm(keys, collections, ui) {
259
255
  const draft = keys.draft;
260
- const scopes = ui.app.apiKeys.draftScopes();
256
+ // Resolved by the apiKeys view: what this draft would actually grant, or null when it
257
+ // would grant nothing.
258
+ const scopes = keys.draftScopes;
261
259
  const ready = !!draft.name.trim() && !!scopes;
262
260
 
263
261
  return div({ className: 'key-form' },
@@ -268,7 +266,7 @@ function draftForm(keys, collections, ui) {
268
266
  ),
269
267
  div({ className: 'control' },
270
268
  input({ className: 'input', value: draft.name, $attrs: { placeholder: 'CI uploader' } })
271
- .on({ input: (e) => ui.app.apiKeys.patchDraft({ name: e.target.value }) }),
269
+ .on({ input: (e) => ui.engine.dispatch(new PatchApiKeyDraftAction({ name: e.target.value })) }),
272
270
  ),
273
271
  ),
274
272
 
@@ -281,7 +279,7 @@ function draftForm(keys, collections, ui) {
281
279
  input({
282
280
  className: 'input', type: 'number', value: draft.expiresInDays,
283
281
  $attrs: { min: '1', max: '3650', placeholder: 'never' },
284
- }).on({ input: (e) => ui.app.apiKeys.patchDraft({ expiresInDays: e.target.value }) }),
282
+ }).on({ input: (e) => ui.engine.dispatch(new PatchApiKeyDraftAction({ expiresInDays: e.target.value })) }),
285
283
  ),
286
284
  ),
287
285
 
@@ -298,8 +296,8 @@ function draftForm(keys, collections, ui) {
298
296
  div({ className: 'keys-actions' },
299
297
  button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } },
300
298
  keys.busy === 'mint' ? 'Creating\u2026' : 'Create key')
301
- .on({ click: () => ready && ui.exec('keys.mint') }),
302
- button({ className: 'btn' }, 'Cancel').on({ click: () => ui.exec('keys.cancel') }),
299
+ .on({ click: () => ready && ui.engine.dispatch(new ExecCommandAction('keys.mint')) }),
300
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.cancel')) }),
303
301
  ),
304
302
  );
305
303
  }
@@ -314,7 +312,7 @@ function scopeRow(collection, draft, ui) {
314
312
  div({ className: 'key-scope-caps' },
315
313
  ...CAPS.map((c) => label({ className: `cap ${held.has(c.id) ? 'on' : ''}`, title: c.hint },
316
314
  input({ type: 'checkbox', checked: held.has(c.id) })
317
- .on({ change: () => ui.app.apiKeys.toggleCap(collection.id, c.id) }),
315
+ .on({ change: () => ui.engine.dispatch(new ToggleApiKeyCapAction(collection.id, c.id)) }),
318
316
  span(c.label),
319
317
  )),
320
318
  ),
@@ -339,7 +337,7 @@ function keyRow(k, keys, collections, ui) {
339
337
  ? button({
340
338
  className: 'btn small danger',
341
339
  $attrs: keys.busy === k.id ? { disabled: 'true' } : {},
342
- }, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.exec('keys.revoke', k.id) })
340
+ }, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.revoke', k.id)) })
343
341
  : null,
344
342
  );
345
343
  }
@@ -366,8 +364,96 @@ function metaLine(k) {
366
364
  return bits.join(' \u00b7 ');
367
365
  }
368
366
 
369
- function openersSection(ui) {
370
- const rows = listAssociations(ui.platform);
367
+ /**
368
+ * The open collection's key: which one it is, and moving it to a new one.
369
+ *
370
+ * Only for an encrypted collection, and only for an admin — a non-admin gets a 403 reading
371
+ * the rotation state, which is the correct answer rather than an error, so the section
372
+ * simply is not drawn.
373
+ */
374
+ function encryptionSection(state, ui) {
375
+ const collectionId = state.ex?.collectionId;
376
+ const enc = state.ex?.collections?.find((c) => c.id === collectionId)?.encryption;
377
+ if (!enc?.enabled) return null;
378
+ // Watching the query is what LOADS it — its bootAction reads the rotation state and its
379
+ // realization polls while one is running. Nothing fetches while this screen is closed.
380
+ return ui.watch(watchQuery(ui.engine, rotationFor(collectionId)), (rot) => encryptionBody(enc, rot || {}, ui));
381
+ }
382
+
383
+ function encryptionBody(enc, rot, ui) {
384
+ const running = rot.rotation?.status === 'running';
385
+ const est = rot.estimate;
386
+
387
+ return div({ className: 'group' },
388
+ h3('Encryption'),
389
+ p({ className: 'sub' }, 'Files in this collection are sealed before they leave your browser. The drive holds the key.'),
390
+
391
+ // The only thing that identifies which key the collection is on, and what a sideloaded
392
+ // object is matched against — so it has to be somewhere findable and copyable.
393
+ div({ className: 'setting' },
394
+ div({ className: 'info' },
395
+ div({ className: 't' }, 'Key fingerprint'),
396
+ div({ className: 'd' }, 'Identifies the key this collection is on.'),
397
+ ),
398
+ div({ className: 'control' },
399
+ button({ className: 'btn small', title: 'Copy' }, dd.h('code', enc.fingerprint))
400
+ .on({ click: () => ui.engine.dispatch(new CopyTextAction(enc.fingerprint, 'Fingerprint copied')) }),
401
+ ),
402
+ ),
403
+
404
+ div({ className: 'setting' },
405
+ div({ className: 'info' },
406
+ div({ className: 't' }, running ? 'Rotating the key' : 'Rotate the key'),
407
+ div({ className: 'd' }, running
408
+ ? `${rot.rotation.moved} moved${rot.rotation.failed ? `, ${rot.rotation.failed} failed` : ''} — this continues in the background.`
409
+ : rotationCost(est)),
410
+ ),
411
+ div({ className: 'control' },
412
+ running
413
+ ? button({ className: 'btn small', disabled: !!rot.busy }, 'Stop')
414
+ .on({ click: () => ui.engine.dispatch(new CancelRotationAction()) })
415
+ : button({ className: 'btn small', disabled: !!rot.busy }, 'Rotate')
416
+ .on({ click: () => ui.engine.dispatch(new BeginRotationAction()) }),
417
+ ),
418
+ ),
419
+ running
420
+ ? null
421
+ // Said before it is started rather than after: what has moved stays moved, so this
422
+ // is not a job that can be undone by regretting it.
423
+ : div({},
424
+ rotationBill(est),
425
+ p({ className: 'sub' }, 'Every file is re-sealed under a new key and the old one is retired once nothing is left on it. Files stay readable throughout.'),
426
+ ),
427
+ );
428
+ }
429
+
430
+ /**
431
+ * What a rotation would cost, before anyone starts one.
432
+ *
433
+ * The server already composes this in words — it knows the provider and its pricing, and
434
+ * whether anyone is billed at all — so this shows what it said rather than recomputing a
435
+ * worse version from a file count. `applicable` is false for a store nobody charges for,
436
+ * where the only cost is time.
437
+ */
438
+ function rotationCost(est) {
439
+ if (!est) return 'Re-seals every file under a fresh key.';
440
+ return est.summary || 'Re-seals every file under a fresh key.';
441
+ }
442
+
443
+ /** The itemised estimate, when the provider charges for the traffic. */
444
+ function rotationBill(est) {
445
+ if (!est?.applicable || !(est.lines || []).length) return null;
446
+ return div({ className: 'rot-bill' },
447
+ ...est.lines.map((l) => div({ className: 'rb-line' },
448
+ span(l.label || l.name || ''), span({ className: 'mono' }, l.amount || l.value || ''))),
449
+ est.total ? div({ className: 'rb-line total' }, span('Estimated total'), span({ className: 'mono' }, est.total)) : null,
450
+ // Prices move and this is a guess against a snapshot of them; saying when stops it
451
+ // reading as a quote.
452
+ est.asOf ? div({ className: 'rb-asof' }, `Prices as of ${est.asOf}.`) : null,
453
+ );
454
+ }
455
+
456
+ function openersSection(rows, ui) {
371
457
  return div({ className: 'group' },
372
458
  h3('Default Openers'),
373
459
  rows.length
@@ -380,7 +466,7 @@ function openersSection(ui) {
380
466
  ),
381
467
  div({ className: 'control' },
382
468
  button({ className: 'iconbtn', title: 'Forget this default' }, icon('close', { size: 14 }))
383
- .on({ click: () => rememberOpener(ui.platform, r.typeKey, null) }),
469
+ .on({ click: () => ui.engine.dispatch(new RememberOpenerAction(r.typeKey, null)) }),
384
470
  ),
385
471
  ),
386
472
  ),
@@ -389,21 +475,31 @@ function openersSection(ui) {
389
475
  );
390
476
  }
391
477
 
392
- // Which shortcut is currently listening for its new chord. Module-level because the
393
- // settings view re-renders from scratch and this is transient UI state, not a setting.
394
- let capturing = null;
478
+ // Which shortcut is listening for its new chord. Transient, and still engine state: it
479
+ // decides whether a row renders a `<kbd>` or "Press keys…". See bl/viewState.js.
480
+ const CAPTURE = 'keybindingCapture';
395
481
 
482
+ // Its own region, over the keybindings view and the capture state.
483
+ //
484
+ // Everything a row needs is decided by the query — the command's title, whether the user
485
+ // changed it, and whether another command answers to the same chord. That last one is not
486
+ // something a row could work out for itself: it needs the whole list at once.
487
+ //
488
+ // Rebinding dispatches with the binding ID. The component has never held a binding object,
489
+ // only a description of one, which is the point.
490
+ let kbRegion = null;
396
491
  function keybindingsSection(ui) {
397
- const kb = ui.platform.keybindings;
398
- const bindings = kb.resolved();
399
- const cmds = ui.platform.contributions;
400
- const overrides = kb.overrides();
401
- const stop = () => { capturing = null; ui.rerender?.(); };
402
- // Which chords more than one command answers to. Nothing rejects a collision, and
403
- // `#matchFor` scans in reverse so the LAST registration wins — bind Delete onto
404
- // ⌘P and Quick Open stops opening, with no hint anywhere that that is why.
405
- const byKey = new Map();
406
- for (const b of bindings) byKey.set(b.key, (byKey.get(b.key) || 0) + 1);
492
+ // Built once. A fresh region per render would rebuild its alias every pass, and dodo
493
+ // identifies an alias by the function — see ui/region.js.
494
+ kbRegion ??= region(ui.engine, { bindings: q.keybindings, view: q.viewState },
495
+ (st) => keybindingRows(st.bindings || [], ui, st.view?.[CAPTURE] ?? null));
496
+ return kbRegion();
497
+ }
498
+
499
+ function keybindingRows(bindings, ui, capturing) {
500
+ const setCapturing = (id) => ui.engine.dispatch(new SetViewStateAction(CAPTURE, id));
501
+ const stop = () => setCapturing(null);
502
+ const rebind = (bindingId, key) => ui.engine.dispatch(new RebindKeyAction(bindingId, key));
407
503
  return div({ className: 'group' },
408
504
  h3('Keyboard Shortcuts'),
409
505
  p({ className: 'sub' }, 'Click a shortcut to record a new one. Esc cancels; Backspace clears it.'),
@@ -411,41 +507,38 @@ function keybindingsSection(ui) {
411
507
  // more" anywhere, the shortcuts past it simply did not exist as far as anyone could
412
508
  // tell, and one plugin keymap was enough to push real ones off the end.
413
509
  ...bindings.map((b) => {
414
- const cmd = cmds.get(b.command);
415
510
  const listening = capturing === b.bindingId;
416
- const custom = !!overrides[b.bindingId];
417
- const clash = byKey.get(b.key) > 1;
418
511
  return div({ className: 'setting' },
419
512
  div({ className: 'info' },
420
- div({ className: 't' }, cmd?.title || b.command),
513
+ div({ className: 't' }, b.title),
421
514
  div({ className: 'd' }, b.command),
422
515
  ),
423
516
  div({ className: 'control' },
424
- clash && !listening
517
+ b.clash && !listening
425
518
  ? span({ className: 'kbd-clash', title: 'Another command answers to this shortcut too — the one registered last wins' },
426
519
  icon('warn', { size: 12 }))
427
520
  : null,
428
- button({ className: `kbd-edit ${listening ? 'listening' : ''} ${clash ? 'clash' : ''}`, title: listening ? 'Press the new shortcut' : 'Click to rebind' },
429
- listening ? span('Press keys…') : dd.h('kbd', prettyKey(b.key)))
521
+ button({ className: `kbd-edit ${listening ? 'listening' : ''} ${b.clash ? 'clash' : ''}`, title: listening ? 'Press the new shortcut' : 'Click to rebind' },
522
+ listening ? span('Press keys…') : dd.h('kbd', b.label))
430
523
  .on({
431
- click: () => { capturing = listening ? null : b.bindingId; ui.rerender?.(); },
524
+ click: () => setCapturing(listening ? null : b.bindingId),
432
525
  blur: () => { if (listening) stop(); },
433
526
  keydown: (e) => {
434
527
  if (!listening) return;
435
528
  e.preventDefault();
436
529
  e.stopPropagation();
437
530
  if (e.key === 'Escape') return stop();
438
- if (e.key === 'Backspace') { kb.rebind(b, null); return stop(); }
531
+ if (e.key === 'Backspace') { rebind(b.bindingId, null); return stop(); }
439
532
  // A bare modifier isn't a chord yet — wait for the key it modifies.
440
533
  if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) return;
441
- kb.rebind(b, eventToKey(e));
534
+ rebind(b.bindingId, eventToKey(e));
442
535
  stop();
443
536
  },
444
537
  $attach: (el) => { if (listening) queueMicrotask(() => el.focus()); },
445
538
  }),
446
- custom
539
+ b.custom
447
540
  ? button({ className: 'c-link', title: 'Back to the default' }, 'reset')
448
- .on({ click: () => { kb.rebind(b, null); ui.rerender?.(); } })
541
+ .on({ click: () => { rebind(b.bindingId, null); setCapturing(null); } })
449
542
  : null,
450
543
  ),
451
544
  );