@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
@@ -5,64 +5,160 @@
5
5
  // dispatch follow-up Actions (the choreographer pattern).
6
6
 
7
7
  import { Engine, Provider } from '@3sln/ngin';
8
- import { effect } from '../runtime.js';
9
- import { ExplorerService, SearchClientService, TransfersService, ApiKeysService } from './services.js';
8
+ import { cell } from '../runtime.js';
9
+ import { registerCoreContext, registerViewportContext } from './context.js';
10
+ import { TransfersService } from './services.js';
11
+ import { explorerState, searchState, apiKeysState, viewState as viewStateSlice, overlayState, workbenchState, rotationState } from './state.js';
12
+ import { NavigationService } from '../platform/navigation.js';
10
13
  import { SocialService } from './social.js';
11
14
  import { OfflineService } from './offline.js';
12
15
  import { ActivityService } from './activity.js';
13
16
  import { registerCommands } from './commands.js';
14
- import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction } from './actions.js';
17
+ import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction, OpenPluginPanelAction } from './actions.js';
15
18
 
16
19
  export function createApp(platform) {
17
- const explorer = new ExplorerService(platform.settings);
18
- const search = new SearchClientService();
19
- const apiKeys = new ApiKeysService();
20
+ // Named slices, not services — see bl/state.js. Each is still its own provider, so a
21
+ // lease naming `explorer` still says what it touches.
22
+ const explorer = explorerState(platform.settings);
23
+ const search = searchState();
24
+ const apiKeys = apiKeysState();
25
+ const rotation = rotationState();
20
26
  // One place for "what's running" and "what's stuck", covering both sides of the wire.
21
27
  const activity = new ActivityService(platform);
28
+ // What the UI is in the middle of doing — see bl/state.js. A resource, because it
29
+ // decides what is on screen.
30
+ const viewState = viewStateSlice();
31
+ // The shell, split into what it actually is. `workbench` and `overlay` are slices; the
32
+ // panel stack stays a service, because it mirrors itself into browser history and
33
+ // persists recents — neither of which a state bag can do. The WorkbenchService that used
34
+ // to sit over all three was a 17-method delegation facade, and the provider graph could
35
+ // not see through it: closing a dialog leased the whole shell.
36
+ const workbench = workbenchState();
37
+ const overlay = overlayState();
38
+ const navigation = new NavigationService();
22
39
  const transfers = new TransfersService(activity);
23
- const social = new SocialService(platform);
40
+ // Offline first: social queues comment and tag writes through it while disconnected, so
41
+ // it is a dependency rather than something bolted on afterwards. It used to be assigned
42
+ // onto `social` after both existed, which reads as optional and is not — a social service
43
+ // built without it silently drops every offline write.
24
44
  const offline = new OfflineService(platform);
25
- social.offline = offline; // social queues sidecar ops through offline when disconnected
45
+ const social = new SocialService(platform, offline);
26
46
 
27
- const app = { platform, explorer, search, transfers, social, offline, activity, apiKeys, engine: null };
47
+ const app = { platform, explorer, search, transfers, social, offline, activity, apiKeys, rotation, workbench, overlay, navigation, engine: null };
28
48
 
49
+ // Every resource the engine has, named. The engine's STATE is the state of its
50
+ // resources, so the single `app` provider this started with made that state one opaque
51
+ // blob: every query and every action leased the whole world, and a lease that always
52
+ // covers everything tells you nothing about what a piece of work touches or how long it
53
+ // needs it.
54
+ //
55
+ // `app` is gone. It survived longest as the thing command handlers closed over, and went
56
+ // when they stopped being closures — see bl/commands.js. Every lease now names what it
57
+ // touches, and `static deps = ['explorer']` means what it says.
29
58
  const engine = new Engine({
30
- providers: { app: Provider.fromSingleton(app) },
31
- });
32
- app.engine = engine;
59
+ providers: {
60
+ // Where a live query keeps its subscription — see bl/queries.js. A provider, so it
61
+ // is per engine; keyed by the query instance, so an entry belongs to exactly one
62
+ // realization. A query instance is shared (it is interned), so it is the wrong place
63
+ // to put anything that is true only while it is being watched.
64
+ appState: Provider.fromSingleton(new Map()),
33
65
 
34
- registerCommands(app);
66
+ // The drive.
67
+ explorer: Provider.fromSingleton(explorer),
68
+ search: Provider.fromSingleton(search),
69
+ transfers: Provider.fromSingleton(transfers),
70
+ social: Provider.fromSingleton(social),
71
+ offline: Provider.fromSingleton(offline),
72
+ activity: Provider.fromSingleton(activity),
73
+ apiKeys: Provider.fromSingleton(apiKeys),
74
+ rotation: Provider.fromSingleton(rotation),
75
+ viewState: Provider.fromSingleton(viewState),
35
76
 
36
- // Wire the plugin panel opener hook to the workbench.
37
- platform.openPluginPanel = (pluginId) => platform.workbench.openPluginPanel(pluginId);
77
+ // The engine itself, for actions that choreograph dispatching a follow-up rather
78
+ // than doing the follow-up's work inline. LAZY because the engine does not exist
79
+ // when its own providers are declared; by the time anything leases this, it does.
80
+ // ngin's execute context carries a dispatchFeed but no dispatcher, so this is the
81
+ // seam for it.
82
+ engine: Provider.fromLazySingleton(async () => engine, () => {}),
38
83
 
39
- // Project explorer state → context keys in ONE place. These drive when-clauses
40
- // (e.g. the Delete keybinding needs `explorer.hasSelection`), and previously only
41
- // NavigateAction set them — so selecting a file never flipped hasSelection true and
42
- // Delete silently did nothing. Deriving them from the observable keeps them honest.
43
- //
44
- // `null` when no collection is open, never the string 'default'. A when-clause reading
45
- // this is asking which collection is open, and answering with the name of one that may
46
- // not exist made every such clause true before the user had chosen anything.
47
- platform.context.setMany({ 'explorer.collectionId': null, 'explorer.hasSelection': false });
48
- effect(explorer.observe(), (ex) => {
49
- platform.context.setMany({
50
- 'explorer.collectionId': ex.collectionId ?? null,
51
- 'explorer.hasSelection': (ex.selection?.length || 0) > 0,
52
- });
53
- });
84
+ // The shell.
85
+ workbench: Provider.fromSingleton(workbench),
86
+ overlay: Provider.fromSingleton(overlay),
87
+ navigation: Provider.fromSingleton(navigation),
88
+ settings: Provider.fromSingleton(platform.settings),
89
+ notifications: Provider.fromSingleton(platform.notifications),
90
+ context: Provider.fromSingleton(platform.context),
91
+ commands: Provider.fromSingleton(platform.commands),
92
+ keybindings: Provider.fromSingleton(platform.keybindings),
93
+ contributions: Provider.fromSingleton(platform.contributions),
94
+ viewport: Provider.fromSingleton(platform.viewport),
95
+ voice: Provider.fromSingleton(platform.voice),
96
+
97
+ // The outside world.
98
+ api: Provider.fromSingleton(platform.api),
54
99
 
55
- // Load a file's conversation/tags whenever the active viewer panel changes (the
56
- // panel stack lives in the navigation sub-service now).
57
- let lastTab = null;
58
- effect(platform.workbench.observeNav(), (nav) => {
59
- if (nav.activeTabId !== lastTab) {
60
- lastTab = nav.activeTabId;
61
- // Clear the sidecar when no file is active (last tab closed) so a stale
62
- // conversation from the previous file doesn't linger.
63
- social.loadSidecar(nav.activeFile ? nav.activeFile.id : null);
64
- }
100
+ // What this deployment can do: which storage drivers it offers, whether it can
101
+ // suggest searches, how transfers reach the bucket.
102
+ //
103
+ // A PROVIDER, not an action and not a query holding a promise. Nobody asks for
104
+ // capabilities they are ambient facts other things consult in order to decide, so
105
+ // there is no intent to dispatch and nothing to put on the feed. It was a query that
106
+ // memoised `api.capabilities()` on its own instance to avoid re-fetching, which is a
107
+ // cache with no invalidation living inside a view.
108
+ //
109
+ // It provides a CELL rather than the value, and the fetch is deliberately not
110
+ // awaited. Awaiting would make every lease of this block until the server answered,
111
+ // so a query that merely consults capabilities would be PENDING and the shell would
112
+ // render nothing until the round trip finished. The cell starts null — "not known
113
+ // yet", which every reader already treats correctly — and fills in, so anything
114
+ // watching simply re-projects when the answer lands.
115
+ //
116
+ // One fetch for the life of the page, because `fromLazySingleton` memoises on the
117
+ // creation promise. That is the provider's job, which is where it belongs.
118
+ // Closes over `platform.api` rather than declaring `{ deps: ['api'] }`: a provider's
119
+ // deps arrive as the PROVIDER INSTANCES, not as resources, so the dependency form
120
+ // would hand over something you must `obtain()` and release. Every other provider
121
+ // here closes over what it needs, and this is the same client either way.
122
+ capabilities: Provider.fromLazySingleton(
123
+ async () => {
124
+ const held = cell(null);
125
+ platform.api.capabilities().then(
126
+ (caps) => held.setValue(caps),
127
+ // A drive whose capabilities cannot be read still works; every reader treats
128
+ // null as "not known" and falls back. Failing the provider would take the
129
+ // whole shell down over an optional answer.
130
+ () => {},
131
+ );
132
+ return held;
133
+ },
134
+ () => {},
135
+ ),
136
+ mediaUrls: Provider.fromSingleton(platform.mediaUrls),
137
+ plugins: Provider.fromSingleton(platform.plugins),
138
+ },
65
139
  });
140
+ app.engine = engine;
141
+
142
+ // How a command reaches the engine. A command resolves to actions and the CommandService
143
+ // dispatches them; it is built with the platform, before the engine exists, so the
144
+ // dispatcher is handed over here.
145
+ //
146
+ // This is the boundary, not a shortcut: a keystroke and a plugin's RPC both originate
147
+ // outside the engine, and one of them has to carry the intent in. Everything past this
148
+ // point is an action on the feed.
149
+ platform.commands.dispatch = (action) => engine.dispatch(action);
150
+ // The same seam for the two other places that originate outside the engine: a docked
151
+ // plugin frame opening a file, and the plugin RPC asking for its panel.
152
+ platform.dispatch = (action) => engine.dispatch(action);
153
+ platform.openPluginPanel = (pluginId) => engine.dispatch(new OpenPluginPanelAction(pluginId));
154
+
155
+ registerCommands(app);
156
+
157
+ // Every built-in when-clause key, derived from the resource it summarises. Registered
158
+ // here because this is where those resources exist; see bl/context.js for the list and
159
+ // for why deriving them is not the same as moving the writes.
160
+ registerCoreContext(platform.context, { workbench, overlay, navigation, explorer });
161
+ registerViewportContext(platform.context, platform.viewport);
66
162
 
67
163
  social.init();
68
164
  offline.init();
@@ -0,0 +1,129 @@
1
+ // One instance per logical query.
2
+ //
3
+ // ngin shares a live query realization by INSTANCE identity — its controller map is keyed on
4
+ // the object, and neither the class nor the fields are consulted. That is fine for a query
5
+ // with no arguments, which can be a module singleton. It is a trap for a parameterised one:
6
+ // `new MediaUrl('n1')` in two places is two realizations of the same question, so two minted
7
+ // URLs and two leases for one file, and nothing fails to say so.
8
+ //
9
+ // So a parameterised query declares a shared factory and is asked for, not constructed:
10
+ //
11
+ // class MediaUrl extends ViewQuery {
12
+ // static of = queryOf(MediaUrl);
13
+ // constructor(nodeId) { ... }
14
+ // }
15
+ //
16
+ // MediaUrl.of('n1') // same id, same instance, one realization
17
+ //
18
+ // A field holding a factory rather than a base class or a mixin, which is what makes this
19
+ // small: the query keeps whatever base it already had, each factory closes over its own
20
+ // table, and there is no name resolved through a prototype chain for anything to shadow.
21
+
22
+ /**
23
+ * Eviction, without a policy to get wrong.
24
+ *
25
+ * Entries are weak, and a finalizer sweeps the key once the instance is collected. That is
26
+ * sound because LIVENESS PINS THE INSTANCE: ngin's controller map is a plain Map holding the
27
+ * query as a key, deleted only on teardown, so a query with observers is strongly reachable
28
+ * and cannot be collected. An idle one can be, and re-asking simply builds a fresh instance.
29
+ *
30
+ * The alternative — an LRU with a cap — is actively wrong here: evicting a LIVE entry means
31
+ * the next `of()` mints a second instance while the first is still running, which is the
32
+ * exact bug this exists to prevent, arriving on a timer.
33
+ *
34
+ * (This leans on ngin's default controller map being strong. `hooks.createQueryControllersMap`
35
+ * could replace it with a weak one; we do not, and a weak one would break this.)
36
+ */
37
+ const sweeper = new FinalizationRegistry(({ table, key }) => {
38
+ // Only if it is still the dead entry — a fresh instance may have claimed the key already.
39
+ if (table.get(key) && !table.get(key).deref()) table.delete(key);
40
+ });
41
+
42
+ /**
43
+ * Reject anything a canonical key cannot capture.
44
+ *
45
+ * A function stringifies to `undefined` in JSON, so two queries taking different callbacks
46
+ * would key identically and silently share one realization — the very bug this exists to
47
+ * prevent, reintroduced by the fix. Fail at the call site instead. A query with arguments
48
+ * like that passes its own key function and never comes through here.
49
+ */
50
+ function assertKeyable(value, path) {
51
+ const t = typeof value;
52
+ if (value === null || t === 'string' || t === 'number' || t === 'boolean') return;
53
+ if (t !== 'object') {
54
+ throw new TypeError(
55
+ `Query argument ${path} is a ${t}, which cannot be part of a sharing key. ` +
56
+ 'Pass an id and let the query look the thing up, or give queryOf a key function.',
57
+ );
58
+ }
59
+ if (Array.isArray(value)) return value.forEach((v, i) => assertKeyable(v, `${path}[${i}]`));
60
+ const proto = Object.getPrototypeOf(value);
61
+ if (proto === Object.prototype || proto === null) {
62
+ return Object.entries(value).forEach(([k, v]) => assertKeyable(v, `${path}.${k}`));
63
+ }
64
+ throw new TypeError(
65
+ `Query argument ${path} is a ${value.constructor?.name ?? 'non-plain object'}, which ` +
66
+ 'cannot be part of a sharing key. Pass an id and let the query look the thing up, ' +
67
+ 'or give queryOf a key function.',
68
+ );
69
+ }
70
+
71
+ /** A canonical string for a value: object keys sorted, so `{a,b}` and `{b,a}` are one key. */
72
+ export function stableKey(value) {
73
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
74
+ if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`;
75
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableKey(value[k])}`).join(',')}}`;
76
+ }
77
+
78
+ /** The default key: every argument, in order, canonically. Types count, so 1 and '1' differ. */
79
+ export function keyOfArgs(...args) {
80
+ args.forEach((a, i) => assertKeyable(a, `#${i}`));
81
+ return args.map(stableKey).join('|');
82
+ }
83
+
84
+ /**
85
+ * A factory returning the shared instance of `Class` for a given argument list.
86
+ *
87
+ * class Thing extends ViewQuery { static of = queryOf(Thing); }
88
+ *
89
+ * `key` defaults to the arguments, canonically. Pass one to share more coarsely than the
90
+ * arguments do — an option that changes how something is displayed but not what is fetched
91
+ * should not split one realization into two — or to key arguments the default refuses:
92
+ *
93
+ * static of = queryOf(Thing, (nodeId, opts) => nodeId);
94
+ *
95
+ * A key rather than a hash plus a comparator: a comparator only earns its complexity when
96
+ * keys can collide, and a canonical key does not collide.
97
+ *
98
+ * The class is captured, so a subclass inheriting this field would build the PARENT —
99
+ * `Sub.of('x')` returning a `Base` is the sort of wrong that reads as right. The factory is
100
+ * a plain function rather than an arrow so that `this` is the class it was reached through,
101
+ * and it refuses to run when that is not the class it was built for. A subclass that wants
102
+ * sharing declares its own `static of`.
103
+ *
104
+ * A detached call — `const of = Thing.of; of('x')` — has no receiver at all, and in a module
105
+ * `this` is `undefined` rather than the global. That is the ordinary way to pass the factory
106
+ * around, so it is allowed; only a DIFFERENT class is an error.
107
+ *
108
+ * @param {Function} Class the query class, referenced from inside its own body
109
+ * @param {(...args: any[]) => string} [key]
110
+ */
111
+ export function queryOf(Class, key = keyOfArgs) {
112
+ const table = new Map();
113
+ return function of(...args) {
114
+ if (this !== undefined && this !== Class) {
115
+ throw new TypeError(
116
+ `${this?.name ?? 'A subclass'}.of() would build a ${Class.name}, because \`of\` ` +
117
+ `captures the class it was declared on. Give ${this?.name ?? 'the subclass'} its own ` +
118
+ '`static of = queryOf(...)`, or call it on ' + Class.name + ' directly.',
119
+ );
120
+ }
121
+ const k = key(...args);
122
+ const existing = table.get(k)?.deref();
123
+ if (existing) return existing;
124
+ const instance = new Class(...args);
125
+ table.set(k, new WeakRef(instance));
126
+ sweeper.register(instance, { table, key: k });
127
+ return instance;
128
+ };
129
+ }
@@ -0,0 +1,251 @@
1
+ // What the launcher is showing.
2
+ //
3
+ // This was ~100 lines inside the launcher COMPONENT, deciding — mid-render, where nothing
4
+ // else could see it — which nodes are on screen, what each heading says, whether "All
5
+ // items" is a lie because another page is waiting, and what the empty state should read
6
+ // when a load FAILED as against when the drive is genuinely empty. Every one of those is a
7
+ // question about engine state.
8
+ //
9
+ // It also made a smaller thing impossible: `pickView` needs the nodes on screen, and while
10
+ // they were assembled in a render they looked like render-layer data. They are not — they
11
+ // come from the explorer and search resources. Deriving them here is what lets the choice
12
+ // of view become engine state too.
13
+ //
14
+ // Groups are DATA. A header control is `{ icon, label, title, actions }`, not a button —
15
+ // the launcher used to put a rendered vnode in `group.action`, which is the same
16
+ // carrying-a-handle problem that menu items had before they carried `actions`.
17
+
18
+ import { parseTagQuery, filterLabel } from './tagQuery.js';
19
+ import {
20
+ ExecCommandAction, CloseSearchModalAction, OpenFileAction, SearchAction, FilterAction,
21
+ SetLaunchQueryAction,
22
+ } from './actions.js';
23
+
24
+ /** Which mode the typed query puts the launcher in. */
25
+ export function launcherMode(query) {
26
+ const q = query || '';
27
+ return q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
28
+ }
29
+
30
+ const iconFor = (node) => {
31
+ const t = node?.contentType || '';
32
+ if (t.startsWith('image/')) return 'file-image';
33
+ if (t.startsWith('audio/')) return 'file-audio';
34
+ if (t.startsWith('video/')) return 'file-video';
35
+ return 'file-text';
36
+ };
37
+
38
+ /**
39
+ * Everything you can do to a file, as descriptions.
40
+ *
41
+ * `pinned` and `keys` are resolved by the caller because both are questions about engine
42
+ * state: whether THIS file is already available offline, and what the shortcut for a
43
+ * command actually is on this machine. Hardcoding "⌘⇧L" told a Windows user about a key
44
+ * they do not have, and told everyone the default even after they had rebound it.
45
+ */
46
+ export function fileMenuOf(node, { pinned = false, keys = {} } = {}) {
47
+ const kbd = (id) => keys[id] || undefined;
48
+ return [
49
+ { label: 'Open', icon: 'file-text', actions: [new ExecCommandAction('explorer.open', node)] },
50
+ { label: 'Download', icon: 'download', actions: [new ExecCommandAction('explorer.download', node)] },
51
+ // Labelled by destination rather than by format: one goes in a document, the other
52
+ // goes to a person.
53
+ { label: 'Copy shareable link', icon: 'link', actions: [new ExecCommandAction('explorer.copyShareLink')] },
54
+ { label: 'Copy trove: link', icon: 'link', kbd: kbd('explorer.copyLink'), actions: [new ExecCommandAction('explorer.copyLink')] },
55
+ { sep: true },
56
+ { label: 'Rename…', actions: [new ExecCommandAction('explorer.rename')] },
57
+ pinned
58
+ ? { label: 'Remove from offline', icon: 'close', actions: [new ExecCommandAction('offline.unpin', node)] }
59
+ : { label: 'Make available offline', icon: 'download', actions: [new ExecCommandAction('offline.pin', node)] },
60
+ { sep: true },
61
+ { label: 'Move to trash', icon: 'trash', danger: true, kbd: kbd('explorer.delete'), actions: [new ExecCommandAction('explorer.delete')] },
62
+ ];
63
+ }
64
+
65
+ /** What you can do to something already in the trash. */
66
+ export function trashMenuOf(node) {
67
+ return [
68
+ { label: 'Restore', icon: 'refresh', actions: [new ExecCommandAction('explorer.restore', node.id)] },
69
+ { sep: true },
70
+ { label: 'Delete forever', icon: 'trash', danger: true, actions: [new ExecCommandAction('explorer.purgeOne', node.id)] },
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * One row for a file.
76
+ *
77
+ * `menu` stays a thunk. A drive can have hundreds of rows on screen and almost none of
78
+ * their menus are ever opened, so building them all would be work nobody asked for — and a
79
+ * pure function deferring pure work is data as much as the array it would have returned.
80
+ */
81
+ function fileItem(node, { modal, pinnedIds, keys }) {
82
+ return {
83
+ icon: iconFor(node),
84
+ title: node.name,
85
+ detail: node.contentType || '',
86
+ node,
87
+ // From the modal search, `reset` starts a fresh viewer stack; then close it.
88
+ actions: [
89
+ new OpenFileAction(node, { reset: !!modal }),
90
+ ...(modal ? [new CloseSearchModalAction()] : []),
91
+ ],
92
+ menu: () => fileMenuOf(node, { pinned: pinnedIds.has(node.id), keys }),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * The groups on screen.
98
+ *
99
+ * @param {object} s the slices this reads, already plain
100
+ * @param {boolean} modal whether this is the double-shift overlay rather than the home
101
+ * screen — the one input that is genuinely about which INSTANCE is rendering, since both
102
+ * are on screen at once when the overlay is up
103
+ */
104
+ export function launcherGroupsOf(s, modal = false) {
105
+ const { ex = {}, se = {}, nav = {}, query = '', commandMatches = [], pinnedIds = new Set(), keys = {} } = s;
106
+ const mode = launcherMode(query);
107
+ const row = (node) => fileItem(node, { modal, pinnedIds, keys });
108
+ const closeModal = modal ? [new CloseSearchModalAction()] : [];
109
+
110
+ if (mode === 'command') {
111
+ return [{
112
+ id: 'commands',
113
+ title: 'Commands',
114
+ items: commandMatches.map((c) => ({
115
+ icon: 'command', title: c.title, detail: c.category, badge: 'command',
116
+ actions: [new ExecCommandAction(c.id), ...closeModal],
117
+ })),
118
+ empty: 'No matching commands.',
119
+ }];
120
+ }
121
+
122
+ const { text, filters } = parseTagQuery(query);
123
+ const nodes = (se.results || []).map((r) => r.node);
124
+ const err = se.error;
125
+ // A one-click retry so a transient search/filter failure isn't a dead end.
126
+ const retry = (actions) => (err ? [{ icon: 'refresh', label: 'Retry', title: 'Retry', actions }] : []);
127
+
128
+ // Drive-wide tag/property filter (server-side), optionally narrowed by free text.
129
+ if (filters.length) {
130
+ const label = filters.map(filterLabel).join(' ') + (text.trim() ? ` · "${text.trim()}"` : '');
131
+ return [{
132
+ id: 'filtered',
133
+ title: se.loading ? 'Filtering…' : err ? 'Filter failed' : 'Filtered',
134
+ // Shown as typed: a tag is a value, not a heading.
135
+ verbatim: se.loading || err ? null : label,
136
+ controls: retry([new FilterAction(filters, text)]),
137
+ items: nodes.map(row),
138
+ empty: se.loading ? 'Filtering…' : err ? `Couldn’t filter: ${err}` : 'No files match those filters.',
139
+ }];
140
+ }
141
+
142
+ // Free-text search.
143
+ if (text.trim()) {
144
+ return [{
145
+ id: 'results',
146
+ title: se.loading ? 'Searching…' : err ? 'Search failed' : 'Results',
147
+ controls: retry([new SearchAction(query)]),
148
+ items: nodes.map(row),
149
+ empty: se.loading ? 'Searching…' : err ? `Couldn’t search: ${err}` : 'No files match.',
150
+ }];
151
+ }
152
+
153
+ // Home: recents, then everything in the collection. There is nothing to descend into —
154
+ // this is the "show me everything" fallback for when search isn't the answer.
155
+ const groups = [];
156
+ const recents = (nav.recents || []).map(row);
157
+ if (recents.length) groups.push({ id: 'recent', title: 'Recent', items: recents });
158
+
159
+ const shown = (ex.items || []).length;
160
+ // `stats` is the collection; `items` is the page. When the server didn't report stats we
161
+ // only know the page — and saying "500" while a next page exists is a claim about the
162
+ // drive that is false, so say "500+" instead.
163
+ const knownTotal = ex.stats?.items ?? null;
164
+ const total = knownTotal ?? shown;
165
+ const totalLabel = knownTotal != null ? knownTotal.toLocaleString() : `${shown.toLocaleString()}+`;
166
+ const items = (ex.items || []).map(row);
167
+ // A partial list must not be titled "All items" — that is a claim about the drive, and on
168
+ // a collection bigger than one page it is false. Say what is on screen, and offer the rest
169
+ // rather than leaving it unreachable.
170
+ if (ex.nextCursor) {
171
+ items.push({
172
+ icon: 'refresh',
173
+ title: ex.loadingMore ? 'Loading…'
174
+ : knownTotal != null ? `Show more (${(knownTotal - shown).toLocaleString()} more)` : 'Show more',
175
+ detail: 'or search to jump straight to something',
176
+ actions: [new ExecCommandAction('explorer.loadMore')],
177
+ });
178
+ }
179
+
180
+ // The trash, when it has been opened. Not shown by default: it is a place you go to
181
+ // recover a mistake, not part of browsing the drive.
182
+ if (ex.trash) {
183
+ groups.push({
184
+ id: 'trash',
185
+ // One line, not two: a header reading "0 items" above a body reading "the trash is
186
+ // empty" is the same sentence twice, permanently parked above the drive.
187
+ title: ex.trash.length
188
+ ? `Trash · ${ex.trash.length} item${ex.trash.length === 1 ? '' : 's'}`
189
+ : 'Trash',
190
+ controls: [
191
+ ...(ex.trash.length
192
+ ? [{ icon: 'trash', label: 'Empty trash', title: 'Destroy everything in the trash', actions: [new ExecCommandAction('explorer.emptyTrash')] }]
193
+ : []),
194
+ // The way back out. Opening the trash used to be one-way until a page reload.
195
+ { icon: 'close', label: 'Close', title: 'Hide the trash', actions: [new ExecCommandAction('explorer.hideTrash')] },
196
+ ],
197
+ items: (ex.trash || []).map((n) => ({
198
+ icon: 'trash',
199
+ title: n.name,
200
+ detail: `deleted ${new Date(n.deletedAt).toLocaleString()} — restore`,
201
+ actions: [new ExecCommandAction('explorer.restore', n.id)],
202
+ menu: () => trashMenuOf(n),
203
+ })),
204
+ empty: 'The trash is empty.',
205
+ });
206
+ }
207
+
208
+ groups.push({
209
+ id: 'all',
210
+ title: ex.nextCursor ? `All items · showing ${shown.toLocaleString()} of ${totalLabel}` : 'All items',
211
+ // The empty state told people to upload a file, and on desktop and TV there was nothing
212
+ // anywhere that would let them. (Phone has the + in its bottom bar.)
213
+ controls: modal ? [] : [{ icon: 'upload', label: 'Upload', title: 'Upload files to this collection', actions: [new ExecCommandAction('explorer.upload')] }],
214
+ items,
215
+ // Don't show a false "empty" when the load actually FAILED (e.g. server unreachable) —
216
+ // say so, so the user knows to retry rather than believing the collection is empty. (A
217
+ // toast also fires, but the persistent state must be honest.)
218
+ empty: ex.loading ? 'Loading…' : ex.error ? `Couldn’t load this collection: ${ex.error}` : 'Nothing here yet — upload a file to get started.',
219
+ });
220
+ return groups;
221
+ }
222
+
223
+ /**
224
+ * The suggestions shown when a search came back with nothing.
225
+ *
226
+ * Two halves, both settled by the `launcherContent` query: whether an offer applies at all
227
+ * — a search ran, it succeeded, it found nothing — and what to suggest, which the server
228
+ * decides and reaches the engine through the `capabilities` resource. Each example carries
229
+ * the actions that run it, so the component only draws them.
230
+ */
231
+ export function searchHelpOf({ eligible = false, caps = null } = {}) {
232
+ if (!eligible) return null;
233
+ const p = caps?.searchPrompt;
234
+ if (!p?.hint && !p?.examples?.length) return null;
235
+ return {
236
+ hint: p.hint || null,
237
+ examples: (p.examples || []).map((ex) => {
238
+ const { text, filters } = parseTagQuery(ex.query);
239
+ return {
240
+ query: ex.query,
241
+ label: ex.label || null,
242
+ // Running an example is the same two steps as typing it: put it in the box, then
243
+ // ask whichever of search or filter that spelling means.
244
+ actions: [
245
+ new SetLaunchQueryAction(ex.query),
246
+ filters.length ? new FilterAction(filters, text) : new SearchAction(text),
247
+ ],
248
+ };
249
+ }),
250
+ };
251
+ }
@@ -29,7 +29,7 @@ export async function openTroveLink(ui, uri, { from } = {}) {
29
29
  try {
30
30
  const res = await ui.platform.api.stat(uri);
31
31
  if (!res?.node) throw new Error('not found');
32
- ui.go(new OpenFileAction(res.node));
32
+ ui.engine.dispatch(new OpenFileAction(res.node));
33
33
  return res.node;
34
34
  } catch (err) {
35
35
  notify.warn(describeBrokenLink(ref, err, from));