@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,137 @@
1
+ // URLs for things that cannot send a header.
2
+ //
3
+ // An `<img src>`, a `<video src>` and `cache.add()` all fetch without our Authorization
4
+ // header, so on a token-authenticated deployment they 401 and the preview shows a
5
+ // fallback. The server can mint a URL that carries its own grant (see
6
+ // docs/design/signed-urls.md); this is the client half.
7
+ //
8
+ // Two things it exists to get right:
9
+ //
10
+ // BATCHING — a gallery draws hundreds of tiles at once. Per-object signing is right for
11
+ // scoping and hopeless as one round trip each, so ids asked for in the same tick go out
12
+ // together.
13
+ //
14
+ // EXPIRY — a minted URL dies. Callers get `expiresAt` alongside and are expected to
15
+ // cycle (see ui/media.js); this service will not hand back a URL that is about to
16
+ // expire, so a caller that re-asks always gets something usable.
17
+
18
+ // Re-mint at 80% of the remaining life rather than at the end: a URL handed out at 99%
19
+ // of its life is one the caller will be back for almost immediately.
20
+ const FRESH_ENOUGH = 0.8;
21
+ // Ids asked for within this window go out as one request. A frame is 16ms; this is long
22
+ // enough to collect a grid's first paint and short enough not to be felt.
23
+ const BATCH_MS = 20;
24
+ const MAX_BATCH = 200;
25
+
26
+ export class MediaUrlService {
27
+ /** @param {{api: object, settings?: object}} deps */
28
+ constructor({ api, settings = null }) {
29
+ this.api = api;
30
+ this.settings = settings;
31
+ this.cache = new Map(); // `${op}:${id}` -> { url, expiresAt }
32
+ this._queue = new Map(); // op -> Map(id -> {resolve, reject})
33
+ this._timer = null;
34
+ }
35
+
36
+ /**
37
+ * Whether URLs need minting at all.
38
+ *
39
+ * With no bearer token the browser authenticates itself — a cookie or a proxy header
40
+ * rides along on a subresource load — so the plain route URL works, needs no round
41
+ * trip, and stays stable enough to be a cache key. Minting there would be a cost with
42
+ * nothing bought. This is the one branch, and it lives here so callers have none.
43
+ *
44
+ * `media.signedUrls` overrides the guess: a deployment behind a proxy that still wants
45
+ * media fetched straight from S3 sets `always`.
46
+ */
47
+ get needed() {
48
+ const mode = this.settings?.get('media.signedUrls');
49
+ if (mode === 'always') return true;
50
+ if (mode === 'never') return false;
51
+ return !!this.api.token?.();
52
+ }
53
+
54
+ /**
55
+ * A stable identifier for this node's bytes.
56
+ *
57
+ * NOT the URL to fetch — a minted one carries a signature and changes every time. The
58
+ * offline cache keys on this, because a key that changes per mint means `unpin` stops
59
+ * matching what `pin` stored and the bytes are never reclaimed.
60
+ */
61
+ cacheKey(id) {
62
+ return this.api.downloadUrl(id);
63
+ }
64
+
65
+ /**
66
+ * A URL for this node's bytes, good for a while.
67
+ *
68
+ * @returns {Promise<{url: string, expiresAt: number}>} `expiresAt` is Infinity when
69
+ * nothing was minted, so a caller's cycling logic simply never fires.
70
+ */
71
+ async url(id, { op = 'media' } = {}) {
72
+ if (!this.needed) return { url: this.api.downloadUrl(id, { attachment: op === 'download' }), expiresAt: Infinity };
73
+ const key = `${op}:${id}`;
74
+ const hit = this.cache.get(key);
75
+ if (hit && this.#fresh(hit)) return hit;
76
+ return this.#enqueue(id, op);
77
+ }
78
+
79
+ /** Forget what we hold for a node, so the next ask re-mints. */
80
+ invalidate(id, op = 'media') {
81
+ if (id == null) this.cache.clear();
82
+ else this.cache.delete(`${op}:${id}`);
83
+ }
84
+
85
+ #fresh(entry) {
86
+ if (entry.expiresAt === Infinity) return true;
87
+ const life = entry.expiresAt - entry.mintedAt;
88
+ return Date.now() < entry.mintedAt + life * FRESH_ENOUGH;
89
+ }
90
+
91
+ #enqueue(id, op) {
92
+ if (!this._queue.has(op)) this._queue.set(op, new Map());
93
+ const forOp = this._queue.get(op);
94
+ const existing = forOp.get(id);
95
+ if (existing) return existing.promise;
96
+
97
+ let resolve;
98
+ let reject;
99
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
100
+ forOp.set(id, { resolve, reject, promise });
101
+
102
+ // Flush immediately at the cap rather than waiting out the window — a drive view that
103
+ // asks for a thousand tiles should not hold the first two hundred hostage.
104
+ if (forOp.size >= MAX_BATCH) this.#flush();
105
+ else if (!this._timer) this._timer = setTimeout(() => this.#flush(), BATCH_MS);
106
+ return promise;
107
+ }
108
+
109
+ async #flush() {
110
+ clearTimeout(this._timer);
111
+ this._timer = null;
112
+ const queue = this._queue;
113
+ this._queue = new Map();
114
+
115
+ for (const [op, waiting] of queue) {
116
+ const ids = [...waiting.keys()];
117
+ try {
118
+ const res = await this.api.mintUrls(ids, op);
119
+ const now = Date.now();
120
+ for (const [id, w] of waiting) {
121
+ const got = res.urls?.[id];
122
+ if (got) {
123
+ const entry = { url: got.url, expiresAt: got.expiresAt, mintedAt: now };
124
+ this.cache.set(`${op}:${id}`, entry);
125
+ w.resolve(entry);
126
+ } else {
127
+ // Absent from the answer means this caller may not read it. A rejection, not
128
+ // a broken URL — the tile shows its icon rather than a broken-image glyph.
129
+ w.reject(new Error(res.failed?.[id] === 'not_found' ? 'That file is gone' : 'You cannot open that file'));
130
+ }
131
+ }
132
+ } catch (err) {
133
+ for (const w of waiting.values()) w.reject(err);
134
+ }
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,131 @@
1
+ // NavigationService — the main panel STACK and its browser-history mirror. The base
2
+ // of the stack is the launcher (search); opening a file pushes a viewer panel; back
3
+ // pops. The stack is mirrored into window.history (pushState/popState) so the browser
4
+ // back/forward buttons navigate it. Also owns the "recent files" list. Extracted from
5
+ // WorkbenchService; the workbench coordinates the couplings it can't own (setting the
6
+ // activity to 'home' and closing the modal search when a file opens).
7
+
8
+ import { cell } from '../runtime.js';
9
+
10
+ const RECENTS_KEY = 'trove.recents';
11
+ const RECENTS_MAX = 12;
12
+
13
+ export class NavigationService {
14
+ constructor(context) {
15
+ this.context = context;
16
+ this.state = {
17
+ stack: [{ kind: 'search' }], // [{kind:'search'}, {kind:'file', id, node, openerId}, …]
18
+ activeTabId: null, // top file panel id (or null)
19
+ activeFile: null, // top file panel node (or null)
20
+ recents: loadRecents(),
21
+ };
22
+ this.cell = cell(this.state);
23
+ }
24
+
25
+ observe() {
26
+ return this.cell;
27
+ }
28
+ #set(patch) {
29
+ this.state = { ...this.state, ...patch };
30
+ this.cell.setValue(this.state);
31
+ }
32
+
33
+ #top() {
34
+ return this.state.stack[this.state.stack.length - 1];
35
+ }
36
+ /** The active file panel (top of the stack, if it's a file). */
37
+ activeTab() {
38
+ const t = this.#top();
39
+ return t?.kind === 'file' ? t : null; // panel kind, not node kind
40
+ }
41
+
42
+ #applyStack(stack, { history = true } = {}) {
43
+ const top = stack[stack.length - 1];
44
+ const file = top && top.kind === 'file' ? top : null;
45
+ this.#set({ stack, activeTabId: file ? file.id : null, activeFile: file ? file.node : null });
46
+ this.context.setMany({ 'editor.open': !!file, 'editor.openerId': file?.openerId || '', 'editor.contentType': file?.node.contentType || '' });
47
+ if (history) this.#pushHistory();
48
+ }
49
+
50
+ /** Reset to the base launcher (used by showHome). */
51
+ reset() {
52
+ this.#applyStack([{ kind: 'search' }]);
53
+ }
54
+
55
+ /**
56
+ * Open a file into the viewer stack. `reset` (used by the modal search) starts a
57
+ * fresh stack (base search + this file). Opening a file already in the stack
58
+ * jumps back to it; otherwise it's pushed on top.
59
+ */
60
+ openFile(node, openerId, { reset = false } = {}) {
61
+ const panel = { kind: 'file', id: node.id, node, openerId };
62
+ let stack;
63
+ if (reset) {
64
+ stack = [{ kind: 'search' }, panel];
65
+ } else {
66
+ const at = this.state.stack.findIndex((p) => p.kind === 'file' && p.id === node.id);
67
+ if (at >= 0) {
68
+ // Already open — jump back to it, but adopt the (possibly new) opener so
69
+ // reopening with a different one actually switches the viewer.
70
+ stack = this.state.stack.slice(0, at + 1);
71
+ stack[at] = panel;
72
+ } else {
73
+ stack = [...this.state.stack, panel];
74
+ }
75
+ }
76
+ this.#pushRecent(node);
77
+ this.#applyStack(stack);
78
+ }
79
+ back() {
80
+ if (this.state.stack.length <= 1) return;
81
+ try { history.back(); } catch { this.pop(); }
82
+ }
83
+ pop() {
84
+ if (this.state.stack.length > 1) this.#applyStack(this.state.stack.slice(0, -1), { history: false });
85
+ }
86
+ closeTab(id) {
87
+ const stack = this.state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
88
+ this.#applyStack(stack.length ? stack : [{ kind: 'search' }], { history: false });
89
+ }
90
+ updateTabNode(node) {
91
+ const stack = this.state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
92
+ this.#applyStack(stack, { history: false });
93
+ }
94
+
95
+ // --- history sync ----------------------------------------------------------
96
+ #pushHistory(replace = false) {
97
+ const top = this.#top();
98
+ const n = top?.node;
99
+ const entry = {
100
+ troveDepth: this.state.stack.length,
101
+ node: n ? { id: n.id, name: n.name, contentType: n.contentType, collectionId: n.collectionId } : null,
102
+ openerId: top?.openerId || null,
103
+ };
104
+ try { (replace ? history.replaceState : history.pushState).call(history, entry, ''); } catch { /* no history API */ }
105
+ }
106
+ /** Handle a browser back/forward: sync the stack depth (re-open on forward). */
107
+ onPopState(e) {
108
+ const s = e?.state || {};
109
+ const depth = s.troveDepth || 1;
110
+ if (depth <= this.state.stack.length) {
111
+ this.#applyStack(this.state.stack.slice(0, depth), { history: false });
112
+ } else if (s.node) {
113
+ this.#applyStack([...this.state.stack, { kind: 'file', id: s.node.id, node: s.node, openerId: s.openerId }], { history: false });
114
+ }
115
+ }
116
+
117
+ #pushRecent(node) {
118
+ if (!node) return;
119
+ const entry = { id: node.id, name: node.name, contentType: node.contentType || '', collectionId: node.collectionId };
120
+ const recents = [entry, ...this.state.recents.filter((r) => r.id !== node.id)].slice(0, RECENTS_MAX);
121
+ this.#set({ recents });
122
+ saveRecents(recents);
123
+ }
124
+ }
125
+
126
+ function loadRecents() {
127
+ try { return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []; } catch { return []; }
128
+ }
129
+ function saveRecents(recents) {
130
+ try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); } catch { /* private mode / no storage */ }
131
+ }
@@ -0,0 +1,50 @@
1
+ // NotificationService — transient toasts + a small log. Everything user-facing
2
+ // that fails routes here so problems are never silent. Reactive: the status/UI
3
+ // watches `observe()`.
4
+
5
+ import { cell } from '../runtime.js';
6
+
7
+ let seq = 0;
8
+
9
+ export class NotificationService {
10
+ constructor() {
11
+ this.items = [];
12
+ this.cell = cell([]);
13
+ }
14
+ observe() {
15
+ return this.cell;
16
+ }
17
+ #push(level, message, opts = {}) {
18
+ const item = {
19
+ id: ++seq, level, message,
20
+ actions: opts.actions || null,
21
+ createdAt: Date.now(),
22
+ sticky: opts.sticky || level === 'error',
23
+ };
24
+ this.items = [...this.items, item];
25
+ this.cell.setValue(this.items);
26
+ if (!item.sticky) setTimeout(() => this.dismiss(item.id), opts.timeout ?? 4000);
27
+ return item.id;
28
+ }
29
+ info(m, o) {
30
+ return this.#push('info', m, o);
31
+ }
32
+ success(m, o) {
33
+ return this.#push('success', m, o);
34
+ }
35
+ warn(m, o) {
36
+ return this.#push('warn', m, o);
37
+ }
38
+ error(m, o) {
39
+ return this.#push('error', m, o);
40
+ }
41
+ /** Update a notification in place (e.g. a progress message). */
42
+ update(id, patch) {
43
+ this.items = this.items.map((i) => (i.id === id ? { ...i, ...patch } : i));
44
+ this.cell.setValue(this.items);
45
+ }
46
+ dismiss(id) {
47
+ this.items = this.items.filter((i) => i.id !== id);
48
+ this.cell.setValue(this.items);
49
+ }
50
+ }
@@ -0,0 +1,81 @@
1
+ // OverlayService — the shell's transient overlays: the command palette, modal
2
+ // dialogs, the right-click context menu, and the plugin popup panel. Extracted from
3
+ // WorkbenchService (which kept ~10 concerns); these four are independent of the panel
4
+ // stack, so they own their own state + subject. The workbench holds one and delegates,
5
+ // coordinating only the Esc/close-topmost ordering (which also spans the stack).
6
+
7
+ import { cell } from '../runtime.js';
8
+
9
+ export class OverlayService {
10
+ constructor(context) {
11
+ this.context = context;
12
+ this.state = {
13
+ palette: null, // { mode: 'commands'|'files', query, index } when open
14
+ dialog: null,
15
+ contextMenu: null,
16
+ pluginPanel: null,
17
+ };
18
+ this.cell = cell(this.state);
19
+ }
20
+
21
+ observe() {
22
+ return this.cell;
23
+ }
24
+ #set(patch) {
25
+ this.state = { ...this.state, ...patch };
26
+ this.cell.setValue(this.state);
27
+ }
28
+
29
+ // --- command palette -------------------------------------------------------
30
+ openPalette(mode = 'commands', query = '') {
31
+ this.#set({ palette: { mode, query, index: 0 } });
32
+ this.context.set('palette.open', true);
33
+ }
34
+ setPaletteQuery(query) {
35
+ if (this.state.palette) this.#set({ palette: { ...this.state.palette, query, index: 0 } });
36
+ }
37
+ movePalette(delta, count) {
38
+ if (!this.state.palette || !count) return;
39
+ this.#set({ palette: { ...this.state.palette, index: wrapIndex(this.state.palette.index, delta, count) } });
40
+ }
41
+ setPaletteIndex(index) {
42
+ if (this.state.palette) this.#set({ palette: { ...this.state.palette, index } });
43
+ }
44
+ closePalette() {
45
+ this.#set({ palette: null });
46
+ this.context.set('palette.open', false);
47
+ }
48
+
49
+ // --- dialog ----------------------------------------------------------------
50
+ showDialog(dialog) {
51
+ this.#set({ dialog });
52
+ }
53
+ /** Merge a patch into the open dialog's state (reactive dialogs, e.g. the chooser). */
54
+ updateDialog(patch) {
55
+ if (this.state.dialog) this.#set({ dialog: { ...this.state.dialog, ...patch } });
56
+ }
57
+ closeDialog() {
58
+ this.#set({ dialog: null });
59
+ }
60
+
61
+ // --- context menu ----------------------------------------------------------
62
+ showContextMenu(x, y, items) {
63
+ this.#set({ contextMenu: { x, y, items } });
64
+ }
65
+ closeContextMenu() {
66
+ this.#set({ contextMenu: null });
67
+ }
68
+
69
+ // --- plugin popup panel ----------------------------------------------------
70
+ openPluginPanel(pluginId) {
71
+ this.#set({ pluginPanel: pluginId });
72
+ }
73
+ closePluginPanel() {
74
+ this.#set({ pluginPanel: null });
75
+ }
76
+ }
77
+
78
+ /** Wrap a list cursor by `delta` within `[0, count)` (shared by the palette + launcher). */
79
+ export function wrapIndex(index, delta, count) {
80
+ return (index + delta + count) % count;
81
+ }
@@ -0,0 +1,132 @@
1
+ // Client-side plugin storage: an on-device SQLite database per scope, run in the
2
+ // HOST with sql.js (wasm) and persisted to IndexedDB. The sandboxed iframe can't
3
+ // touch IndexedDB (opaque origin), so — like everything else — the host holds the
4
+ // store and the plugin reaches it over RPC. It exposes the SAME async
5
+ // SqliteDatabase surface as the server side, so ctx.storage.*.client mirrors
6
+ // .server. Lazy: the wasm only loads the first time a plugin uses client storage.
7
+
8
+ let sqlPromise = null;
9
+ function loadSql() {
10
+ // locateFile points at the wasm we serve at the app root (build copies it; dev &
11
+ // test serve it via middleware). Only fetched on first use. On failure we clear
12
+ // the cached promise so a later attempt can retry rather than wedging forever.
13
+ if (!sqlPromise) {
14
+ sqlPromise = import('sql.js')
15
+ .then((m) => (m.default || m)({ locateFile: () => '/sql-wasm.wasm' }))
16
+ .catch((err) => { sqlPromise = null; throw err; });
17
+ }
18
+ return sqlPromise;
19
+ }
20
+
21
+ // --- IndexedDB blob persistence (host origin) --------------------------------
22
+
23
+ const IDB = 'trove-plugin-clientdb';
24
+ const STORE = 'dbs';
25
+
26
+ function openIdb() {
27
+ return new Promise((resolve, reject) => {
28
+ const req = indexedDB.open(IDB, 1);
29
+ req.onupgradeneeded = () => {
30
+ const db = req.result;
31
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
32
+ };
33
+ req.onsuccess = () => resolve(req.result);
34
+ req.onerror = () => reject(req.error);
35
+ });
36
+ }
37
+ function idbOp(mode, fn) {
38
+ return openIdb().then((db) => new Promise((resolve, reject) => {
39
+ const t = db.transaction(STORE, mode);
40
+ let out;
41
+ Promise.resolve(fn(t.objectStore(STORE))).then((r) => (out = r));
42
+ t.oncomplete = () => { db.close(); resolve(out); };
43
+ t.onerror = () => { db.close(); reject(t.error); };
44
+ }));
45
+ }
46
+ const reqP = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
47
+ const loadBytes = (key) => idbOp('readonly', (s) => reqP(s.get(key)));
48
+ const saveBytes = (key, bytes) => idbOp('readwrite', (s) => s.put(bytes, key));
49
+ const dropBytes = (key) => idbOp('readwrite', (s) => s.delete(key));
50
+
51
+ // --- a single scoped database (conforms to SqliteDatabase) -------------------
52
+
53
+ class ClientDatabase {
54
+ constructor(key, db) {
55
+ this.key = key;
56
+ this.db = db;
57
+ this._saveTimer = null;
58
+ }
59
+ // Debounce persistence: export the whole (small, per-scope) db to IndexedDB
60
+ // shortly after the last write.
61
+ //
62
+ // The write already returned `{ok:true}` to the plugin by the time this runs, so a
63
+ // failure here is data the plugin believes it saved and hasn't. Nothing can be
64
+ // returned to it any more — but it must at least be visible, not swallowed.
65
+ #dirty() {
66
+ clearTimeout(this._saveTimer);
67
+ this._saveTimer = setTimeout(() => {
68
+ this.flush().catch((err) => {
69
+ console.error(`persisting plugin storage "${this.key}" failed`, err);
70
+ this.onError?.(err, this.key);
71
+ });
72
+ }, 150);
73
+ }
74
+ async flush() {
75
+ clearTimeout(this._saveTimer);
76
+ this._saveTimer = null;
77
+ await saveBytes(this.key, this.db.export());
78
+ }
79
+ async exec(sql) { this.db.exec(sql); this.#dirty(); return { ok: true }; }
80
+ async run(sql, ...params) { this.db.run(sql, params); this.#dirty(); return { ok: true }; }
81
+ async get(sql, ...params) {
82
+ const stmt = this.db.prepare(sql);
83
+ try { stmt.bind(params); return stmt.step() ? stmt.getAsObject() : null; }
84
+ finally { stmt.free(); }
85
+ }
86
+ async all(sql, ...params) {
87
+ const stmt = this.db.prepare(sql);
88
+ const out = [];
89
+ try { stmt.bind(params); while (stmt.step()) out.push(stmt.getAsObject()); }
90
+ finally { stmt.free(); }
91
+ return out;
92
+ }
93
+ async batch(statements = []) {
94
+ this.db.run('BEGIN');
95
+ try {
96
+ for (const { sql, params = [] } of statements) this.db.run(sql, params);
97
+ this.db.run('COMMIT');
98
+ } catch (err) {
99
+ try { this.db.run('ROLLBACK'); } catch { /* already rolled back */ }
100
+ throw err;
101
+ }
102
+ this.#dirty();
103
+ return { ok: true };
104
+ }
105
+ close() { clearTimeout(this._saveTimer); this.db.close(); }
106
+ }
107
+
108
+ // --- the pool ----------------------------------------------------------------
109
+
110
+ export class ClientSqlProvider {
111
+ constructor({ onError } = {}) { this._pool = new Map(); this.onError = onError || null; }
112
+
113
+ async obtain(key) {
114
+ let db = this._pool.get(key);
115
+ if (!db) {
116
+ const SQL = await loadSql();
117
+ const bytes = await loadBytes(key);
118
+ db = new ClientDatabase(key, new SQL.Database(bytes || undefined));
119
+ db.onError = this.onError;
120
+ this._pool.set(key, db);
121
+ }
122
+ return db;
123
+ }
124
+
125
+ /** Drop a scope's database. Throws if the bytes can't be deleted — the caller
126
+ * (uninstall) reports leftover on-device data rather than claiming a clean wipe. */
127
+ async drop(key) {
128
+ const db = this._pool.get(key);
129
+ if (db) { db.close(); this._pool.delete(key); }
130
+ await dropBytes(key);
131
+ }
132
+ }
@@ -0,0 +1,141 @@
1
+ // FrameDock — where a plugin frame's iframe is *shown*, and the floating dock (PiP).
2
+ //
3
+ // A frame's iframe is created once and stays a child of <body> for its life — we
4
+ // NEVER re-parent it, because moving an <iframe> in the DOM reloads its document (per
5
+ // the HTML spec) and would kill the running plugin + its MessagePort. To show a frame
6
+ // "inside" a panel/viewer/dock we float it as a position:fixed overlay whose inset
7
+ // tracks the target element's box (`place`), recomputed on intersection/resize/scroll
8
+ // rather than every animation frame — the viewer area only moves on layout changes,
9
+ // so this stays idle when nothing shifts.
10
+ //
11
+ // The dock is the single floating mini-player a viewer becomes when you navigate away
12
+ // (video → PiP, audio → mini transport), if the viewer opted in via `dock.enable`.
13
+
14
+ export class FrameDock {
15
+ /**
16
+ * @param {object} deps
17
+ * @param {(frame:object)=>void} deps.destroyFrame tear a frame down for good
18
+ * @param {(node:object, openerId:string)=>void} deps.openFile reopen the docked file
19
+ * @param {()=>void} [deps.onChange] notify listeners (the dock affects plugin state)
20
+ */
21
+ constructor({ destroyFrame, openFile, onChange } = {}) {
22
+ this.destroyFrame = destroyFrame;
23
+ this.openFile = openFile;
24
+ this.onChange = onChange || (() => {});
25
+ this.docked = null; // the frame currently in the dock
26
+ this.el = null; // the dock's DOM host (created lazily)
27
+ }
28
+
29
+ // --- placement -------------------------------------------------------------
30
+
31
+ /** Float `frame`'s iframe over `targetEl` and keep its inset aligned. */
32
+ place(frame, targetEl, z, radius = '') {
33
+ this.stopPlace(frame);
34
+ const f = frame.iframe;
35
+ f.style.cssText = `position:fixed;border:0;visibility:visible;display:block;background:transparent;z-index:${z};margin:0;padding:0;border-radius:${radius};`;
36
+ const sync = () => {
37
+ const r = targetEl.getBoundingClientRect();
38
+ const shown = r.width > 0 && r.height > 0 && document.contains(targetEl);
39
+ f.style.left = `${r.left}px`;
40
+ f.style.top = `${r.top}px`;
41
+ f.style.width = `${r.width}px`;
42
+ f.style.height = `${r.height}px`;
43
+ f.style.visibility = shown ? 'visible' : 'hidden';
44
+ };
45
+ sync();
46
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(sync) : null;
47
+ ro?.observe(targetEl);
48
+ if (document.body) ro?.observe(document.body);
49
+ const io = typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver(sync, { threshold: [0, 1] }) : null;
50
+ io?.observe(targetEl);
51
+ window.addEventListener('scroll', sync, true);
52
+ window.addEventListener('resize', sync);
53
+ frame.place = {
54
+ target: targetEl,
55
+ stop: () => {
56
+ ro?.disconnect();
57
+ io?.disconnect();
58
+ window.removeEventListener('scroll', sync, true);
59
+ window.removeEventListener('resize', sync);
60
+ },
61
+ };
62
+ }
63
+
64
+ stopPlace(frame) {
65
+ if (frame.place) { frame.place.stop(); frame.place = null; }
66
+ }
67
+
68
+ /** Stop tracking and park the iframe offscreen (kept alive, just not shown). */
69
+ hide(frame) {
70
+ this.stopPlace(frame);
71
+ frame.iframe.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;border:0;visibility:hidden;';
72
+ }
73
+
74
+ /** Frame teardown hook: stop placement and drop the dock if this frame was in it. */
75
+ releaseFrame(frame) {
76
+ this.stopPlace(frame);
77
+ if (this.docked === frame) { this.docked = null; this.#hideEl(); }
78
+ }
79
+
80
+ // --- dock ------------------------------------------------------------------
81
+
82
+ #hideEl() {
83
+ if (this.el) this.el.style.display = 'none';
84
+ }
85
+
86
+ /** Float a viewer frame as the single floating dock, sized by its declared minimums. */
87
+ dock(frame) {
88
+ if (this.docked && this.docked !== frame) this.closeDock(this.docked);
89
+ const min = frame.dock?.minSize || { width: 300, height: 90 };
90
+ const el = this.#dockEl();
91
+ el.style.width = `${clampDim(min.width, 200, 480)}px`;
92
+ el.style.height = `${clampDim(min.height, 56, 360) + 26}px`; // + header
93
+ el.querySelector('.vd-title').textContent = frame.node?.name || (frame.record.manifest.displayName || frame.record.manifest.name);
94
+ el.style.display = 'flex';
95
+ // Round the frame's bottom corners to match the dock's rounded body.
96
+ this.place(frame, el.querySelector('.vd-body'), 61, '0 0 11px 11px');
97
+ this.docked = frame;
98
+ frame.channel?.emit('dock:state', { docked: true });
99
+ this.onChange();
100
+ }
101
+
102
+ /** Un-dock without destroying — used when a docked frame is re-adopted into a viewer. */
103
+ undock(frame) {
104
+ this.#hideEl();
105
+ if (this.docked === frame) this.docked = null;
106
+ frame.channel?.emit('dock:state', { docked: false });
107
+ }
108
+
109
+ /** Dismiss the dock: the user closed it, or the viewer disabled docking. The frame
110
+ * is done, so tear it down. */
111
+ closeDock(frame) {
112
+ this.#hideEl();
113
+ if (this.docked === frame) this.docked = null;
114
+ frame.channel?.emit('dock:state', { docked: false, closed: true });
115
+ this.destroyFrame?.(frame);
116
+ this.onChange();
117
+ }
118
+
119
+ #dockEl() {
120
+ if (this.el) return this.el;
121
+ const el = document.createElement('div');
122
+ el.className = 'viewer-dock';
123
+ el.innerHTML = '<div class="vd-bar"><span class="vd-title"></span><button class="vd-expand" title="Reopen">↗</button><button class="vd-close" title="Close">✕</button></div><div class="vd-body"></div>';
124
+ el.querySelector('.vd-expand').addEventListener('click', () => {
125
+ const frame = this.docked;
126
+ if (frame?.node) this.openFile?.(frame.node, frame.openerId);
127
+ });
128
+ el.querySelector('.vd-close').addEventListener('click', () => {
129
+ if (this.docked) this.closeDock(this.docked);
130
+ });
131
+ document.body.appendChild(el);
132
+ this.el = el;
133
+ return el;
134
+ }
135
+ }
136
+
137
+ // Clamp a viewer-declared dock dimension into a sane host range (defaulting to the
138
+ // low bound when the viewer gives nothing).
139
+ function clampDim(v, lo, hi) {
140
+ return Math.max(lo, Math.min(hi, v || lo));
141
+ }