@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
@@ -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) {
@@ -6,10 +6,11 @@
6
6
 
7
7
  import { dd } from '../../runtime.js';
8
8
  import { icon } from '../icon.js';
9
- import { OpenFileAction, SearchAction, FilterAction } from '../../bl/actions.js';
9
+ import { ExecCommandAction, FilterAction, MoveLaunchAction, SearchAction, SelectLaunchAction, SetLaunchIndexAction, SetLaunchQueryAction } from '../../bl/actions.js';
10
10
  import { parseTagQuery, filterLabel } from '../../bl/tagQuery.js';
11
- import { activeView, renderView, viewSwitcher, viewMove } from './views/index.js';
11
+ import { renderView, viewSwitcher, viewMove } from './views/index.js';
12
12
  import { openRowMenu } from './views/parts.js';
13
+ import { activate } from '../activate.js';
13
14
 
14
15
  const { div, span, input, button } = dd;
15
16
 
@@ -23,8 +24,8 @@ const ARROWS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
23
24
  // stops being the right advice. The fallback covers the moment before capabilities
24
25
  // arrive and any server too old to say; `!` is ours either way, since running a command
25
26
  // from here is a client convention the server knows nothing about.
26
- function promptFor(ui, { compact = false, modal = false } = {}) {
27
- const p = ui.platform.capabilities?.searchPrompt;
27
+ function promptFor(caps, { compact = false, modal = false } = {}) {
28
+ const p = caps?.searchPrompt;
28
29
  const base = (compact ? p?.short || p?.placeholder : p?.placeholder)
29
30
  || (compact ? 'Search files' : 'Search files · # filter by tag');
30
31
  // On a phone the box is ~300px wide; adding a second clause guarantees an ellipsis
@@ -36,30 +37,32 @@ let searchTimer = null;
36
37
  function runSearch(ui, query) {
37
38
  // SearchAction owns search state; the launcher only dispatches (no direct .set).
38
39
  clearTimeout(searchTimer);
39
- searchTimer = setTimeout(() => ui.go(new SearchAction(query)), 240);
40
+ searchTimer = setTimeout(() => ui.engine.dispatch(new SearchAction(query)), 240);
40
41
  }
41
42
  function clearSearch(ui) {
42
- ui.platform.workbench.setLaunchQuery('');
43
- ui.go(new SearchAction('')); // empty query resets results/ran/error in the service
43
+ ui.engine.dispatch(new SetLaunchQueryAction(''));
44
+ ui.engine.dispatch(new SearchAction('')); // empty query resets results/ran/error in the service
44
45
  }
45
46
  function runFilter(ui, filters, text) {
46
47
  clearTimeout(searchTimer);
47
- searchTimer = setTimeout(() => ui.go(new FilterAction(filters, text)), 200);
48
+ searchTimer = setTimeout(() => ui.engine.dispatch(new FilterAction(filters, text)), 200);
48
49
  }
49
50
 
50
51
  export default function launcher(state, ui, opts = {}) {
51
- const wb = ui.platform.workbench;
52
52
  const modal = !!opts.modal; // rendered as the double-shift overlay → picks reset the stack
53
53
  const q = state.wb.launch.query;
54
- const mode = q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
55
-
56
- const groups = buildContent(state, ui, q, mode, modal);
54
+ // What is on screen, which view draws it, and whether a search-help offer applies — all
55
+ // decided by the `launcherContent` query. This used to be ~100 lines of derivation right
56
+ // here: which nodes, what each heading says, whether "All items" is a claim the drive
57
+ // cannot support. See bl/launcher.js.
58
+ const content = modal ? state.modalContent : state.content;
59
+ const { groups, mode, view } = content;
57
60
  const flat = groups.flatMap((g) => g.items);
58
61
  const idx = flat.length ? Math.min(state.wb.launch.index, flat.length - 1) : 0;
59
62
 
60
63
  const onInput = (e) => {
61
64
  const v = e.target.value;
62
- wb.setLaunchQuery(v);
65
+ ui.engine.dispatch(new SetLaunchQueryAction(v));
63
66
  if (v.startsWith('!')) return; // command mode: no query dispatch
64
67
  const { text, filters } = parseTagQuery(v);
65
68
  if (filters.length) runFilter(ui, filters, text); // drive-wide tag/property query
@@ -75,20 +78,15 @@ export default function launcher(state, ui, opts = {}) {
75
78
  // move re-renders the list under the pointer, and a row replaced between mousedown and
76
79
  // mouseup never receives its click. A pointer user acts through the row's own menu,
77
80
  // which selects explicitly before it opens.
78
- const hoverAt = (at) => wb.setLaunchIndex(at);
79
- const selectAt = (at) => { wb.setLaunchIndex(at); syncSelection(ui, flat[at]); };
80
- const move = (delta) => {
81
- wb.moveLaunch(delta, flat.length);
82
- syncSelection(ui, flat[wb.state.launch.index]);
83
- };
84
- // How the results are drawn right now. The view gets a say in what an arrow key
85
- // means one row down is one tile down in a grid, not one tile across — but the
86
- // index, the selection and the wrapping stay here, so up and down mean the same
87
- // thing whichever view is showing.
88
- //
89
- // The search transformer may have suggested one. Only for an actual search: on the
90
- // home list there is no sentence to have read.
91
- const view = activeView(ui.platform, flat, mode === 'search' ? state.se.resolved?.view : null);
81
+ const hoverAt = (at) => ui.engine.dispatch(new SetLaunchIndexAction(at));
82
+ // Moving the highlight and selecting what it lands on are one action, not two. The index
83
+ // wraps, so only the store can say where a move ends up and reading that back out here
84
+ // would read it before the dispatch had applied. See MoveLaunchAction.
85
+ const selectAt = (at) => ui.engine.dispatch(new SelectLaunchAction(at, flat[at]?.node));
86
+ const move = (delta) => ui.engine.dispatch(new MoveLaunchAction(delta, flat.map((i) => i.node)));
87
+ // The view gets a say in what an arrow key means — one row down is one tile down in a
88
+ // grid, not one tile across but the index, the selection and the wrapping stay here, so
89
+ // up and down mean the same thing whichever view is showing.
92
90
  const onKey = (e) => {
93
91
  // `textual`: there is text in the box, so left/right are the caret's and no view
94
92
  // may claim them. Fixing a typo must not move the highlight.
@@ -98,7 +96,7 @@ export default function launcher(state, ui, opts = {}) {
98
96
  if (claimed !== null) { e.preventDefault(); move(claimed); }
99
97
  else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
100
98
  else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
101
- else if (e.key === 'Enter') { e.preventDefault(); flat[idx]?.run(); }
99
+ else if (e.key === 'Enter') { e.preventDefault(); activate(ui, flat[idx]); }
102
100
  else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui); }
103
101
  // The row under the highlight is the subject of the row menu, so the key that opens
104
102
  // one on every other list opens this one too — without making the user leave the
@@ -123,23 +121,23 @@ export default function launcher(state, ui, opts = {}) {
123
121
  // `false`, not `'false'`: this is set as a property, and the string is truthy —
124
122
  // so the search box had spellcheck ON, red-underlining every filename typed into it.
125
123
  input({ className: 'launch-input', value: q, autofocus: true, spellcheck: false,
126
- placeholder: promptFor(ui, { compact: state.vp?.mode === 'phone', modal }) })
124
+ placeholder: promptFor(state.caps, { compact: state.vp?.mode === 'phone', modal }) })
127
125
  .on({ input: onInput, keydown: onKey }),
128
126
  q ? button({ className: 'launch-clear', title: 'Clear' }, icon('close', { size: 14 }))
129
127
  .on({ click: () => clearSearch(ui) }) : null,
130
128
  // Only where the browser can transcribe WITHOUT sending audio anywhere. A remote's
131
129
  // own mic needs no button from us — it dictates into this field once it is focused,
132
130
  // which is what `search.voice` is really for.
133
- ui.platform.voice?.canListen()
131
+ state.voice?.canListen
134
132
  ? button({
135
133
  className: `launch-mic ${state.voice?.listening ? 'on' : ''}`,
136
134
  title: state.voice?.listening ? 'Stop listening' : 'Search by voice',
137
135
  'aria-pressed': state.voice?.listening ? 'true' : 'false',
138
- }, icon('mic', { size: 15 })).on({ click: () => ui.exec('search.voice') })
136
+ }, icon('mic', { size: 15 })).on({ click: () => ui.engine.dispatch(new ExecCommandAction('search.voice')) })
139
137
  : null,
140
138
  // Which way to look at the drive. Not in the modal search, where the answer is
141
139
  // always "the one thing I am about to press Enter on".
142
- modal ? null : viewSwitcher(ui.platform, view),
140
+ modal ? null : viewSwitcher(state.views, view, ui),
143
141
  ),
144
142
  showResolved ? resolvedBar(resolved) : null,
145
143
  // The results belong to the active view — see ui/components/views. The launcher says
@@ -147,7 +145,7 @@ export default function launcher(state, ui, opts = {}) {
147
145
  // split is why a gallery is a contribution rather than another branch in here.
148
146
  div({ className: 'launch-body' },
149
147
  renderView(view, { groups, index: idx, handlers: { hover: hoverAt, select: selectAt }, state, ui }),
150
- searchHelp(state, ui, mode),
148
+ searchHelp(content.help, ui),
151
149
  ),
152
150
  );
153
151
  return modal ? inner : div({ className: 'editor' }, inner);
@@ -169,10 +167,6 @@ function resolvedBar(r) {
169
167
  // The NODE goes along with the id. A search result or a recent can be from a collection
170
168
  // that isn't loaded — or a page that isn't — so an id alone is something the explorer
171
169
  // cannot resolve, and the commands that act on "the selection" then do nothing at all.
172
- function syncSelection(ui, item) {
173
- const node = item?.node;
174
- ui.app.explorer.select(node?.id ? [node.id] : [], { nodes: node ? [node] : null });
175
- }
176
170
 
177
171
  /**
178
172
  * How this search box works — shown when, and only when, a search came back empty.
@@ -185,208 +179,28 @@ function syncSelection(ui, item) {
185
179
  * The text comes from the server's transformer, so it describes the grammar this
186
180
  * deployment actually runs rather than the one this file was written against.
187
181
  */
188
- function searchHelp(state, ui, mode) {
189
- if (mode === 'command') return null;
190
- const se = state.se;
191
- if (!se.ran || se.loading || se.error || (se.results || []).length) return null;
192
- const p = ui.platform.capabilities?.searchPrompt;
193
- if (!p?.hint && !p?.examples?.length) return null;
194
- const wb = ui.platform.workbench;
195
- const tryIt = (query) => () => {
196
- wb.setLaunchQuery(query);
197
- const { text, filters } = parseTagQuery(query);
198
- if (filters.length) ui.go(new FilterAction(filters, text));
199
- else ui.go(new SearchAction(text));
200
- };
182
+ // Decided by the `launcherContent` query — whether an offer applies, and what the server
183
+ // suggests. This only draws it.
184
+ function searchHelp(help, ui) {
185
+ if (!help) return null;
201
186
  return div({ className: 'launch-help' },
202
- p.hint ? div({ className: 'lh-hint' }, icon('info', { size: 13 }), span(p.hint)) : null,
203
- p.examples?.length
204
- ? div({ className: 'lh-examples' }, ...p.examples.map((ex) =>
187
+ help.hint ? div({ className: 'lh-hint' }, icon('info', { size: 13 }), span(help.hint)) : null,
188
+ help.examples.length
189
+ ? div({ className: 'lh-examples' }, ...help.examples.map((ex) =>
205
190
  button({ className: 'lh-example', title: ex.label || 'Try this search' },
206
191
  span({ className: 'lh-q' }, ex.query),
207
192
  ex.label ? span({ className: 'lh-label' }, ex.label) : null,
208
- ).on({ click: tryIt(ex.query) })))
193
+ ).on({ click: () => activate(ui, ex) })))
209
194
  : null,
210
195
  );
211
196
  }
212
197
 
213
- function buildContent(state, ui, q, mode, modal) {
214
- const done = () => { if (modal) ui.platform.workbench.closeSearchModal(); };
215
- if (mode === 'command') {
216
- const term = q.slice(1).trim().toLowerCase();
217
- const items = ui.platform.commands.paletteCommands()
218
- .map((c) => ({ hay: `${c.category || ''} ${c.title}`.toLowerCase(), c }))
219
- .map(({ hay, c }) => ({ s: score(hay, term), c }))
220
- .filter((x) => term === '' || x.s > 0)
221
- .sort((a, b) => b.s - a.s)
222
- .slice(0, 40)
223
- .map(({ c }) => ({ icon: 'command', title: c.title, detail: c.category, badge: 'command', run: () => { ui.exec(c.id); done(); } }));
224
- return [{ title: 'Commands', items, empty: 'No matching commands.' }];
225
- }
226
198
 
227
- const { text, filters } = parseTagQuery(q);
228
- const nodes = (state.se.results || []).map((r) => r.node);
229
- const err = state.se.error;
230
199
 
231
- // A one-click retry so a transient search/filter failure isn't a dead end.
232
- const retryBtn = (action) => button({ className: 'launch-up', title: 'Retry' }, icon('refresh', { size: 13 }), 'Retry').on({ click: () => ui.go(action) });
233
-
234
- // Drive-wide tag/property filter (server-side), optionally narrowed by free text.
235
- if (filters.length) {
236
- const label = filters.map(filterLabel).join(' ') + (text.trim() ? ` · "${text.trim()}"` : '');
237
- return [{
238
- title: state.se.loading ? 'Filtering…' : err ? 'Filter failed' : 'Filtered',
239
- // Shown as typed: a tag is a value, not a heading.
240
- verbatim: state.se.loading || err ? null : label,
241
- action: err ? retryBtn(new FilterAction(filters, text)) : null,
242
- items: nodes.map((n) => fileItem(n, ui, modal)),
243
- empty: state.se.loading ? 'Filtering…' : err ? `Couldn’t filter: ${err}` : 'No files match those filters.',
244
- }];
245
- }
246
-
247
- // Free-text search.
248
- if (text.trim()) {
249
- return [{
250
- title: state.se.loading ? 'Searching…' : err ? 'Search failed' : 'Results',
251
- action: err ? retryBtn(new SearchAction(q)) : null,
252
- items: nodes.map((n) => fileItem(n, ui, modal)),
253
- empty: state.se.loading ? 'Searching…' : err ? `Couldn’t search: ${err}` : 'No files match.',
254
- }];
255
- }
256
-
257
- // Home: recents, then everything in the collection. There is nothing to descend
258
- // into — this is the "show me everything" fallback for when search isn't the answer.
259
- const groups = [];
260
- const recents = (state.nav.recents || []).map((r) => fileItem(r, ui, modal));
261
- if (recents.length) groups.push({ title: 'Recent', items: recents });
262
-
263
- const ex = state.ex;
264
- const shown = (ex.items || []).length;
265
- // `stats` is the collection; `items` is the page. When the server didn't report stats
266
- // we only know the page — and saying "500" while a next page exists is a claim about
267
- // the drive that is false, so say "500+" instead.
268
- const knownTotal = ex.stats?.items ?? null;
269
- const total = knownTotal ?? shown;
270
- const totalLabel = knownTotal != null ? knownTotal.toLocaleString() : `${shown.toLocaleString()}+`;
271
- const items = (ex.items || []).map((n) => fileItem(n, ui, modal));
272
- // A partial list must not be titled "All items" — that is a claim about the drive,
273
- // and on a collection bigger than one page it is false. Say what is on screen, and
274
- // offer the rest rather than leaving it unreachable.
275
- if (ex.nextCursor) {
276
- items.push({
277
- icon: 'refresh',
278
- title: ex.loadingMore ? 'Loading…'
279
- : knownTotal != null ? `Show more (${(knownTotal - shown).toLocaleString()} more)` : 'Show more',
280
- detail: 'or search to jump straight to something',
281
- run: () => ui.exec('explorer.loadMore'),
282
- });
283
- }
284
- // The trash, when it has been opened. Not shown by default: it is a place you go to
285
- // recover a mistake, not part of browsing the drive.
286
- if (ex.trash) {
287
- groups.push({
288
- // One line, not two: a header reading "0 items" above a body reading "the trash is
289
- // empty" is the same sentence twice, permanently parked above the drive.
290
- title: ex.trash.length
291
- ? `Trash · ${ex.trash.length} item${ex.trash.length === 1 ? '' : 's'}`
292
- : 'Trash',
293
- action: div({ className: 'lh-actions' },
294
- ex.trash.length
295
- ? button({ className: 'launch-up', title: 'Destroy everything in the trash' },
296
- icon('trash', { size: 13 }), 'Empty trash').on({ click: () => ui.exec('explorer.emptyTrash') })
297
- : null,
298
- // The way back out. Opening the trash used to be one-way until a page reload.
299
- button({ className: 'launch-up', title: 'Hide the trash' },
300
- icon('close', { size: 13 }), 'Close').on({ click: () => ui.exec('explorer.hideTrash') }),
301
- ),
302
- items: ex.trash.length
303
- ? ex.trash.map((n) => ({
304
- icon: 'trash',
305
- title: n.name,
306
- detail: `deleted ${new Date(n.deletedAt).toLocaleString()} — restore`,
307
- run: () => ui.exec('explorer.restore', n.id),
308
- menu: () => trashMenu(n, ui),
309
- }))
310
- : [],
311
- empty: 'The trash is empty.',
312
- });
313
- }
314
-
315
- groups.push({
316
- title: ex.nextCursor ? `All items · showing ${shown.toLocaleString()} of ${totalLabel}` : 'All items',
317
- // The empty state told people to upload a file, and on desktop and TV there was
318
- // nothing anywhere that would let them. (Phone has the + in its bottom bar.)
319
- action: modal ? null : button({ className: 'launch-up', title: 'Upload files to this collection' },
320
- icon('upload', { size: 13 }), 'Upload').on({ click: () => ui.exec('explorer.upload') }),
321
- items,
322
- // Don't show a false "empty" when the load actually FAILED (e.g. server
323
- // unreachable) — say so, so the user knows to retry rather than believing the
324
- // collection is empty. (A toast also fires, but the persistent state must be honest.)
325
- empty: ex.loading ? 'Loading…' : ex.error ? `Couldn’t load this collection: ${ex.error}` : 'Nothing here yet — upload a file to get started.',
326
- });
327
- return groups;
328
- }
329
-
330
- function fileItem(node, ui, modal) {
331
- return {
332
- icon: fileIcon(node),
333
- title: node.name,
334
- detail: node.contentType || '',
335
- node,
336
- // From the modal search, `reset` starts a fresh viewer stack; then close it.
337
- run: () => {
338
- ui.go(new OpenFileAction(node, { reset: !!modal }));
339
- if (modal) ui.platform.workbench.closeSearchModal();
340
- },
341
- menu: () => fileMenu(node, ui),
342
- };
343
- }
344
-
345
- function fileMenu(node, ui) {
346
- const pinned = (ui.app.offline?.state?.pins || []).some((p) => p.id === node.id);
347
- // Ask what the shortcut actually IS. Hardcoding "⌘⇧L" told a Windows or Linux user
348
- // about a key their machine does not have, and told everyone the default even after
349
- // they had rebound it.
350
- const kbd = (id) => ui.platform.keybindings.labelFor(id) || undefined;
351
- return [
352
- { label: 'Open', icon: 'file-text', run: () => ui.exec('explorer.open', node) },
353
- { label: 'Download', icon: 'download', run: () => ui.exec('explorer.download', node) },
354
- { label: 'Copy link', icon: 'link', kbd: kbd('explorer.copyLink'), run: () => ui.exec('explorer.copyLink') },
355
- { sep: true },
356
- { label: 'Rename…', run: () => ui.exec('explorer.rename') },
357
- pinned
358
- ? { label: 'Remove from offline', icon: 'close', run: () => ui.exec('offline.unpin', node) }
359
- : { label: 'Make available offline', icon: 'download', run: () => ui.exec('offline.pin', node) },
360
- { sep: true },
361
- { label: 'Move to trash', icon: 'trash', danger: true, kbd: kbd('explorer.delete'), run: () => ui.exec('explorer.delete') },
362
- ];
363
- }
364
200
 
365
201
  // The trash is the one place "delete" means destroy, so its two verbs live together:
366
202
  // put it back, or finish the job on this one item. Emptying the whole trash was
367
203
  // previously the only way to purge anything.
368
- function trashMenu(node, ui) {
369
- return [
370
- { label: 'Restore', icon: 'refresh', run: () => ui.exec('explorer.restore', node.id) },
371
- { sep: true },
372
- { label: 'Delete forever', icon: 'trash', danger: true, run: () => ui.exec('explorer.purgeOne', node.id) },
373
- ];
374
- }
375
204
 
376
- function fileIcon(node) {
377
- const t = node.contentType || '';
378
- if (t.startsWith('image/')) return 'file-image';
379
- if (t.startsWith('audio/')) return 'file-audio';
380
- if (t.startsWith('video/')) return 'file-video';
381
- return 'file-text';
382
- }
383
205
 
384
206
  // Substring-first, then subsequence fuzzy score (0 = no match).
385
- function score(hay, term) {
386
- if (!term) return 1;
387
- const i = hay.indexOf(term);
388
- if (i >= 0) return 1000 - i;
389
- let ti = 0;
390
- for (let hi = 0; hi < hay.length && ti < term.length; hi++) if (hay[hi] === term[ti]) ti++;
391
- return ti === term.length ? 1 : 0;
392
- }
@@ -2,11 +2,14 @@
2
2
  // Each declares a selector (extensions / mime) and a component(node, ui) → vnode.
3
3
  // The editor area picks the highest-priority opener whose selector matches.
4
4
 
5
- import { dd, cell, fromAsync, watch } from '../../../runtime.js';
5
+ import { cell, dd, watch } from '../../../runtime.js';
6
6
  import { icon } from '../../icon.js';
7
7
  import { bytes } from '../../format.js';
8
8
  import { markdownOpener } from './markdown.js';
9
9
  import { attachMedia } from '../../media.js';
10
+ import { FileText } from '../../../bl/queries.js';
11
+ import { watchQuery } from '../../../bl/watchQuery.js';
12
+ import { ExecCommandAction } from '../../../bl/actions.js';
10
13
 
11
14
  const { div, pre, img, span, button, video, audio } = dd;
12
15
 
@@ -77,9 +80,9 @@ export function renderOpener(node, openerId, ui) {
77
80
  const TEXT_VIEW_BYTES = 512 * 1024;
78
81
 
79
82
  function textOpener(node, ui) {
80
- const src = fromAsync(() => ui.platform.api.readTextCapped(node.id, { maxBytes: TEXT_VIEW_BYTES, size: node.size }));
83
+ const src = watchQuery(ui.engine, FileText.of(node.id, TEXT_VIEW_BYTES, node.size));
81
84
  return dd.alias(() =>
82
- ui.platform.reactive.watch(
85
+ ui.watch(
83
86
  src,
84
87
  ({ text, truncated, total }) => div({ className: 'viewer text' },
85
88
  pre(text),
@@ -162,7 +165,7 @@ function fallbackOpener(node, ui, reason) {
162
165
  span({ $styling: { color: 'var(--text-faint)', 'max-width': '340px' } },
163
166
  reason || 'No preview available for this file type. Install a plugin that handles it, or download the file.'),
164
167
  button({ className: 'btn primary' }, icon('download', { size: 15 }), 'Download')
165
- .on({ click: () => ui.exec('explorer.download', node) }),
168
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('explorer.download', node)) }),
166
169
  ),
167
170
  );
168
171
  }
@@ -18,19 +18,21 @@
18
18
  // document length and the node count are capped — a truncated document with a visible
19
19
  // notice beats a hung one.
20
20
 
21
- import { dd, fromAsync } from '../../../runtime.js';
21
+ import { dd, mapCell } from '../../../runtime.js';
22
22
  import { icon } from '../../icon.js';
23
23
  import { parseTroveUri } from '@3sln/trove/core/links.js';
24
24
  import { openTroveLink } from '../../../bl/links.js';
25
+ import { FileText } from '../../../bl/queries.js';
26
+ import { watchQuery } from '../../../bl/watchQuery.js';
25
27
 
26
28
  const { div, span, h1, h2, h3, p, ul, ol, li, pre, code, a, em, strong, blockquote, hr, br } = dd;
27
29
 
28
30
  export function markdownOpener(node, ui) {
29
31
  // Bounded at the TRANSFER, not just the render: MAX_CHARS below stops us laying out a
30
32
  // huge document, but without this the whole file is still pulled into the tab first.
31
- const src = fromAsync(() => ui.platform.api.readTextCapped(node.id, { maxBytes: MAX_CHARS, size: node.size }).then((r) => r.text));
33
+ const src = mapCell(watchQuery(ui.engine, FileText.of(node.id, MAX_CHARS, node.size)), (r) => r.text);
32
34
  return dd.alias(() =>
33
- ui.platform.reactive.watch(
35
+ ui.watch(
34
36
  src,
35
37
  // A throw in here is NOT covered by the `error:` handler below — that one is for
36
38
  // the observable failing, not for the mapper. Rendering untrusted content is