@3sln/trove 0.0.2

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 (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,410 @@
1
+ // ngin Actions — the workbench's business logic as thin, dispatchable verbs
2
+ // (CQRS-style). Each depends on the `app` provider (platform + reactive
3
+ // services) and performs one user intent: navigate, mutate the tree, upload,
4
+ // search, open a file. Errors surface as notifications; the reactive services
5
+ // they update flow straight back into the UI via `watch`.
6
+
7
+ import { Action } from '@3sln/ngin';
8
+ import { newId } from '@3sln/trove/core/util.js';
9
+ import { matchesTagFilters } from './tagQuery.js';
10
+ import { availableOpeners, rememberedOpenerId } from './openers.js';
11
+
12
+ class AppAction extends Action {
13
+ static deps = ['app'];
14
+ }
15
+
16
+ /**
17
+ * Load a collection's items. There is nothing to navigate INTO any more — a drive is
18
+ * browsed by search and by following links — so this only ever switches which
19
+ * collection is on screen, or refreshes the current one.
20
+ */
21
+ export class NavigateAction extends AppAction {
22
+ constructor(collectionId) {
23
+ super();
24
+ this.collectionId = collectionId;
25
+ }
26
+ async execute({ app }) {
27
+ const { explorer, platform } = app;
28
+ const collectionId = this.collectionId || explorer.state.collectionId || 'default';
29
+ const switching = collectionId !== explorer.state.collectionId;
30
+ // A collection's trash belongs to that collection. Carrying it across a switch
31
+ // listed the OLD collection's deleted files under the new one — above an "Empty
32
+ // trash" button that purges the new one, so the rows and the button disagreed
33
+ // about which drive they were talking about.
34
+ explorer.set({ loading: true, error: null, collectionId, ...(switching ? { trash: null } : {}) });
35
+ try {
36
+ const sort = platform.settings.get('explorer.sort');
37
+ const order = platform.settings.get('explorer.sortOrder');
38
+ const res = await platform.api.list({ sort, order, collection: collectionId });
39
+ explorer.set({
40
+ items: res.items, loading: false, selection: [], sort, order,
41
+ collectionId: res.collectionId || collectionId,
42
+ // What the COLLECTION holds, as against the page we were handed. Reporting the
43
+ // page length would tell someone with 3,000 files that they have 500.
44
+ stats: res.stats || null,
45
+ usage: res.usage || null,
46
+ nextCursor: res.nextCursor || null,
47
+ });
48
+ // Remember where they were, so the next visit opens there rather than guessing.
49
+ platform.settings.set?.('explorer.lastCollection', collectionId);
50
+ // Explorer→context projection (collectionId/hasSelection) lives in bl/index.js
51
+ // so it stays in sync with selection too — nothing to mirror here.
52
+ } catch (err) {
53
+ explorer.set({ loading: false, error: err.message });
54
+ platform.notifications.error(`Couldn't load this collection: ${err.message}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ export class LoadCollectionsAction extends AppAction {
60
+ async execute({ app }) {
61
+ try {
62
+ const res = await app.platform.api.collections();
63
+ const collections = res.collections || [];
64
+ app.explorer.set({ collections, canCreateCollection: !!res.canCreate });
65
+ return collections;
66
+ } catch { /* collections disabled */ return []; }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Open the drive the user should land on.
72
+ *
73
+ * NOT simply 'default'. On a multi-user deployment plenty of people have no access to
74
+ * the default collection at all, and starting there shows them a permission error
75
+ * instead of their own files — the drive appears broken to the exact users for whom it
76
+ * is working correctly. So: keep the last collection if it is still readable, otherwise
77
+ * take the first one they can actually see.
78
+ */
79
+ export class OpenInitialCollectionAction extends AppAction {
80
+ async execute({ app }) {
81
+ // Calls the API directly rather than dispatching LoadCollectionsAction: ngin's
82
+ // dispatch() returns an event feed, not the action's value, so awaiting it would
83
+ // hand back an EventTarget and this would fail in a way nothing reports.
84
+ let collections = [];
85
+ try {
86
+ const res = await app.platform.api.collections();
87
+ collections = res.collections || [];
88
+ app.explorer.set({ collections, canCreateCollection: !!res.canCreate });
89
+ } catch (err) {
90
+ app.explorer.set({ loading: false, error: `Couldn't load your collections: ${err.message}` });
91
+ return;
92
+ }
93
+ const ids = collections.map((c) => c.id);
94
+ const remembered = app.platform.settings.get('explorer.lastCollection');
95
+ if (!ids.length) {
96
+ // A signed-in user with no grants at all. Say so; an empty file list with no
97
+ // explanation reads as "your drive is empty", which is a different and much worse
98
+ // thing to believe.
99
+ app.explorer.set({
100
+ loading: false, items: [], collections: [],
101
+ error: 'You do not have access to any collections yet. Ask an administrator to grant you one.',
102
+ });
103
+ return;
104
+ }
105
+ const target = ids.includes(remembered) ? remembered : (ids.includes('default') ? 'default' : ids[0]);
106
+ return app.engine.dispatch(new NavigateAction(target));
107
+ }
108
+ }
109
+
110
+ export class CreateCollectionAction extends AppAction {
111
+ constructor(record) {
112
+ super();
113
+ this.record = record;
114
+ }
115
+ async execute({ app }) {
116
+ try {
117
+ const res = await app.platform.api.createCollection(this.record);
118
+ app.platform.notifications.success(`Created collection “${res.collection.name}”`);
119
+ await app.engine.dispatch(new LoadCollectionsAction());
120
+ app.engine.dispatch(new NavigateAction(res.collection.id));
121
+ } catch (err) {
122
+ app.platform.notifications.error(`Couldn’t create collection: ${err.message}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Append the next page of a collection to what's already on screen. */
128
+ export class LoadMoreAction extends AppAction {
129
+ async execute({ app }) {
130
+ const { explorer, platform } = app;
131
+ const cursor = explorer.state.nextCursor;
132
+ if (!cursor || explorer.state.loadingMore) return;
133
+ explorer.set({ loadingMore: true });
134
+ try {
135
+ const res = await platform.api.list({
136
+ sort: explorer.state.sort, order: explorer.state.order,
137
+ collection: explorer.state.collectionId, cursor,
138
+ });
139
+ explorer.set({
140
+ items: [...explorer.state.items, ...res.items],
141
+ nextCursor: res.nextCursor || null,
142
+ loadingMore: false,
143
+ });
144
+ } catch (err) {
145
+ explorer.set({ loadingMore: false });
146
+ platform.notifications.error(`Couldn't load more: ${err.message}`);
147
+ }
148
+ }
149
+ }
150
+
151
+ export class RefreshAction extends AppAction {
152
+ async execute({ app }) {
153
+ return app.engine.dispatch(new NavigateAction(app.explorer.state.collectionId));
154
+ }
155
+ }
156
+
157
+ /** Show what's been deleted but not destroyed, and act on it. */
158
+ export class TrashAction extends AppAction {
159
+ constructor(op = 'list', id = null) {
160
+ super();
161
+ this.op = op;
162
+ this.id = id;
163
+ }
164
+ async execute({ app }) {
165
+ const { explorer, platform } = app;
166
+ const collection = explorer.state.collectionId;
167
+ // Putting it away is one of the things you can do to the trash. Without this the
168
+ // section, once opened, stayed on screen until a page reload — there was no way
169
+ // back to a plain list of files.
170
+ if (this.op === 'hide') {
171
+ explorer.set({ trash: null });
172
+ return;
173
+ }
174
+ try {
175
+ if (this.op === 'restore') {
176
+ const { node } = await platform.api.restore(this.id);
177
+ platform.notifications.success(`Restored “${node.name}”`);
178
+ } else if (this.op === 'purge') {
179
+ await platform.api.purgeTrash({ id: this.id });
180
+ } else if (this.op === 'empty') {
181
+ const { purged } = await platform.api.purgeTrash({ collection });
182
+ platform.notifications.success(`Deleted ${purged} item${purged === 1 ? '' : 's'} for good`);
183
+ }
184
+ const { items } = await platform.api.trash(collection);
185
+ explorer.set({ trash: items });
186
+ // Restoring puts something back in the drive, so the list on screen is now stale.
187
+ if (this.op !== 'list') await app.engine.dispatch(new NavigateAction(collection));
188
+ } catch (err) {
189
+ platform.notifications.error(`Trash: ${err.message}`);
190
+ }
191
+ }
192
+ }
193
+
194
+ export class DeleteAction extends AppAction {
195
+ constructor(ids) {
196
+ super();
197
+ this.ids = ids;
198
+ }
199
+ async execute({ app }) {
200
+ try {
201
+ for (const id of this.ids) await app.platform.api.remove(id);
202
+ app.platform.notifications.info(`Deleted ${this.ids.length} item${this.ids.length > 1 ? 's' : ''}`);
203
+ for (const id of this.ids) app.platform.workbench.closeTab(id);
204
+ } catch (err) {
205
+ app.platform.notifications.error(`Couldn’t delete: ${err.message}`);
206
+ }
207
+ app.engine.dispatch(new RefreshAction());
208
+ }
209
+ }
210
+
211
+ export class RenameAction extends AppAction {
212
+ constructor(id, newName) {
213
+ super();
214
+ this.id = id;
215
+ this.newName = newName;
216
+ }
217
+ async execute({ app }) {
218
+ try {
219
+ const node = await app.platform.api.rename(this.id, this.newName);
220
+ app.platform.workbench.updateTabNode(node.node);
221
+ app.engine.dispatch(new RefreshAction());
222
+ } catch (err) {
223
+ app.platform.notifications.error(`Couldn’t rename: ${err.message}`);
224
+ }
225
+ }
226
+ }
227
+
228
+
229
+ export class OpenFileAction extends AppAction {
230
+ /** @param {object} node @param {{reset?:boolean}} [opts] reset → start a fresh stack (modal search) */
231
+ constructor(node, opts = {}) {
232
+ super();
233
+ this.node = node;
234
+ this.opts = opts;
235
+ }
236
+ async execute({ app }) {
237
+ // Guard against being invoked with no target (e.g. a command fired with an empty
238
+ // selection) — dereferencing a null node would throw an unhandled rejection.
239
+ if (!this.node) return;
240
+ const { platform } = app;
241
+ const open = (openerId) => platform.workbench.openFile(this.node, openerId, { reset: !!this.opts.reset });
242
+
243
+ // An explicit opener (e.g. the switch-opener control) wins outright.
244
+ if (this.opts.openerId) return open(this.opts.openerId);
245
+
246
+ // Only openers available right now (a plugin previewer needing the network while
247
+ // offline is skipped, so we fall back to a built-in one).
248
+ const avail = availableOpeners(platform, this.node);
249
+
250
+ // A remembered choice for this file type, if that opener is still available.
251
+ const remembered = rememberedOpenerId(platform, this.node);
252
+ if (remembered && avail.some((o) => o.id === remembered)) return open(remembered);
253
+
254
+ // Several openers and no saved preference → let the user choose (with an option to
255
+ // remember). One or none → just open the best (or the download fallback).
256
+ if (avail.length > 1) {
257
+ return platform.workbench.showDialog({ kind: 'opener-chooser', node: this.node, openers: avail, reset: !!this.opts.reset });
258
+ }
259
+ open(avail[0]?.id || 'core.fallback');
260
+ }
261
+ }
262
+
263
+ export class UploadFilesAction extends AppAction {
264
+ constructor(files, collectionId) {
265
+ super();
266
+ this.files = files;
267
+ this.collectionId = collectionId;
268
+ }
269
+ async execute({ app }) {
270
+ const collection = this.collectionId || app.explorer.state.collectionId || 'default';
271
+ const concurrency = app.platform.settings.get('uploads.concurrency');
272
+ const uploads = [...this.files].map((file) => this.#one(app, file, collection, concurrency));
273
+ await Promise.allSettled(uploads);
274
+ app.engine.dispatch(new NavigateAction(collection));
275
+ }
276
+ async #one(app, file, collection, concurrency) {
277
+ const { transfers, platform } = app;
278
+ const tid = newId('xfer');
279
+ const controller = new AbortController();
280
+ transfers.start(tid, file.name, file.size, controller);
281
+ let uploadId = null;
282
+ try {
283
+ const node = await platform.api.upload(file, {
284
+ collection, concurrency, signal: controller.signal,
285
+ onStart: (id) => { uploadId = id; },
286
+ onProgress: (p) => transfers.progress(tid, p),
287
+ });
288
+ transfers.finish(tid, 'done');
289
+ // The server disambiguates a same-name collision rather than overwriting — tell
290
+ // the user when the saved name differs from what they dropped.
291
+ if (node?.name && node.name !== file.name) {
292
+ platform.notifications.info(`"${file.name}" already existed — saved as "${node.name}".`);
293
+ }
294
+ } catch (err) {
295
+ // Release the server-side session so a cancelled/failed multipart upload doesn't
296
+ // leak an open multipart object (best-effort; the server also sweeps stale ones).
297
+ if (uploadId) platform.api.abortUpload(uploadId).catch(() => {});
298
+ if (err.code === 'aborted') transfers.finish(tid, 'cancelled');
299
+ else {
300
+ transfers.finish(tid, 'error', err.message);
301
+ platform.notifications.error(`Upload failed: ${file.name} — ${err.message}`);
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Drive-wide tag/property filter (`#tag`, `#key:op:value`), optionally narrowed
308
+ * by free text. Falls back to filtering the loaded folder when offline. */
309
+ export class FilterAction extends AppAction {
310
+ constructor(filters, text) {
311
+ super();
312
+ this.filters = filters || [];
313
+ this.text = text || '';
314
+ }
315
+ async execute({ app }) {
316
+ const { search, platform } = app;
317
+ if (!this.filters.length) {
318
+ search.set({ results: [], ran: false, filtered: false });
319
+ return;
320
+ }
321
+ search.set({ query: this.text, loading: true, error: null, filtered: true });
322
+ if (app.offline && !app.offline.state.online) {
323
+ const items = (app.explorer.state.items || []).filter((n) => matchesTagFilters(n, this.filters));
324
+ search.set({ results: items.map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: true });
325
+ return;
326
+ }
327
+ try {
328
+ const res = await platform.api.tagSearch(this.filters, this.text.trim() || undefined, { limit: 100 });
329
+ search.set({ results: (res.items || []).map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: false });
330
+ } catch (err) {
331
+ search.set({ loading: false, error: err.message, ran: true, filtered: true });
332
+ }
333
+ }
334
+ }
335
+
336
+ /** Command-palette quick-open: a keyword file search whose results live in the search
337
+ * service (state.se.paletteFiles) instead of ad-hoc state hung off the UI. */
338
+ export class QuickOpenAction extends AppAction {
339
+ constructor(query) {
340
+ super();
341
+ this.query = query;
342
+ }
343
+ async execute({ app }) {
344
+ const { search, platform } = app;
345
+ const q = (this.query || '').trim();
346
+ if (!q) { search.set({ paletteFiles: [], paletteQuery: '', paletteError: null, paletteLoading: false }); return; }
347
+ // Keystrokes outrun the network: a slower request for an earlier query must not
348
+ // land on top of a newer one's results. The query itself is the sequence token —
349
+ // whatever the palette input holds now is the only answer worth showing.
350
+ search.set({ paletteQuery: q, paletteLoading: true, paletteError: null });
351
+ try {
352
+ const res = await platform.api.search(q, { mode: 'keyword', limit: 30 });
353
+ if (search.state.paletteQuery !== q) return; // superseded
354
+ search.set({ paletteFiles: res.results || [], paletteLoading: false });
355
+ } catch (err) {
356
+ if (search.state.paletteQuery !== q) return;
357
+ // A failed search must not look like "no files matched" — that reads as a fact
358
+ // about the drive when it's actually a fact about the request.
359
+ search.set({ paletteFiles: [], paletteLoading: false, paletteError: err?.message || 'Search failed' });
360
+ }
361
+ }
362
+ }
363
+
364
+ export class SearchAction extends AppAction {
365
+ constructor(query, mode) {
366
+ super();
367
+ this.query = query;
368
+ this.mode = mode;
369
+ }
370
+ async execute({ app }) {
371
+ const { search, platform } = app;
372
+ const q = this.query.trim();
373
+ if (!q) {
374
+ search.set({ query: '', results: [], ran: false, error: null, resolved: null });
375
+ return;
376
+ }
377
+ const mode = this.mode || platform.settings.get('search.mode');
378
+ search.set({ query: this.query, mode, loading: true, error: null, resolved: null });
379
+ const offline = app.offline;
380
+ // Offline (or server unreachable) → search the pinned corpus locally.
381
+ if (offline && !offline.state.online) {
382
+ try {
383
+ const results = await offline.searchOffline(q, { limit: 40 });
384
+ search.set({ results, loading: false, ran: true, offline: true, resolved: null });
385
+ } catch (err) {
386
+ search.set({ results: [], loading: false, ran: true, offline: true, error: err.message });
387
+ }
388
+ return;
389
+ }
390
+ try {
391
+ // The server transforms the raw query (parse/LLM) and tells us what it actually
392
+ // searched — surfaced to the user via `resolved` so search is honest.
393
+ //
394
+ // We send the views we can draw with, because the transformer is the only thing in
395
+ // the stack that read the sentence: "photos from the trip last summer" asks for a
396
+ // gallery, and by the time the results are back all anyone can do is guess from
397
+ // content types. It can only name a view from this list.
398
+ const views = platform.contributions.ofType('view').map((v) => ({ id: v.id, title: v.title || v.id }));
399
+ const res = await platform.api.query(q, { mode, limit: 40, views });
400
+ search.set({ results: res.results, resolved: res.resolved || null, loading: false, ran: true, offline: false });
401
+ } catch (err) {
402
+ if (offline) {
403
+ const results = await offline.searchOffline(q, { limit: 40 });
404
+ search.set({ results, loading: false, ran: true, offline: true, error: results.length ? null : err.message });
405
+ } else {
406
+ search.set({ loading: false, error: err.message, ran: true });
407
+ }
408
+ }
409
+ }
410
+ }