@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,306 @@
1
+ // ActivityService — the client's answer to "what is going on, and what is stuck".
2
+ //
3
+ // It holds two lists that look similar and mean different things, and keeping them
4
+ // distinct is the whole design:
5
+ //
6
+ // TASKS work happening now. Ephemeral. A task disappears when it finishes (after a
7
+ // moment on screen) and when the process running it stops. That is correct:
8
+ // the work stopped too.
9
+ // ISSUES a standing problem left behind by something that already failed. Durable on
10
+ // the server. It stays until the thing it complains about actually works —
11
+ // not until it is acknowledged, and not until a reload.
12
+ //
13
+ // WHY ONE SERVICE FOR BOTH SIDES. Work happens in two places — an upload runs in this
14
+ // browser, a reindex runs on the server — and a user does not care which. So this holds
15
+ // one list, with each task tagged by where it lives:
16
+ //
17
+ // local started here, driven here. The owner (TransfersService) reports progress
18
+ // directly, and cancelling calls a function.
19
+ // server a read-only MIRROR of the server's registry. Nothing here drives it;
20
+ // cancelling posts, and the next poll shows the result. Treating a mirror as
21
+ // writable is how two sources of truth get invented.
22
+ //
23
+ // WHY POLLING. There is no streaming transport in this server, and adding SSE through
24
+ // three runtime adapters to move a progress bar is not a trade worth making yet. So:
25
+ // poll fast (1s) only while something is running, drop to nothing when the drive is
26
+ // idle, and re-check when the tab regains focus. At rest this costs one request when
27
+ // the app loads. If a real-time channel ever exists, only `#poll` changes.
28
+
29
+ import { cell } from '../runtime.js';
30
+
31
+ const ACTIVE_MS = 1000; // while something is running, we want a live-feeling bar
32
+ const IDLE_MS = 60_000; // otherwise just enough to notice a task someone else started
33
+
34
+ export class ActivityService {
35
+ constructor(platform) {
36
+ this.platform = platform;
37
+ this.api = platform.api;
38
+ this.state = {
39
+ tasks: [], // merged: local first, then the server mirror
40
+ issues: [],
41
+ issuesLoading: false,
42
+ // A load failure is state, not a shrug: "no problems" and "we couldn't ask" look
43
+ // identical on screen unless one of them says so. TWO fields, not one, because
44
+ // these are independent requests — a tasks poll that succeeds must not erase the
45
+ // news that the issues poll failed, which is exactly the bug one shared field
46
+ // produced.
47
+ tasksError: null,
48
+ issuesError: null,
49
+ open: false, // the activity panel
50
+ };
51
+ this.cell = cell(this.state);
52
+ this._local = new Map(); // id -> local task (this browser owns these)
53
+ this._server = [];
54
+ this._timer = null;
55
+ this._pollMs = null;
56
+ }
57
+
58
+ observe() {
59
+ return this.cell;
60
+ }
61
+ #set(patch) {
62
+ this.state = { ...this.state, ...patch };
63
+ this.cell.setValue(this.state);
64
+ }
65
+ #merge() {
66
+ this.#set({ tasks: [...this._local.values(), ...this._server] });
67
+ }
68
+
69
+ // --- local tasks -----------------------------------------------------------
70
+
71
+ /**
72
+ * Register work happening in this browser. Returns a handle with the same shape the
73
+ * server's registry hands its callers, so code that reports progress doesn't need to
74
+ * know which side it is running on.
75
+ *
76
+ * @param {{title: string, kind?: string, detail?: string, total?: number|null,
77
+ * unit?: string, onCancel?: () => void}} spec
78
+ */
79
+ start(spec = {}) {
80
+ const id = spec.id || `local_${Math.random().toString(36).slice(2, 10)}`;
81
+ const task = {
82
+ id,
83
+ source: 'local',
84
+ kind: spec.kind || 'general',
85
+ title: spec.title || 'Working',
86
+ detail: spec.detail ?? null,
87
+ status: 'running',
88
+ done: 0,
89
+ // No total means indeterminate, and that is a legitimate answer. A caller that
90
+ // doesn't know how much work there is must not invent a number: a progress bar
91
+ // that lies is worse than a spinner that admits it doesn't know.
92
+ total: spec.total ?? null,
93
+ unit: spec.unit || null,
94
+ cancellable: !!spec.onCancel,
95
+ startedAt: Date.now(),
96
+ endedAt: null,
97
+ error: null,
98
+ };
99
+ this._local.set(id, task);
100
+ this._cancels ||= new Map();
101
+ if (spec.onCancel) this._cancels.set(id, spec.onCancel);
102
+ this.#merge();
103
+ this.#schedule();
104
+
105
+ const patch = (p) => {
106
+ const cur = this._local.get(id);
107
+ if (!cur || cur.status !== 'running') return; // finished tasks are final
108
+ this._local.set(id, { ...cur, ...p });
109
+ this.#merge();
110
+ };
111
+ return {
112
+ id,
113
+ progress: (p = {}) => patch({
114
+ done: p.done ?? this._local.get(id)?.done ?? 0,
115
+ total: p.total === undefined ? this._local.get(id)?.total : p.total,
116
+ unit: p.unit ?? this._local.get(id)?.unit,
117
+ detail: p.detail ?? this._local.get(id)?.detail,
118
+ }),
119
+ succeed: (detail) => { patch({ status: 'done', endedAt: Date.now(), detail: detail ?? this._local.get(id)?.detail }); this.#expire(id); },
120
+ fail: (err) => { patch({ status: 'failed', endedAt: Date.now(), error: err?.message || String(err || 'failed') }); },
121
+ cancel: () => { patch({ status: 'cancelled', endedAt: Date.now() }); this.#expire(id); },
122
+ };
123
+ }
124
+
125
+ /** A finished task lingers briefly so it is seen to finish, then goes. */
126
+ #expire(id, after = 5000) {
127
+ setTimeout(() => {
128
+ this._local.delete(id);
129
+ this._cancels?.delete(id);
130
+ this.#merge();
131
+ }, after);
132
+ }
133
+
134
+ async cancel(id) {
135
+ const local = this._local.get(id);
136
+ if (local) {
137
+ this._cancels?.get(id)?.();
138
+ return;
139
+ }
140
+ // A server task is a mirror: ask, then let the next poll tell the truth.
141
+ try {
142
+ await this.api.cancelTask(id);
143
+ await this.refreshTasks();
144
+ } catch (err) {
145
+ this.#set({ tasksError: err.message });
146
+ }
147
+ }
148
+
149
+ dismiss(id) {
150
+ if (this._local.delete(id)) { this.#merge(); return; }
151
+ this.api.dismissTask(id).catch(() => {});
152
+ this._server = this._server.filter((t) => t.id !== id);
153
+ this.#merge();
154
+ }
155
+
156
+ // --- server mirror + issues ------------------------------------------------
157
+
158
+ async init() {
159
+ await this.refresh();
160
+ this.#schedule();
161
+ // Coming back to the tab is the moment a stale list is most obvious.
162
+ window.addEventListener('focus', () => this.refresh());
163
+ }
164
+
165
+ async refresh() {
166
+ await Promise.all([this.refreshTasks(), this.refreshIssues()]);
167
+ }
168
+
169
+ async refreshTasks() {
170
+ const wasRunning = this.running.length > 0;
171
+ try {
172
+ const { tasks } = await this.api.tasks();
173
+ this._server = (tasks || []).map((t) => ({ ...t, source: 'server' }));
174
+ this.#set({ tasksError: null });
175
+ this.#merge();
176
+ } catch (err) {
177
+ // Offline or a server that doesn't have the endpoint: stop claiming to know.
178
+ this._server = [];
179
+ this.#set({ tasksError: err.message });
180
+ this.#merge();
181
+ }
182
+ // The moment work STOPS is the moment an issue is most likely to have been fixed,
183
+ // so re-read them then. Without this, a retry that succeeds in 50 ms leaves the
184
+ // problem on screen until the next idle poll — up to a minute of the user being
185
+ // told something is broken that isn't.
186
+ if (wasRunning && !this.running.length) this.refreshIssues();
187
+ this.#schedule();
188
+ }
189
+
190
+ async refreshIssues() {
191
+ this.#set({ issuesLoading: true });
192
+ try {
193
+ const { issues } = await this.api.issues();
194
+ this.#set({ issues: issues || [], issuesLoading: false, issuesError: null });
195
+ } catch (err) {
196
+ // Deliberately NOT clearing `issues`: the last known list is better than an empty
197
+ // one that reads as "nothing is wrong".
198
+ this.#set({ issuesLoading: false, issuesError: err.message });
199
+ }
200
+ }
201
+
202
+ async retryIssue(id) {
203
+ try {
204
+ await this.api.retryIssue(id);
205
+ // The fix runs as a task; adopt it now so the click visibly did something, and
206
+ // let the poll clear the issue when the work actually succeeds. The retry may
207
+ // finish faster than a poll interval, so re-read the issues directly too — a
208
+ // Retry button that leaves the problem sitting there reads as a broken button.
209
+ await this.refreshTasks();
210
+ // A retry can finish faster than one poll interval — in which case there is no
211
+ // running→idle transition to notice and the fixed problem would sit on screen
212
+ // until the next idle poll. A couple of bounded follow-ups cover that window
213
+ // without turning this into a busy loop.
214
+ this.#followUp();
215
+ this.platform.notifications?.info?.('Retrying — watch its progress in Activity');
216
+ } catch (err) {
217
+ this.platform.notifications?.error?.(`Couldn't retry: ${err.message}`);
218
+ }
219
+ }
220
+
221
+ async dismissIssue(id) {
222
+ const before = this.state.issues;
223
+ this.#set({ issues: before.filter((i) => i.id !== id) });
224
+ try {
225
+ await this.api.dismissIssue(id);
226
+ } catch (err) {
227
+ this.#set({ issues: before }); // put it back rather than pretend
228
+ this.platform.notifications?.error?.(`Couldn't dismiss: ${err.message}`);
229
+ }
230
+ }
231
+
232
+ /** Two bounded re-reads after an action that may have resolved something. */
233
+ #followUp() {
234
+ for (const ms of [400, 1500]) {
235
+ const t = setTimeout(() => { this.refreshTasks(); this.refreshIssues(); }, ms);
236
+ t?.unref?.();
237
+ }
238
+ }
239
+
240
+ /** Rebuild the whole search index. Surfaced as a command; reports as a task. */
241
+ async rebuildIndex() {
242
+ try {
243
+ const res = await this.api.reindex();
244
+ await this.refreshTasks();
245
+ this.togglePanel(true);
246
+ if (res.alreadyRunning) this.platform.notifications?.info?.('A rebuild is already running');
247
+ return res;
248
+ } catch (err) {
249
+ this.platform.notifications?.error?.(`Couldn't rebuild the index: ${err.message}`);
250
+ throw err;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Reconcile a collection with its object store — the way a drive notices files added,
256
+ * replaced, or removed by anything that isn't Trove. Reports as a task, like a rebuild.
257
+ */
258
+ async scanCollection(collectionId) {
259
+ try {
260
+ const res = await this.api.scanCollection(collectionId);
261
+ await this.refreshTasks();
262
+ this.togglePanel(true);
263
+ if (res.alreadyRunning) this.platform.notifications?.info?.('A scan of this collection is already running');
264
+ this.#followUp();
265
+ return res;
266
+ } catch (err) {
267
+ this.platform.notifications?.error?.(`Couldn't scan “${collectionId}”: ${err.message}`);
268
+ throw err;
269
+ }
270
+ }
271
+
272
+ togglePanel(open) {
273
+ this.#set({ open: open ?? !this.state.open });
274
+ if (this.state.open) this.refresh();
275
+ }
276
+
277
+ get running() {
278
+ return this.state.tasks.filter((t) => t.status === 'running');
279
+ }
280
+
281
+ /**
282
+ * Poll fast while something is running, slowly otherwise. Rescheduled rather than
283
+ * fixed, so an idle drive costs one request a minute and a running rebuild feels
284
+ * live — without a second transport to maintain.
285
+ */
286
+ #schedule() {
287
+ const want = this.running.length ? ACTIVE_MS : IDLE_MS;
288
+ if (this._timer && this._pollMs === want) return;
289
+ clearTimeout(this._timer);
290
+ this._pollMs = want;
291
+ this._timer = setTimeout(() => {
292
+ this._timer = null;
293
+ this.refreshTasks().then(() => {
294
+ // Issues change when tasks end, so re-read them then rather than on their own
295
+ // clock — a retry that fixed something should stop being listed promptly.
296
+ if (!this.running.length) this.refreshIssues();
297
+ });
298
+ }, want);
299
+ this._timer?.unref?.();
300
+ }
301
+
302
+ dispose() {
303
+ clearTimeout(this._timer);
304
+ this._timer = null;
305
+ }
306
+ }
@@ -0,0 +1,274 @@
1
+ // Register the workbench's built-in commands. Commands are the single entry
2
+ // point for every action — palette, keybindings, buttons, and menus all call
3
+ // `commands.execute(id)`. Handlers here dispatch ngin Actions for anything that
4
+ // touches data, and poke the reactive shell services for pure-UI toggles.
5
+
6
+ import {
7
+ NavigateAction, RefreshAction, DeleteAction, RenameAction,
8
+ UploadFilesAction, OpenFileAction, CreateCollectionAction, LoadMoreAction, TrashAction,
9
+ SearchAction,
10
+ } from './actions.js';
11
+ import { beginInstallFromFile, beginInstallFromUrl } from './pluginInstall.js';
12
+ import { troveUri } from '@3sln/trove/core/links.js';
13
+
14
+ export function registerCommands(app) {
15
+ const { platform, engine, explorer } = app;
16
+ const { commands, workbench } = platform;
17
+ const go = (a) => engine.dispatch(a);
18
+
19
+ const cmd = (id, title, handler, extra = {}) => commands.register({ id, title, handler, ...extra });
20
+
21
+ // --- navigation / views ----------------------------------------------------
22
+ cmd('workbench.showCommandPalette', 'Show All Commands', () => workbench.openPalette('commands'), { category: 'View', icon: 'command' });
23
+ cmd('workbench.quickOpen', 'Go to File…', () => workbench.openPalette('files'), { category: 'File', icon: 'search' });
24
+ cmd('workbench.view.home', 'Go Home (search & browse)', () => workbench.showHome(), { category: 'View', icon: 'search' });
25
+ cmd('workbench.view.plugins', 'Show Plugins', () => workbench.setActivity('plugins'), { category: 'View' });
26
+ cmd('workbench.openSettings', 'Open Settings', () => workbench.setActivity('settings'), { category: 'Preferences', icon: 'gear' });
27
+ cmd('workbench.closeOverlays', 'Close', () => workbench.closeOverlays(), { palette: false });
28
+
29
+ // Speak to search.
30
+ //
31
+ // On a TV this is mostly NOT about recognising anything: a remote's mic dictates into
32
+ // whatever text field the platform keyboard is attached to, and is swallowed by the
33
+ // system assistant when there isn't one. So the command's first job is to put the
34
+ // search field on screen and focused, which is a feature on every TV whether or not
35
+ // the browser can transcribe. Where it can — on-device only, see platform/voice.js —
36
+ // it also starts listening and types what it hears.
37
+ cmd('search.voice', 'Search by Voice', async () => {
38
+ await platform.voice.run({
39
+ onText: (text, { final }) => {
40
+ if (!text) return;
41
+ workbench.setLaunchQuery(text);
42
+ // Interim results keep the box in step with the speaker; only the settled
43
+ // transcript is worth a round trip to the server.
44
+ if (final) go(new SearchAction(text));
45
+ },
46
+ });
47
+ }, { category: 'View', icon: 'search' });
48
+
49
+ // --- background work + standing problems -----------------------------------
50
+ cmd('workbench.showActivity', 'Show Activity (running work & problems)',
51
+ () => app.activity.togglePanel(true), { category: 'View', icon: 'refresh' });
52
+ // The manual scan the drive had no way to ask for. Reports as a task rather than
53
+ // blocking, because on a large drive it takes minutes.
54
+ cmd('workbench.rebuildIndex', 'Rebuild Search Index',
55
+ () => app.activity.rebuildIndex().catch(() => {}), { category: 'View', icon: 'refresh' });
56
+ // Trove is not the only thing that can write to the bucket. This is how files added,
57
+ // replaced, or removed by something else get picked up.
58
+ cmd('workbench.scanCollection', 'Scan Collection for Outside Changes',
59
+ () => app.activity.scanCollection(explorer.state.collectionId || 'default').catch(() => {}),
60
+ { category: 'Explorer', icon: 'refresh' });
61
+
62
+ // --- explorer --------------------------------------------------------------
63
+ cmd('explorer.refresh', 'Refresh', () => go(new RefreshAction()), { category: 'Explorer', icon: 'refresh' });
64
+ cmd('explorer.loadMore', 'Show More Items', () => go(new LoadMoreAction()), { palette: false });
65
+ cmd('explorer.upload', 'Upload Files…', () => {
66
+ pickFiles((files) => files.length && go(new UploadFilesAction(files, explorer.state.collectionId)));
67
+ }, { category: 'Explorer', icon: 'upload' });
68
+
69
+ // Copy the item's trove: link, which is how one item references another in markdown.
70
+ // "Nothing is selected" is a real answer, and it has to be said out loud: these used
71
+ // to return silently, which is indistinguishable from a broken command.
72
+ const subject = (fallbackNode) => {
73
+ const node = fallbackNode || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
74
+ if (!node) platform.notifications.info('Pick a file first — highlight one in the list, or open it.');
75
+ return node;
76
+ };
77
+
78
+ cmd('explorer.copyLink', 'Copy Link to Item', async () => {
79
+ const node = subject();
80
+ if (!node) return;
81
+ const uri = troveUri(node);
82
+ try {
83
+ await navigator.clipboard.writeText(uri);
84
+ platform.notifications.success(`Copied ${uri}`);
85
+ } catch {
86
+ // Clipboard access can be denied; showing the link is still useful.
87
+ platform.notifications.info(uri, { sticky: true });
88
+ }
89
+ }, { category: 'Explorer', icon: 'link' });
90
+
91
+ cmd('explorer.rename', 'Rename', () => {
92
+ const node = subject();
93
+ if (!node) return;
94
+ workbench.showDialog({
95
+ kind: 'prompt', title: 'Rename', label: 'New name', value: node.name, confirmLabel: 'Rename',
96
+ onSubmit: (name) => {
97
+ workbench.closeDialog();
98
+ if (name?.trim() && name !== node.name) go(new RenameAction(node.id, name.trim()));
99
+ },
100
+ });
101
+ }, { category: 'Explorer' });
102
+
103
+ cmd('explorer.delete', 'Delete', () => {
104
+ const nodes = explorer.selectedNodes();
105
+ const fallback = nodes.length ? null : workbench.activeTab()?.node;
106
+ if (fallback) nodes.push(fallback); // deleting the file you have open is the obvious intent
107
+ if (!nodes.length) {
108
+ platform.notifications.info('Pick a file first — highlight one in the list, or open it.');
109
+ return;
110
+ }
111
+ const doDelete = () => go(new DeleteAction(nodes.map((n) => n.id)));
112
+ if (platform.settings.get('explorer.confirmDelete')) {
113
+ workbench.showDialog({
114
+ kind: 'confirm', title: `Move ${nodes.length} item${nodes.length > 1 ? 's' : ''} to the trash?`,
115
+ // Say what actually happens. Telling someone a file will be "permanently
116
+ // deleted" when it goes to the trash trains them to fear a safe action; the
117
+ // reverse — promising recovery that doesn't exist — is worse.
118
+ body: nodes.length === 1
119
+ ? `"${nodes[0].name}" leaves the drive but is kept, and can be restored from the trash.`
120
+ : 'They leave the drive but are kept, and can be restored from the trash.',
121
+ confirmLabel: 'Move to trash',
122
+ onConfirm: () => {
123
+ workbench.closeDialog();
124
+ doDelete();
125
+ },
126
+ });
127
+ } else doDelete();
128
+ }, { category: 'Explorer', icon: 'trash' });
129
+
130
+ cmd('explorer.showTrash', 'Show Trash', () => {
131
+ go(new TrashAction('list'));
132
+ workbench.showHome();
133
+ }, { category: 'Explorer', icon: 'trash' });
134
+ cmd('explorer.hideTrash', 'Hide Trash', () => go(new TrashAction('hide')), { palette: false });
135
+ cmd('explorer.restore', 'Restore from Trash', (id) => id && go(new TrashAction('restore', id)), { palette: false });
136
+ cmd('explorer.purgeOne', 'Delete Forever', (id) => id && go(new TrashAction('purge', id)), { palette: false });
137
+ cmd('explorer.emptyTrash', 'Empty Trash', () => {
138
+ workbench.showDialog({
139
+ kind: 'confirm', title: 'Empty the trash?',
140
+ body: 'Everything in the trash will be destroyed. This cannot be undone.',
141
+ danger: true, confirmLabel: 'Delete forever',
142
+ onConfirm: () => { workbench.closeDialog(); go(new TrashAction('empty')); },
143
+ });
144
+ }, { category: 'Explorer', icon: 'trash' });
145
+
146
+ cmd('explorer.open', 'Open', (node) => go(new OpenFileAction(node || explorer.selectedNodes()[0])), { palette: false });
147
+ cmd('explorer.download', 'Download', async (node) => {
148
+ const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
149
+ if (!target?.id) return;
150
+ try {
151
+ const { url, revoke } = await platform.api.download(target.id, target.name);
152
+ triggerDownload(url, target.name);
153
+ // A blob URL pins the bytes until it is released; the click has already happened
154
+ // by the time this runs.
155
+ if (revoke) setTimeout(() => URL.revokeObjectURL(url), 60_000);
156
+ } catch (err) {
157
+ platform.notifications.error(`Couldn't download ${target.name}: ${err.message}`);
158
+ }
159
+ }, { category: 'Explorer', icon: 'download' });
160
+
161
+ // --- offline ---------------------------------------------------------------
162
+ cmd('offline.pin', 'Make Available Offline', (node) => {
163
+ const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
164
+ if (target?.id) app.offline.pin(target);
165
+ }, { category: 'Offline', icon: 'download' });
166
+ cmd('offline.unpin', 'Remove from Offline', (node) => {
167
+ const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
168
+ if (target) app.offline.unpin(target.id);
169
+ }, { palette: false });
170
+
171
+ // --- collections -----------------------------------------------------------
172
+ // The menu of "where else could I be". Shared by the palette command and the status
173
+ // bar's collection segment, so both offer the same list.
174
+ const collectionMenu = () => {
175
+ const current = explorer.state.collectionId || 'default';
176
+ const items = (explorer.state.collections || []).map((c) => ({
177
+ label: c.name || c.id,
178
+ icon: c.id === current ? 'check' : 'files',
179
+ run: () => commands.execute('collections.switch', c.id),
180
+ }));
181
+ if (explorer.state.canCreateCollection) {
182
+ if (items.length) items.push({ sep: true });
183
+ items.push({ label: 'New collection…', icon: 'plus', run: () => commands.execute('collections.create') });
184
+ }
185
+ return items;
186
+ };
187
+ app.collectionMenu = collectionMenu;
188
+
189
+ // `NavigateAction` takes a single collectionId. This passed ('/', cid) — a leftover
190
+ // from a path+collection signature — so every switch navigated to a collection
191
+ // literally named "/" and failed to load. It was also unreachable: no caller, and
192
+ // hidden from the palette.
193
+ cmd('collections.switch', 'Switch Collection…', (cid) => {
194
+ if (cid) {
195
+ go(new NavigateAction(cid));
196
+ workbench.showHome();
197
+ return;
198
+ }
199
+ // From the palette or a keybinding there is no pointer to anchor a menu to.
200
+ const items = collectionMenu();
201
+ if (!items.length) return platform.notifications.info('This drive has one collection.');
202
+ const w = typeof window === 'undefined' ? 800 : window.innerWidth;
203
+ workbench.showContextMenu(Math.max(12, Math.round(w / 2) - 110), 120, items);
204
+ }, { category: 'Collections', icon: 'files' });
205
+ cmd('collections.create', 'New Collection…', () => {
206
+ workbench.showDialog({
207
+ kind: 'collection', title: 'New collection',
208
+ onSubmit: (record) => {
209
+ workbench.closeDialog();
210
+ if (record) go(new CreateCollectionAction(record));
211
+ },
212
+ });
213
+ }, { category: 'Collections', icon: 'files' });
214
+
215
+ // --- conversations & notifications -----------------------------------------
216
+ // The details panel is a view OF a file. With nothing open there is nothing for it to
217
+ // show, and flipping the flag silently was indistinguishable from a broken command.
218
+ cmd('workbench.toggleInfoPanel', 'Toggle Details & Conversation', () => {
219
+ if (!workbench.activeTab()) {
220
+ platform.notifications.info('Open a file to see its details and conversation.');
221
+ return;
222
+ }
223
+ workbench.toggleInfoPanel();
224
+ }, { category: 'View', icon: 'info' });
225
+ cmd('notifications.show', 'Show Notifications', () => app.social.toggleInbox(true), { category: 'View' });
226
+ cmd('notifications.enablePush', 'Enable Push Notifications', () => app.social.enablePush(), { category: 'Notifications' });
227
+
228
+ // --- plugins ---------------------------------------------------------------
229
+ cmd('plugins.installFromUrl', 'Install Plugin from URL…', () => {
230
+ workbench.showDialog({
231
+ kind: 'prompt', title: 'Install plugin from URL', label: 'Plugin package (.zip) URL',
232
+ placeholder: 'https://example.com/plugin.zip', confirmLabel: 'Fetch',
233
+ onSubmit: (url) => { workbench.closeDialog(); if (url?.trim()) beginInstallFromUrl(app, url.trim()); },
234
+ });
235
+ }, { category: 'Plugins', icon: 'plug' });
236
+ cmd('plugins.installFromFile', 'Install Plugin from File…', () => {
237
+ pickZip((file) => file && beginInstallFromFile(app, file));
238
+ }, { category: 'Plugins', icon: 'plug' });
239
+ }
240
+
241
+ // --- helpers ----------------------------------------------------------------
242
+
243
+ // Open a native file picker. The hidden <input> is removed on selection AND on
244
+ // cancel — cancelling fires no `change`, so we also clean up on the next window
245
+ // focus (which the OS dialog returns) to avoid leaking a growing pile of inputs.
246
+ function pick(cb, configure) {
247
+ const input = document.createElement('input');
248
+ input.type = 'file';
249
+ configure(input);
250
+ input.style.display = 'none';
251
+ document.body.appendChild(input);
252
+ const cleanup = () => { input.remove(); window.removeEventListener('focus', onFocus); };
253
+ const onFocus = () => setTimeout(() => { if (!input.files.length) cleanup(); }, 300);
254
+ input.addEventListener('change', () => { cb(input.files); cleanup(); }, { once: true });
255
+ window.addEventListener('focus', onFocus);
256
+ input.click();
257
+ }
258
+ function pickFiles(cb) {
259
+ pick((files) => cb(files), (input) => { input.multiple = true; });
260
+ }
261
+ function pickZip(cb) {
262
+ pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
263
+ }
264
+
265
+ function triggerDownload(url, name) {
266
+ const a = document.createElement('a');
267
+ a.href = url;
268
+ a.download = name || '';
269
+ document.body.appendChild(a);
270
+ a.click();
271
+ a.remove();
272
+ }
273
+
274
+ export { pickFiles, triggerDownload };
@@ -0,0 +1,49 @@
1
+ // One place that knows how to classify a file by type — extension extraction and a
2
+ // single format table. Previously the ext/mime lists were re-derived (and drifted)
3
+ // across openers.extOf, contributions.matchesSelector, iconForNode, and offline.isTexty;
4
+ // now the icon shown, the "is this text?" decision, and the type label all agree.
5
+
6
+ /** The extension (".pdf") for a node, lowercased, or '' if none. */
7
+ export function extOf(node) {
8
+ const name = (node?.name || '').toLowerCase();
9
+ return name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
10
+ }
11
+
12
+ // Category → { mime: prefixes/exact it matches, ext: extensions }. Order matters:
13
+ // kindOf checks these top-down.
14
+ const CATEGORIES = [
15
+ { kind: 'audio', icon: 'file-audio', mime: ['audio/'], ext: ['.mp3', '.flac', '.wav', '.opus', '.ogg', '.m4a', '.m4b'] },
16
+ { kind: 'image', icon: 'file-image', mime: ['image/'], ext: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'] },
17
+ { kind: 'video', icon: 'file-video', mime: ['video/'], ext: ['.mp4', '.webm', '.mkv', '.mov'] },
18
+ {
19
+ kind: 'text', icon: 'file-text', mime: ['text/', 'application/json'],
20
+ ext: ['.txt', '.md', '.markdown', '.json', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.css', '.html',
21
+ '.xml', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.py', '.rb', '.go', '.rs', '.sh', '.c', '.h', '.cpp', '.java'],
22
+ },
23
+ ];
24
+
25
+ function matchesCategory(cat, ext, ct) {
26
+ if (cat.ext.includes(ext)) return true;
27
+ // A mime entry ending in '/' is a prefix (audio/), otherwise an exact type.
28
+ return cat.mime.some((m) => (m.endsWith('/') ? ct.startsWith(m) : ct === m));
29
+ }
30
+
31
+ /** Classify an item → 'audio'|'image'|'video'|'text'|'file'. */
32
+ export function kindOf(node) {
33
+ const ext = extOf(node);
34
+ const ct = node?.contentType || '';
35
+ for (const cat of CATEGORIES) if (matchesCategory(cat, ext, ct)) return cat.kind;
36
+ return 'file';
37
+ }
38
+
39
+ const ICONS = { ...Object.fromEntries(CATEGORIES.map((c) => [c.kind, c.icon])), file: 'file' };
40
+
41
+ /** The icon name for a node, derived from its kind. */
42
+ export function iconForKind(node) {
43
+ return ICONS[kindOf(node)] || 'file';
44
+ }
45
+
46
+ /** Whether a node holds indexable text (drives offline text extraction). */
47
+ export function isTexty(node) {
48
+ return kindOf(node) === 'text';
49
+ }