@3sln/trove 0.0.12 → 0.0.13
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.
- package/README.md +13 -1
- package/package.json +2 -1
- package/packages/core/src/collections/index.js +71 -10
- package/packages/core/src/encryption/keys.js +4 -3
- package/packages/core/src/encryption/policy.js +14 -7
- package/packages/core/src/encryption/rotation.js +46 -12
- package/packages/core/src/errors.js +8 -0
- package/packages/core/src/index.js +8 -2
- package/packages/core/src/indexers/contribution.js +18 -17
- package/packages/core/src/indexing.js +8 -4
- package/packages/core/src/issues.js +8 -0
- package/packages/core/src/links.js +21 -9
- package/packages/core/src/metadata/interface.js +22 -0
- package/packages/core/src/metadata/memory.js +15 -0
- package/packages/core/src/metadata/sqlite.js +23 -1
- package/packages/core/src/plugins/contributions.js +13 -0
- package/packages/core/src/rateLimit.js +253 -0
- package/packages/core/src/scan.js +44 -16
- package/packages/core/src/sidecar/document.js +18 -14
- package/packages/core/src/sidecar/index.js +9 -10
- package/packages/core/src/sidecar/manager.js +10 -15
- package/packages/core/src/uploads.js +40 -15
- package/packages/core/src/vfs.js +59 -34
- package/packages/plugin-sdk/src/browser.js +155 -0
- package/packages/plugin-sdk/src/protocol.js +13 -1
- package/packages/server/src/adapters/bun.js +1 -2
- package/packages/server/src/adapters/node.js +1 -2
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +17 -34
- package/packages/server/src/engine/providers/core.js +112 -14
- package/packages/server/src/index.js +145 -103
- package/packages/server/src/mcp/index.js +7 -12
- package/packages/server/src/mcp/tools.js +24 -29
- package/packages/server/src/router.js +31 -8
- package/packages/server/src/routes.js +74 -113
- package/packages/server/src/scope.js +78 -0
- package/packages/web/dist/assets/main-828yzsr7.js +511 -0
- package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +226 -104
- package/packages/web/src/bl/activity.js +32 -82
- package/packages/web/src/bl/index.js +23 -11
- package/packages/web/src/bl/launcher.js +2 -9
- package/packages/web/src/bl/links.js +31 -18
- package/packages/web/src/bl/offline.js +24 -10
- package/packages/web/src/bl/pluginInstall.js +29 -26
- package/packages/web/src/bl/queries.js +76 -68
- package/packages/web/src/bl/services.js +22 -14
- package/packages/web/src/bl/social.js +39 -25
- package/packages/web/src/bl/state.js +22 -4
- package/packages/web/src/bl/viewState.js +9 -0
- package/packages/web/src/bl/watchQuery.js +1 -8
- package/packages/web/src/dispatch.js +26 -0
- package/packages/web/src/platform/api.js +32 -0
- package/packages/web/src/platform/commands.js +14 -15
- package/packages/web/src/platform/context.js +3 -58
- package/packages/web/src/platform/contributions.js +1 -35
- package/packages/web/src/platform/fileChunks.js +281 -0
- package/packages/web/src/platform/index.js +8 -9
- package/packages/web/src/platform/mediaUrls.js +10 -0
- package/packages/web/src/platform/navigation.js +35 -21
- package/packages/web/src/platform/pickers.js +39 -19
- package/packages/web/src/platform/pluginHost.js +4 -1
- package/packages/web/src/platform/pluginRpc.js +47 -2
- package/packages/web/src/platform/spatialNav.js +1 -1
- package/packages/web/src/platform/viewport.js +14 -6
- package/packages/web/src/platform/voiceSearch.js +17 -9
- package/packages/web/src/platform/whenclause.js +9 -19
- package/packages/web/src/runtime.js +2 -37
- package/packages/web/src/ui/activate.js +18 -2
- package/packages/web/src/ui/components/activityPanel.js +1 -1
- package/packages/web/src/ui/components/adminView.js +4 -7
- package/packages/web/src/ui/components/editorArea.js +10 -1
- package/packages/web/src/ui/components/launcher.js +29 -13
- package/packages/web/src/ui/components/openers/index.js +17 -22
- package/packages/web/src/ui/components/openers/markdown.js +2 -2
- package/packages/web/src/ui/components/overlays.js +31 -5
- package/packages/web/src/ui/components/pluginReview.js +7 -4
- package/packages/web/src/ui/components/pluginsView.js +1 -1
- package/packages/web/src/ui/components/settingsView.js +30 -27
- package/packages/web/src/ui/components/statusBar.js +8 -6
- package/packages/web/src/ui/components/views/grid.js +2 -2
- package/packages/web/src/ui/components/views/parts.js +1 -1
- package/packages/web/src/ui/compositions/workbench.js +20 -6
- package/packages/web/src/ui/sanitize.js +0 -5
- package/packages/web/src/workbench.js +37 -19
- package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
- package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
|
@@ -11,26 +11,40 @@ const RECENTS_KEY = 'trove.recents';
|
|
|
11
11
|
const RECENTS_MAX = 12;
|
|
12
12
|
|
|
13
13
|
export class NavigationService {
|
|
14
|
+
#state;
|
|
15
|
+
|
|
14
16
|
constructor() {
|
|
15
|
-
this
|
|
17
|
+
this.#state = {
|
|
16
18
|
stack: [{ kind: 'search' }], // [{kind:'search'}, {kind:'file', id, node, openerId}, …]
|
|
17
19
|
activeTabId: null, // top file panel id (or null)
|
|
18
20
|
activeFile: null, // top file panel node (or null)
|
|
19
21
|
recents: loadRecents(),
|
|
20
22
|
};
|
|
21
|
-
this.cell = cell(this
|
|
23
|
+
this.cell = cell(this.#state);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The value, for whoever is about to decide something from it.
|
|
28
|
+
*
|
|
29
|
+
* The same door every slice offers. Actions read `.state` and queries read `.cell`, and
|
|
30
|
+
* the two are only equal by habit — bl/state.js says so, and says a resource has one
|
|
31
|
+
* value and one way to read it. `state` is also a public field on a service, which is one
|
|
32
|
+
* typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
|
|
33
|
+
*/
|
|
34
|
+
get() {
|
|
35
|
+
return this.#state;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
observe() {
|
|
25
39
|
return this.cell;
|
|
26
40
|
}
|
|
27
41
|
#set(patch) {
|
|
28
|
-
this
|
|
29
|
-
this.cell.setValue(this
|
|
42
|
+
this.#state = { ...this.#state, ...patch };
|
|
43
|
+
this.cell.setValue(this.#state);
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
#top() {
|
|
33
|
-
return this
|
|
47
|
+
return this.#state.stack[this.#state.stack.length - 1];
|
|
34
48
|
}
|
|
35
49
|
/** The active file panel (top of the stack, if it's a file). */
|
|
36
50
|
activeTab() {
|
|
@@ -61,25 +75,25 @@ export class NavigationService {
|
|
|
61
75
|
if (reset) {
|
|
62
76
|
stack = [{ kind: 'search' }, panel];
|
|
63
77
|
} else {
|
|
64
|
-
const at = this
|
|
78
|
+
const at = this.#state.stack.findIndex((p) => p.kind === 'file' && p.id === node.id);
|
|
65
79
|
if (at >= 0) {
|
|
66
80
|
// Already open — jump back to it, but adopt the (possibly new) opener so
|
|
67
81
|
// reopening with a different one actually switches the viewer.
|
|
68
|
-
stack = this
|
|
82
|
+
stack = this.#state.stack.slice(0, at + 1);
|
|
69
83
|
stack[at] = panel;
|
|
70
84
|
} else {
|
|
71
|
-
stack = [...this
|
|
85
|
+
stack = [...this.#state.stack, panel];
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
88
|
this.#pushRecent(node);
|
|
75
89
|
this.#applyStack(stack);
|
|
76
90
|
}
|
|
77
91
|
back() {
|
|
78
|
-
if (this
|
|
92
|
+
if (this.#state.stack.length <= 1) return;
|
|
79
93
|
try { history.back(); } catch { this.pop(); }
|
|
80
94
|
}
|
|
81
95
|
pop() {
|
|
82
|
-
if (this
|
|
96
|
+
if (this.#state.stack.length > 1) this.#applyStack(this.#state.stack.slice(0, -1), { history: false });
|
|
83
97
|
}
|
|
84
98
|
/**
|
|
85
99
|
* The node is GONE — drop it from everywhere this service holds a copy.
|
|
@@ -94,12 +108,12 @@ export class NavigationService {
|
|
|
94
108
|
* round — a tile that leads nowhere is worse than one that is missing.
|
|
95
109
|
*/
|
|
96
110
|
forget(id) {
|
|
97
|
-
const recents = this
|
|
98
|
-
if (recents.length !== this
|
|
111
|
+
const recents = this.#state.recents.filter((r) => r.id !== id);
|
|
112
|
+
if (recents.length !== this.#state.recents.length) {
|
|
99
113
|
this.#set({ recents });
|
|
100
114
|
saveRecents(recents);
|
|
101
115
|
}
|
|
102
|
-
const stack = this
|
|
116
|
+
const stack = this.#state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
|
|
103
117
|
this.#applyStack(stack.length ? stack : [{ kind: 'search' }], { history: false });
|
|
104
118
|
}
|
|
105
119
|
/**
|
|
@@ -111,14 +125,14 @@ export class NavigationService {
|
|
|
111
125
|
* list, which reads as the rename half-failing.
|
|
112
126
|
*/
|
|
113
127
|
updateTabNode(node) {
|
|
114
|
-
const recents = this
|
|
128
|
+
const recents = this.#state.recents.map((r) => (r.id === node.id
|
|
115
129
|
? { ...r, name: node.name, contentType: node.contentType || r.contentType }
|
|
116
130
|
: r));
|
|
117
|
-
if (recents.some((r, i) => r !== this
|
|
131
|
+
if (recents.some((r, i) => r !== this.#state.recents[i])) {
|
|
118
132
|
this.#set({ recents });
|
|
119
133
|
saveRecents(recents);
|
|
120
134
|
}
|
|
121
|
-
const stack = this
|
|
135
|
+
const stack = this.#state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
|
|
122
136
|
this.#applyStack(stack, { history: false });
|
|
123
137
|
}
|
|
124
138
|
|
|
@@ -127,7 +141,7 @@ export class NavigationService {
|
|
|
127
141
|
const top = this.#top();
|
|
128
142
|
const n = top?.node;
|
|
129
143
|
const entry = {
|
|
130
|
-
troveDepth: this
|
|
144
|
+
troveDepth: this.#state.stack.length,
|
|
131
145
|
node: n ? { id: n.id, name: n.name, contentType: n.contentType, collectionId: n.collectionId } : null,
|
|
132
146
|
openerId: top?.openerId || null,
|
|
133
147
|
};
|
|
@@ -137,17 +151,17 @@ export class NavigationService {
|
|
|
137
151
|
onPopState(e) {
|
|
138
152
|
const s = e?.state || {};
|
|
139
153
|
const depth = s.troveDepth || 1;
|
|
140
|
-
if (depth <= this
|
|
141
|
-
this.#applyStack(this
|
|
154
|
+
if (depth <= this.#state.stack.length) {
|
|
155
|
+
this.#applyStack(this.#state.stack.slice(0, depth), { history: false });
|
|
142
156
|
} else if (s.node) {
|
|
143
|
-
this.#applyStack([...this
|
|
157
|
+
this.#applyStack([...this.#state.stack, { kind: 'file', id: s.node.id, node: s.node, openerId: s.openerId }], { history: false });
|
|
144
158
|
}
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
#pushRecent(node) {
|
|
148
162
|
if (!node) return;
|
|
149
163
|
const entry = { id: node.id, name: node.name, contentType: node.contentType || '', collectionId: node.collectionId };
|
|
150
|
-
const recents = [entry, ...this
|
|
164
|
+
const recents = [entry, ...this.#state.recents.filter((r) => r.id !== node.id)].slice(0, RECENTS_MAX);
|
|
151
165
|
this.#set({ recents });
|
|
152
166
|
saveRecents(recents);
|
|
153
167
|
}
|
|
@@ -8,31 +8,51 @@
|
|
|
8
8
|
// They were private to bl/commands.js, where the command handlers that used them lived.
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Open a native file picker.
|
|
11
|
+
* Open a native file picker. Resolves with the chosen files, or `null` if cancelled.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* A PROMISE rather than a callback, because the action that opens the picker has to be able
|
|
14
|
+
* to wait for it. It could not before: `execute` returned while the OS dialog was still on
|
|
15
|
+
* screen, ngin released the leases in its `finally`, the feed emitted `complete` for an
|
|
16
|
+
* upload that had not begun, and the callback then used `engine` and `explorer` afterwards.
|
|
17
|
+
*
|
|
18
|
+
* SETTLING ON BOTH PATHS is the whole difficulty, and the reason this shape was not taken
|
|
19
|
+
* earlier: cancelling fires no `change` event at all. It is detected the only way the
|
|
20
|
+
* platform allows — the OS dialog returns focus to the window, and after a short grace the
|
|
21
|
+
* input still holds no files. Both paths go through `done`, and `resolve` is idempotent, so
|
|
22
|
+
* whichever happens first settles the promise and the other is a no-op. A picker that could
|
|
23
|
+
* fail to settle would hold its action's lease forever and never emit a terminal event,
|
|
24
|
+
* which is strictly worse than the early `complete` this replaces.
|
|
25
|
+
*
|
|
26
|
+
* The hidden `<input>` is removed on both paths too, so a cancelled picker does not leak
|
|
27
|
+
* one per attempt.
|
|
16
28
|
*/
|
|
17
|
-
function pick(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
function pick(configure) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const input = document.createElement('input');
|
|
32
|
+
input.type = 'file';
|
|
33
|
+
configure(input);
|
|
34
|
+
input.style.display = 'none';
|
|
35
|
+
document.body.appendChild(input);
|
|
36
|
+
const done = (files) => {
|
|
37
|
+
resolve(files);
|
|
38
|
+
input.remove();
|
|
39
|
+
window.removeEventListener('focus', onFocus);
|
|
40
|
+
};
|
|
41
|
+
const onFocus = () => setTimeout(() => { if (!input.files.length) done(null); }, 300);
|
|
42
|
+
input.addEventListener('change', () => done(input.files), { once: true });
|
|
43
|
+
window.addEventListener('focus', onFocus);
|
|
44
|
+
input.click();
|
|
45
|
+
});
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
/** @returns {Promise<FileList|null>} */
|
|
49
|
+
export function pickFiles() {
|
|
50
|
+
return pick((input) => { input.multiple = true; });
|
|
32
51
|
}
|
|
33
52
|
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
/** @returns {Promise<File|null>} */
|
|
54
|
+
export function pickZip() {
|
|
55
|
+
return pick((input) => { input.accept = '.zip,application/zip'; }).then((files) => files?.[0] ?? null);
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
/** Hand a URL to the browser as a download. */
|
|
@@ -57,7 +57,10 @@ export class PluginHost {
|
|
|
57
57
|
destroyFrame: (frame) => this.frames.destroy(frame),
|
|
58
58
|
// Dispatched, not called: opening a file from a docked plugin frame is the same
|
|
59
59
|
// intent as opening one from the drive, and the engine should see both.
|
|
60
|
-
|
|
60
|
+
// Not `dispatch?.()`. An unwired seam here silently drops a docked frame's
|
|
61
|
+
// open-file, which is exactly the shape server/src/index.js records as having turned
|
|
62
|
+
// a sweep into a permanent no-op.
|
|
63
|
+
openFile: (node, openerId) => this.platform.dispatch(new OpenInPanelAction(node, openerId)),
|
|
61
64
|
onChange: () => this.#emit(),
|
|
62
65
|
});
|
|
63
66
|
this.frames = new FrameManager({ media: this.media, dock: this.dock });
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
import { networkEndpoints, canExecuteCommand, displayName } from './pluginPackage.js';
|
|
12
12
|
import { isAllowedUrl } from './pluginNet.js';
|
|
13
13
|
import { isSourceModule } from './pluginModules.js';
|
|
14
|
+
// pluginHost.js already imports actions to build this router, so this widens an existing
|
|
15
|
+
// edge rather than creating one — and it removes the third seam assigned onto `platform`
|
|
16
|
+
// after construction, which is the shape that makes the resource graph not the whole story.
|
|
17
|
+
import { OpenPluginPanelAction } from '../bl/actions.js';
|
|
14
18
|
import { contribUri, parseContribUri } from '@3sln/trove/core/plugins/identity.js';
|
|
15
19
|
import { assertSafePluginSql } from '@3sln/trove/core/plugins/sql.js';
|
|
16
20
|
|
|
@@ -143,7 +147,48 @@ export class PluginRpcRouter {
|
|
|
143
147
|
{ sort: params.sort, order: params.order, limit: params.limit, cursor: params.cursor },
|
|
144
148
|
);
|
|
145
149
|
case 'files:stat': return cap('files'), this.platform.api.stat(params.id);
|
|
146
|
-
|
|
150
|
+
|
|
151
|
+
// BYTES, by range. `files:read` answers text, which for an audiobook or any other
|
|
152
|
+
// container is not slow, it is corrupt — and `files:downloadUrl` used to hand out a
|
|
153
|
+
// bare route URL carrying no authorization, which an opaque-origin sandbox cannot
|
|
154
|
+
// make good on: no cookies, no `Authorization` header, a 401 on any drive that
|
|
155
|
+
// authenticates. So this is how a viewer gets bytes at all.
|
|
156
|
+
//
|
|
157
|
+
// Half-open `[start, end)`, and through `fileChunks` rather than the API directly:
|
|
158
|
+
// a pinned file answers from disk, a file being kept offline contributes what it
|
|
159
|
+
// fetches, and a file nobody asked to keep leaves nothing behind. See fileChunks.js.
|
|
160
|
+
case 'files:bytes': {
|
|
161
|
+
cap('files');
|
|
162
|
+
const r = await this.platform.fileChunks.read(params.id, { start: params.start, end: params.end });
|
|
163
|
+
// Transferred rather than copied: a 4 MiB chunk crossing by structured clone twice
|
|
164
|
+
// per read is the difference between a seek and a stutter.
|
|
165
|
+
return { bytes: r.bytes.buffer, etag: r.etag, total: r.total };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Keeping a file offline, which is a DIFFERENT act from reading it — see
|
|
169
|
+
// fileChunks.js. Until `start` is called, ranging over a file stores nothing.
|
|
170
|
+
case 'files:offline:start': return cap('files'), this.platform.fileChunks.start(params.id);
|
|
171
|
+
case 'files:offline:status': return cap('files'), this.platform.fileChunks.status(params.id);
|
|
172
|
+
case 'files:offline:cancel': return cap('files'), (this.platform.fileChunks.cancel(params.id), { ok: true });
|
|
173
|
+
case 'files:offline:remove': return cap('files'), this.platform.fileChunks.remove(params.id).then(() => ({ ok: true }));
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A URL a media element can load on its own.
|
|
177
|
+
*
|
|
178
|
+
* The one place a host URL deliberately crosses into the sandbox, and it is minted —
|
|
179
|
+
* it carries its own grant and expires, which is exactly what `platform/mediaUrls.js`
|
|
180
|
+
* exists for: "URLs for things that cannot send a header … an <img src>, a <video
|
|
181
|
+
* src>". The rule it bends is that plugins hold only opaque handles; the reason is
|
|
182
|
+
* that `<audio src>` is the only way to stream a progressive MP4 without a
|
|
183
|
+
* fragmenter, because MSE refuses one and a Blob has to be whole before it can
|
|
184
|
+
* become an object URL. A viewer that can range-read for chapters and stream for
|
|
185
|
+
* playback needs both, and this is the streaming half.
|
|
186
|
+
*/
|
|
187
|
+
case 'files:mediaUrl': {
|
|
188
|
+
cap('files');
|
|
189
|
+
const { url, expiresAt } = await this.platform.mediaUrls.url(params.id, { op: params.op || 'media' });
|
|
190
|
+
return { url, expiresAt };
|
|
191
|
+
}
|
|
147
192
|
case 'files:index': {
|
|
148
193
|
cap('indexer');
|
|
149
194
|
const ns = parseContribUri(params.indexerId) ? params.indexerId : contribUri(record.manifest, params.indexerId || 'default');
|
|
@@ -174,7 +219,7 @@ export class PluginRpcRouter {
|
|
|
174
219
|
case 'ui:showPanel':
|
|
175
220
|
cap('ui');
|
|
176
221
|
record.hasUi = true;
|
|
177
|
-
this.platform.
|
|
222
|
+
this.platform.dispatch(new OpenPluginPanelAction(pid));
|
|
178
223
|
this.onChange();
|
|
179
224
|
return { ok: true };
|
|
180
225
|
|
|
@@ -59,7 +59,7 @@ export class SpatialNavigationService {
|
|
|
59
59
|
/** Follow the viewport: arrows are remapped only while the TV layout is showing. */
|
|
60
60
|
install() {
|
|
61
61
|
if (this.viewport) effect(this.viewport.observe(), (vp) => this.setActive(vp.mode === 'tv'));
|
|
62
|
-
this.setActive(this.viewport?.
|
|
62
|
+
this.setActive(this.viewport?.get().mode === 'tv');
|
|
63
63
|
return this;
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -29,6 +29,8 @@ export function looksLikeTv(ua = '', width = 0) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export class ViewportService {
|
|
32
|
+
#state;
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
35
|
* @param {object} [deps]
|
|
34
36
|
* @param {Window} [deps.window] the window to measure and listen to
|
|
@@ -40,11 +42,17 @@ export class ViewportService {
|
|
|
40
42
|
// A URL override outranks the setting: it is how someone checks the phone layout on
|
|
41
43
|
// a laptop, and how the e2e suite drives each shell without a device farm.
|
|
42
44
|
this.urlOverride = readUrlOverride(win);
|
|
43
|
-
this
|
|
44
|
-
this.cell = cell(this
|
|
45
|
+
this.#state = this.#measure();
|
|
46
|
+
this.cell = cell(this.#state);
|
|
45
47
|
this._onResize = () => this.refresh();
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** The value, for whoever is about to decide something from it. See bl/state.js: one
|
|
51
|
+
* value, one way to read it — a public `state` field is a second, writable door. */
|
|
52
|
+
get() {
|
|
53
|
+
return this.#state;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
observe() {
|
|
49
57
|
return this.cell;
|
|
50
58
|
}
|
|
@@ -58,7 +66,7 @@ export class ViewportService {
|
|
|
58
66
|
// stopped returning something with `.subscribe` it would have become a silent
|
|
59
67
|
// no-op and the viewport would have stopped following its own setting.
|
|
60
68
|
if (this.settings) effect(this.settings.observe(), () => this.refresh());
|
|
61
|
-
this.#publish(this
|
|
69
|
+
this.#publish(this.#state);
|
|
62
70
|
return this;
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -69,10 +77,10 @@ export class ViewportService {
|
|
|
69
77
|
|
|
70
78
|
refresh() {
|
|
71
79
|
const next = this.#measure();
|
|
72
|
-
const same = next.mode === this
|
|
73
|
-
&& next.height === this
|
|
80
|
+
const same = next.mode === this.#state.mode && next.width === this.#state.width
|
|
81
|
+
&& next.height === this.#state.height && next.coarse === this.#state.coarse;
|
|
74
82
|
if (same) return;
|
|
75
|
-
this
|
|
83
|
+
this.#state = next;
|
|
76
84
|
this.#publish(next);
|
|
77
85
|
this.cell.setValue(next);
|
|
78
86
|
}
|
|
@@ -17,6 +17,8 @@ import { cell } from '../runtime.js';
|
|
|
17
17
|
import { canTranscribeLocally, localAvailability, installLocal, listen } from './voice.js';
|
|
18
18
|
|
|
19
19
|
export class VoiceSearchService {
|
|
20
|
+
#state;
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* @param {object} deps
|
|
22
24
|
* @param {object} deps.notifications
|
|
@@ -25,21 +27,27 @@ export class VoiceSearchService {
|
|
|
25
27
|
this.notifications = notifications;
|
|
26
28
|
this.settings = settings;
|
|
27
29
|
this.session = null;
|
|
28
|
-
this
|
|
29
|
-
this.cell = cell(this
|
|
30
|
+
this.#state = { supported: canTranscribeLocally(), status: 'unknown', listening: false };
|
|
31
|
+
this.cell = cell(this.#state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The value, for whoever is about to decide something from it. See bl/state.js: one
|
|
35
|
+
* value, one way to read it — a public `state` field is a second, writable door. */
|
|
36
|
+
get() {
|
|
37
|
+
return this.#state;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
observe() {
|
|
33
41
|
return this.cell;
|
|
34
42
|
}
|
|
35
43
|
#set(patch) {
|
|
36
|
-
this
|
|
37
|
-
this.cell.setValue(this
|
|
44
|
+
this.#state = { ...this.#state, ...patch };
|
|
45
|
+
this.cell.setValue(this.#state);
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/** Ask once whether an on-device language pack is ready, and remember the answer. */
|
|
41
49
|
async refresh() {
|
|
42
|
-
if (!this
|
|
50
|
+
if (!this.#state.supported) return 'unavailable';
|
|
43
51
|
const status = await localAvailability(this.#lang());
|
|
44
52
|
this.#set({ status });
|
|
45
53
|
return status;
|
|
@@ -51,7 +59,7 @@ export class VoiceSearchService {
|
|
|
51
59
|
|
|
52
60
|
/** Can we offer a microphone button right now? */
|
|
53
61
|
canListen() {
|
|
54
|
-
return this
|
|
62
|
+
return this.#state.supported && (this.#state.status === 'available' || this.#state.status === 'downloadable');
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/**
|
|
@@ -66,9 +74,9 @@ export class VoiceSearchService {
|
|
|
66
74
|
// Focus AFTER the surface has rendered — the input does not exist yet on the frame
|
|
67
75
|
// that opened it.
|
|
68
76
|
await this.focusInput();
|
|
69
|
-
if (!this
|
|
77
|
+
if (!this.#state.supported) return { listening: false, reason: 'unsupported' };
|
|
70
78
|
|
|
71
|
-
const status = this
|
|
79
|
+
const status = this.#state.status === 'unknown' ? await this.refresh() : this.#state.status;
|
|
72
80
|
if (status === 'downloadable') {
|
|
73
81
|
// Pressing the button IS the consent to fetch it; nothing downloads before that.
|
|
74
82
|
this.notifications?.info('Downloading the on-device voice model — this happens once.');
|
|
@@ -125,7 +133,7 @@ export class VoiceSearchService {
|
|
|
125
133
|
openSearchSurface() {
|
|
126
134
|
const wb = this.workbench;
|
|
127
135
|
if (!wb) return;
|
|
128
|
-
const onLauncher = wb.state.activity === 'home' && !wb.nav?.
|
|
136
|
+
const onLauncher = wb.state.activity === 'home' && !wb.nav?.get?.().activeTabId;
|
|
129
137
|
if (onLauncher) wb.closeSearchModal?.();
|
|
130
138
|
else wb.openSearchModal();
|
|
131
139
|
}
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
// parentheses, string/number/boolean literals. Expressions are parsed once into
|
|
8
8
|
// a predicate function and cached, so evaluation on every keydown is cheap.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// Every clause is evaluated against the WHOLE context snapshot, which is what the palette,
|
|
11
|
+
// the keybindings and the openers all do. A per-clause subscription layer lived here once —
|
|
12
|
+
// the parser collected the keys an expression named and ContextRegistry derived a cell over
|
|
13
|
+
// exactly those — and nothing ever used it, while `evaluate` over the snapshot did all the
|
|
14
|
+
// work. If per-clause reactivity is ever wanted (a palette that becomes its own region is
|
|
15
|
+
// the case for it), the lexer already sees the key/literal distinction in one pass, so it
|
|
16
|
+
// is ~25 lines to bring back. Shipping both halves and using neither is the one thing that
|
|
17
|
+
// was not defensible.
|
|
15
18
|
//
|
|
16
19
|
// A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
|
|
17
20
|
// contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
|
|
@@ -25,16 +28,10 @@ export function compileWhen(expr) {
|
|
|
25
28
|
if (cache.has(expr)) return cache.get(expr);
|
|
26
29
|
let fn;
|
|
27
30
|
try {
|
|
28
|
-
|
|
29
|
-
fn = parser.parseExpression();
|
|
30
|
-
// The keys this clause reads. Frozen: it is shared by every caller through the cache.
|
|
31
|
-
fn.keys = Object.freeze([...parser.keys]);
|
|
31
|
+
fn = new Parser(expr).parseExpression();
|
|
32
32
|
} catch (err) {
|
|
33
33
|
console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
|
|
34
34
|
fn = () => false;
|
|
35
|
-
// A clause that could not be parsed depends on nothing, so watching it yields a cell
|
|
36
|
-
// that is constantly false rather than one that never settles.
|
|
37
|
-
fn.keys = Object.freeze([]);
|
|
38
35
|
}
|
|
39
36
|
cache.set(expr, fn);
|
|
40
37
|
return fn;
|
|
@@ -44,11 +41,6 @@ export function evaluateWhen(expr, ctx) {
|
|
|
44
41
|
return compileWhen(expr)(ctx || {});
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
/** Every context key `expr` names — `[]` for a constant or unparseable clause. */
|
|
48
|
-
export function keysOf(expr) {
|
|
49
|
-
return compileWhen(expr).keys ?? [];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
44
|
// Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
|
|
53
45
|
// slashes would otherwise start a /…/ literal and swallow the rest of the expression.
|
|
54
46
|
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/[a-z]*|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
|
|
@@ -58,7 +50,6 @@ class Parser {
|
|
|
58
50
|
this.src = src;
|
|
59
51
|
this.tokens = this.#lex(src);
|
|
60
52
|
this.pos = 0;
|
|
61
|
-
this.keys = new Set();
|
|
62
53
|
}
|
|
63
54
|
#lex(src) {
|
|
64
55
|
const out = [];
|
|
@@ -155,7 +146,6 @@ class Parser {
|
|
|
155
146
|
return () => s;
|
|
156
147
|
}
|
|
157
148
|
// A context key.
|
|
158
|
-
this.keys.add(t);
|
|
159
149
|
return (c) => c[t];
|
|
160
150
|
}
|
|
161
151
|
}
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// `PENDING` is how a cell says "no value yet" — `watch` renders its placeholder
|
|
18
18
|
// instead of calling the builder. An error is reported by THROWING from
|
|
19
|
-
// `getValue()`, which `watch` renders as its error view.
|
|
20
|
-
// `fromAsync` below, which is the only shape of ours the module does not ship.
|
|
19
|
+
// `getValue()`, which `watch` renders as its error view.
|
|
21
20
|
|
|
22
21
|
import * as dodo from '@3sln/dodo';
|
|
23
22
|
import {
|
|
@@ -32,42 +31,8 @@ export {
|
|
|
32
31
|
fromObservable, toObservable, effect, isCell, readCell, PENDING,
|
|
33
32
|
};
|
|
34
33
|
|
|
35
|
-
/**
|
|
36
|
-
* A cell backed by one async call: `PENDING` until it settles, the value after,
|
|
37
|
-
* and a throw from `getValue()` if it rejects — which is exactly the vocabulary
|
|
38
|
-
* `watch`'s `placeholder` and `error` options are written against.
|
|
39
|
-
*
|
|
40
|
-
* The work starts on the first listener, not at construction, so building one
|
|
41
|
-
* for a view that is never rendered costs nothing. It runs ONCE: a cell that
|
|
42
|
-
* refetched every time its last watcher went away and came back would reload a
|
|
43
|
-
* document on every remount.
|
|
44
|
-
*/
|
|
45
|
-
export function fromAsync(work) {
|
|
46
|
-
let state = { status: 'idle' };
|
|
47
|
-
return connectable(
|
|
48
|
-
(notify) => {
|
|
49
|
-
if (state.status === 'idle') {
|
|
50
|
-
state = { status: 'running' };
|
|
51
|
-
Promise.resolve()
|
|
52
|
-
.then(work)
|
|
53
|
-
.then(
|
|
54
|
-
(value) => { state = { status: 'done', value }; notify(); },
|
|
55
|
-
(error) => { state = { status: 'failed', error }; notify(); },
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
// Nothing to disconnect: a promise cannot be un-awaited, and the result is
|
|
59
|
-
// kept so a later listener reads it rather than starting again.
|
|
60
|
-
return () => {};
|
|
61
|
-
},
|
|
62
|
-
() => {
|
|
63
|
-
if (state.status === 'failed') throw state.error;
|
|
64
|
-
return state.status === 'done' ? state.value : PENDING;
|
|
65
|
-
},
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
34
|
/** The reactive API as one object, for `platform.reactive` (and plugin-facing code). */
|
|
70
35
|
export const reactive = {
|
|
71
36
|
watch, cell, derive, constant, mapCell, connectable,
|
|
72
|
-
fromObservable,
|
|
37
|
+
fromObservable, toObservable, effect, isCell, readCell, PENDING,
|
|
73
38
|
};
|
|
@@ -13,10 +13,26 @@
|
|
|
13
13
|
// file from the search modal opens it and then closes the modal. Sequencing them here keeps
|
|
14
14
|
// that out of the item.
|
|
15
15
|
|
|
16
|
+
import { runAction } from '../dispatch.js';
|
|
17
|
+
|
|
16
18
|
/**
|
|
19
|
+
* Run an item's actions, one genuinely finished before the next begins.
|
|
20
|
+
*
|
|
21
|
+
* The loop used to be a bare `ui.engine.dispatch(action)` under a comment claiming it
|
|
22
|
+
* sequenced them — `dispatch` returns a feed and schedules the body, so it fired them all
|
|
23
|
+
* at once. Nothing was visibly broken, because the lists touched independent slices, but
|
|
24
|
+
* bl/launcher.js already emits `[SetLaunchQueryAction, FilterAction]` and the comment was
|
|
25
|
+
* telling the next reader that a read-after-write pair here would be safe.
|
|
26
|
+
*
|
|
27
|
+
* The same shape and the same stop-on-failure rule as CommandService.execute, which is the
|
|
28
|
+
* other implementation of "run this item's list of actions".
|
|
29
|
+
*
|
|
17
30
|
* @param {object} ui
|
|
18
31
|
* @param {{actions?: object[]}} item
|
|
19
32
|
*/
|
|
20
|
-
export function activate(ui, item) {
|
|
21
|
-
for (const action of item?.actions || [])
|
|
33
|
+
export async function activate(ui, item) {
|
|
34
|
+
for (const action of item?.actions || []) {
|
|
35
|
+
const settled = await runAction(ui.engine, action);
|
|
36
|
+
if (settled?.type !== 'complete') break;
|
|
37
|
+
}
|
|
22
38
|
}
|
|
@@ -98,7 +98,7 @@ function issueRow(issue, ui) {
|
|
|
98
98
|
|
|
99
99
|
export default function activityPanel(state, ui) {
|
|
100
100
|
const act = state.act || { tasks: [], issues: [] };
|
|
101
|
-
if (!
|
|
101
|
+
if (!state.overlay.activityPanel) return null;
|
|
102
102
|
const running = act.tasks.filter((t) => t.status === 'running');
|
|
103
103
|
const recent = act.tasks.filter((t) => t.status !== 'running');
|
|
104
104
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { dd } from '../../runtime.js';
|
|
20
20
|
import { watchQuery } from '../../bl/watchQuery.js';
|
|
21
21
|
import { grantsFor } from '../../bl/queries.js';
|
|
22
|
-
import {
|
|
22
|
+
import { activate } from '../activate.js';
|
|
23
23
|
import { bytes } from '../format.js';
|
|
24
24
|
import { ExecCommandAction, SetGrantAction } from '../../bl/actions.js';
|
|
25
25
|
|
|
@@ -97,12 +97,9 @@ function collectionsSection(state, ui) {
|
|
|
97
97
|
// where the second only means anything after the first has landed. Fired concurrently,
|
|
98
98
|
// "Rotate key…" opened Settings and was then thrown back to home by the switch that had
|
|
99
99
|
// not finished yet. Same shape as platform/commands.js, which got this right first.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (settled?.type !== 'complete') break;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
100
|
+
// The same "run this item's list of actions" as every menu item and list row — one
|
|
101
|
+
// implementation, in ui/activate.js, rather than a third spelling of the loop here.
|
|
102
|
+
const run = (actions) => () => activate(ui, { actions });
|
|
106
103
|
return div({ className: 'group' },
|
|
107
104
|
h3('Collections'),
|
|
108
105
|
p({ className: 'sub' }, 'What exists on this drive, where its bytes live, and which key seals it.'),
|
|
@@ -13,7 +13,16 @@ import { NavigateBackAction, OpenInPanelAction, ShowDialogAction, ShowHomeAction
|
|
|
13
13
|
|
|
14
14
|
const { div, span, button } = dd;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `${panelId}:${openerId}` -> stable render fn.
|
|
18
|
+
*
|
|
19
|
+
* dodo identifies an alias by the FUNCTION, so a fresh one per render would rebuild it
|
|
20
|
+
* every pass and take the <audio> with it. Module scope is safe here in a way it was not
|
|
21
|
+
* for the keybindings region, and the difference is the key: a panel id is minted by
|
|
22
|
+
* `newId()` and is unique across every workbench on the page, so two shells cannot collide
|
|
23
|
+
* and hand each other's `ui` to a render. `pruneOpeners` bounds it to the live stack.
|
|
24
|
+
*/
|
|
25
|
+
const openerFns = new Map();
|
|
17
26
|
|
|
18
27
|
export default function editorArea(state, ui) {
|
|
19
28
|
const wb = state.wb;
|