@3sln/trove 0.0.8 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/src/encryption/envelope.js +46 -7
- package/packages/core/src/encryption/rotation.js +187 -68
- package/packages/core/src/uploads.js +130 -29
- package/packages/core/src/vfs.js +13 -1
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +12 -0
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +59 -26
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1454 -137
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +77 -141
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +31 -102
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +74 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +158 -45
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-y778bpte.js +0 -356
- package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
- package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
// createPlatform — assemble every service into one object the whole app shares.
|
|
2
|
-
// This is the "workbench platform": the reactive registries and services that
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// This is the "workbench platform": the reactive registries and services that core
|
|
3
|
+
// features and plugins both build on. It also registers the default settings schema and
|
|
4
|
+
// keybindings so the shell has sensible behaviour out of the box.
|
|
5
|
+
//
|
|
6
|
+
// It does NOT hold the shell's state. Which activity is open, whether the palette is
|
|
7
|
+
// showing, what is on the panel stack — those are engine resources, built by createApp
|
|
8
|
+
// (see ../bl/state.js). The platform is the machinery underneath them.
|
|
7
9
|
|
|
8
10
|
import { reactive } from '../runtime.js';
|
|
9
11
|
import { ContributionRegistry } from './contributions.js';
|
|
10
|
-
import {
|
|
12
|
+
import { ContextRegistry } from './context.js';
|
|
11
13
|
import { CommandService } from './commands.js';
|
|
12
14
|
import { KeybindingService } from './keybindings.js';
|
|
13
15
|
import { SettingsService } from './settings.js';
|
|
14
16
|
import { NotificationService } from './notifications.js';
|
|
15
17
|
import { TroveApiClient } from './api.js';
|
|
16
18
|
import { PluginHost } from './pluginHost.js';
|
|
17
|
-
import { WorkbenchService } from './workbench.js';
|
|
18
19
|
import { ViewportService } from './viewport.js';
|
|
19
20
|
import { SpatialNavigationService } from './spatialNav.js';
|
|
20
21
|
import { VoiceSearchService } from './voiceSearch.js';
|
|
@@ -42,12 +43,18 @@ export function writeToken(token) {
|
|
|
42
43
|
|
|
43
44
|
export function createPlatform({ baseUrl = '' } = {}) {
|
|
44
45
|
const contributions = new ContributionRegistry();
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
46
|
+
// Only the fixed facts are seeded. Everything else is registered by whoever owns it —
|
|
47
|
+
// the built-in keys are derived from the shell and the drive (see bl/context.js), so
|
|
48
|
+
// there is nothing sensible to pre-fill them with and nothing that would go stale if
|
|
49
|
+
// there were.
|
|
50
|
+
//
|
|
51
|
+
// It used to be seeded with `view.active: 'home'` for a reason worth remembering: nothing
|
|
52
|
+
// wrote that key until the user navigated, so every `when: view.active == 'home'`
|
|
53
|
+
// binding — the Delete shortcut among them — was dead from boot until the first click on
|
|
54
|
+
// the rail, while the row menu cheerfully advertised "Del" as the way to delete. A
|
|
55
|
+
// derived key is never unwritten, so the seed is not needed and cannot drift from the
|
|
56
|
+
// state it was standing in for.
|
|
57
|
+
const context = new ContextRegistry();
|
|
51
58
|
const notifications = new NotificationService();
|
|
52
59
|
const commands = new CommandService(contributions, context, notifications);
|
|
53
60
|
const settings = new SettingsService();
|
|
@@ -57,22 +64,22 @@ export function createPlatform({ baseUrl = '' } = {}) {
|
|
|
57
64
|
// But a deployment where the user HOLDS a token has no other way to present it, and
|
|
58
65
|
// reading it here — once, from one place — keeps that out of every call site.
|
|
59
66
|
const api = new TroveApiClient({ baseUrl, token: () => readToken() });
|
|
60
|
-
const workbench = new WorkbenchService(context);
|
|
61
67
|
// Which shell to render — phone, desktop, or TV. Constructed before the defaults are
|
|
62
68
|
// registered, so it reads the setting through `settings.get` once that exists.
|
|
63
|
-
const viewport = new ViewportService({ settings
|
|
69
|
+
const viewport = new ViewportService({ settings });
|
|
64
70
|
// Arrow keys → geometry, but only on a TV. Inert everywhere else.
|
|
65
|
-
const spatialNav = new SpatialNavigationService({
|
|
71
|
+
const spatialNav = new SpatialNavigationService({ viewport });
|
|
66
72
|
// Speak to search. Mostly this just puts the search field under the remote's mic —
|
|
67
73
|
// see voiceSearch.js for why that is the whole feature on a TV.
|
|
68
|
-
const voice = new VoiceSearchService({
|
|
74
|
+
const voice = new VoiceSearchService({ notifications, settings });
|
|
69
75
|
|
|
70
76
|
const platform = {
|
|
71
77
|
reactive,
|
|
72
|
-
contributions, context, commands, keybindings, settings, notifications, api,
|
|
78
|
+
contributions, context, commands, keybindings, settings, notifications, api,
|
|
73
79
|
viewport, spatialNav, voice,
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
// The shell's own state is the engine's, not the platform's — createApp builds it and
|
|
81
|
+
// hands back what the few imperative edges below still need.
|
|
82
|
+
openPluginPanel: null,
|
|
76
83
|
};
|
|
77
84
|
platform.mediaUrls = new MediaUrlService({ api: platform.api, settings });
|
|
78
85
|
platform.plugins = new PluginHost(platform);
|
|
@@ -11,8 +11,7 @@ const RECENTS_KEY = 'trove.recents';
|
|
|
11
11
|
const RECENTS_MAX = 12;
|
|
12
12
|
|
|
13
13
|
export class NavigationService {
|
|
14
|
-
constructor(
|
|
15
|
-
this.context = context;
|
|
14
|
+
constructor() {
|
|
16
15
|
this.state = {
|
|
17
16
|
stack: [{ kind: 'search' }], // [{kind:'search'}, {kind:'file', id, node, openerId}, …]
|
|
18
17
|
activeTabId: null, // top file panel id (or null)
|
|
@@ -43,7 +42,6 @@ export class NavigationService {
|
|
|
43
42
|
const top = stack[stack.length - 1];
|
|
44
43
|
const file = top && top.kind === 'file' ? top : null;
|
|
45
44
|
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
45
|
if (history) this.#pushHistory();
|
|
48
46
|
}
|
|
49
47
|
|
|
@@ -83,11 +81,43 @@ export class NavigationService {
|
|
|
83
81
|
pop() {
|
|
84
82
|
if (this.state.stack.length > 1) this.#applyStack(this.state.stack.slice(0, -1), { history: false });
|
|
85
83
|
}
|
|
86
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The node is GONE — drop it from everywhere this service holds a copy.
|
|
86
|
+
*
|
|
87
|
+
* Named for what happened rather than for the stack, because it was `closeTab` and so
|
|
88
|
+
* only closed the panel: a deleted file kept its recent tile, and clicking that tile
|
|
89
|
+
* opened nothing. Recents are a snapshot, not a reference, so nothing else was ever
|
|
90
|
+
* going to notice.
|
|
91
|
+
*
|
|
92
|
+
* Trashing is reversible and this is not, quite: restoring a file does not put it back
|
|
93
|
+
* in recents, it just stops being listed until opened again. That is the right way
|
|
94
|
+
* round — a tile that leads nowhere is worse than one that is missing.
|
|
95
|
+
*/
|
|
96
|
+
forget(id) {
|
|
97
|
+
const recents = this.state.recents.filter((r) => r.id !== id);
|
|
98
|
+
if (recents.length !== this.state.recents.length) {
|
|
99
|
+
this.#set({ recents });
|
|
100
|
+
saveRecents(recents);
|
|
101
|
+
}
|
|
87
102
|
const stack = this.state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
|
|
88
103
|
this.#applyStack(stack.length ? stack : [{ kind: 'search' }], { history: false });
|
|
89
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A node changed underneath us — a rename — so every copy of it here has to change.
|
|
107
|
+
*
|
|
108
|
+
* BOTH the stack and the recents list, because recents hold a SNAPSHOT (id, name,
|
|
109
|
+
* contentType) rather than a reference: updating only the stack renamed the open panel's
|
|
110
|
+
* title and left the recent tile showing the old name until it aged off the end of the
|
|
111
|
+
* list, which reads as the rename half-failing.
|
|
112
|
+
*/
|
|
90
113
|
updateTabNode(node) {
|
|
114
|
+
const recents = this.state.recents.map((r) => (r.id === node.id
|
|
115
|
+
? { ...r, name: node.name, contentType: node.contentType || r.contentType }
|
|
116
|
+
: r));
|
|
117
|
+
if (recents.some((r, i) => r !== this.state.recents[i])) {
|
|
118
|
+
this.#set({ recents });
|
|
119
|
+
saveRecents(recents);
|
|
120
|
+
}
|
|
91
121
|
const stack = this.state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
|
|
92
122
|
this.#applyStack(stack, { history: false });
|
|
93
123
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The two things only the browser can do: ask for a file, and save one.
|
|
2
|
+
//
|
|
3
|
+
// These are effects, so they are reached from actions rather than from a render — but they
|
|
4
|
+
// are effects on the DOCUMENT rather than on the drive, which is why they live in
|
|
5
|
+
// `platform` and not in `bl`. An action that needs a file from the user calls in here; the
|
|
6
|
+
// action is still the thing that decides what happens to it.
|
|
7
|
+
//
|
|
8
|
+
// They were private to bl/commands.js, where the command handlers that used them lived.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Open a native file picker.
|
|
12
|
+
*
|
|
13
|
+
* The hidden `<input>` is removed on selection AND on cancel — cancelling fires no
|
|
14
|
+
* `change`, so cleanup also hangs off the next window focus (which the OS dialog returns)
|
|
15
|
+
* to avoid leaking a growing pile of inputs.
|
|
16
|
+
*/
|
|
17
|
+
function pick(cb, configure) {
|
|
18
|
+
const input = document.createElement('input');
|
|
19
|
+
input.type = 'file';
|
|
20
|
+
configure(input);
|
|
21
|
+
input.style.display = 'none';
|
|
22
|
+
document.body.appendChild(input);
|
|
23
|
+
const cleanup = () => { input.remove(); window.removeEventListener('focus', onFocus); };
|
|
24
|
+
const onFocus = () => setTimeout(() => { if (!input.files.length) cleanup(); }, 300);
|
|
25
|
+
input.addEventListener('change', () => { cb(input.files); cleanup(); }, { once: true });
|
|
26
|
+
window.addEventListener('focus', onFocus);
|
|
27
|
+
input.click();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function pickFiles(cb) {
|
|
31
|
+
pick((files) => cb(files), (input) => { input.multiple = true; });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function pickZip(cb) {
|
|
35
|
+
pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hand a URL to the browser as a download. */
|
|
39
|
+
export function triggerDownload(url, name) {
|
|
40
|
+
const a = document.createElement('a');
|
|
41
|
+
a.href = url;
|
|
42
|
+
a.download = name || '';
|
|
43
|
+
document.body.appendChild(a);
|
|
44
|
+
a.click();
|
|
45
|
+
a.remove();
|
|
46
|
+
}
|
|
@@ -27,6 +27,7 @@ import { pluginId, contribUri } from '@3sln/trove/core/plugins/identity.js';
|
|
|
27
27
|
import { endpointSummary } from './pluginNet.js';
|
|
28
28
|
import { MediaController } from './pluginMedia.js';
|
|
29
29
|
import { FrameDock } from './pluginDock.js';
|
|
30
|
+
import { InvokePluginCommandAction, OpenInPanelAction } from '../bl/actions.js';
|
|
30
31
|
import { FrameManager } from './pluginFrames.js';
|
|
31
32
|
import { PluginRpcRouter } from './pluginRpc.js';
|
|
32
33
|
// The canonical capability list lives in core (the server's authority); import it so
|
|
@@ -54,7 +55,9 @@ export class PluginHost {
|
|
|
54
55
|
this.media = new MediaController();
|
|
55
56
|
this.dock = new FrameDock({
|
|
56
57
|
destroyFrame: (frame) => this.frames.destroy(frame),
|
|
57
|
-
|
|
58
|
+
// Dispatched, not called: opening a file from a docked plugin frame is the same
|
|
59
|
+
// intent as opening one from the drive, and the engine should see both.
|
|
60
|
+
openFile: (node, openerId) => this.platform.dispatch?.(new OpenInPanelAction(node, openerId)),
|
|
58
61
|
onChange: () => this.#emit(),
|
|
59
62
|
});
|
|
60
63
|
this.frames = new FrameManager({ media: this.media, dock: this.dock });
|
|
@@ -107,15 +110,19 @@ export class PluginHost {
|
|
|
107
110
|
if (runtime.grants.includes('opener')) keep(reg.register(c.uri, base));
|
|
108
111
|
break;
|
|
109
112
|
|
|
110
|
-
// Declared, implemented by the plugin's primary frame.
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
+
// Declared, implemented by the plugin's primary frame. Addressed over RPC by the
|
|
114
|
+
// contribution's short NAME — inside its own frame a plugin addresses its commands
|
|
115
|
+
// by name, not by URI.
|
|
113
116
|
case 'command':
|
|
114
117
|
keep(this.platform.commands.register({
|
|
115
118
|
id: c.uri, title: c.title || `${label}: ${c.name}`,
|
|
116
119
|
category: c.category || label, icon: c.icon,
|
|
117
120
|
when: c.when, offline: c.offline, palette: c.palette, pluginId: pid,
|
|
118
|
-
|
|
121
|
+
// A description, like every other command. It used to be a closure over
|
|
122
|
+
// `runtime` proxying straight to the channel, which left a plugin command
|
|
123
|
+
// running invisible to the engine — the same hole ExecCommandAction closed for
|
|
124
|
+
// the host's own commands, still open for everyone else's.
|
|
125
|
+
actions: (...args) => new InvokePluginCommandAction(pid, c.name, args),
|
|
119
126
|
}));
|
|
120
127
|
break;
|
|
121
128
|
|
|
@@ -133,13 +140,20 @@ export class PluginHost {
|
|
|
133
140
|
break;
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
// A context value slot
|
|
137
|
-
//
|
|
138
|
-
|
|
143
|
+
// A context value slot — the one kind of key that genuinely is PUSHED rather than
|
|
144
|
+
// derived, because only the plugin knows what it means. Owned here, on the
|
|
145
|
+
// plugin's behalf: the registry hands back a writer, which goes on the runtime
|
|
146
|
+
// record so `context:setRegister` can reach it and nothing else can.
|
|
147
|
+
//
|
|
148
|
+
// Seeded with its declared default so when-clauses referencing it evaluate
|
|
149
|
+
// sensibly before the plugin has run at all.
|
|
150
|
+
case 'register': {
|
|
139
151
|
keep(reg.register(c.uri, base));
|
|
140
|
-
this.platform.context.
|
|
141
|
-
|
|
152
|
+
const owned = this.platform.context.own(c.uri, c.default);
|
|
153
|
+
runtime.registers.set(c.uri, owned.set);
|
|
154
|
+
keep(() => { runtime.registers.delete(c.uri); owned.dispose(); });
|
|
142
155
|
break;
|
|
156
|
+
}
|
|
143
157
|
|
|
144
158
|
// A keymap JSON file inside the package. Read + validated here, at register
|
|
145
159
|
// time, so a malformed keymap is a visible install-time problem rather than a
|
|
@@ -373,6 +387,10 @@ export class PluginHost {
|
|
|
373
387
|
if (this.plugins.get(record.id)?.status === 'active') return;
|
|
374
388
|
const runtime = {
|
|
375
389
|
...record, iframe: null, status: 'loading', error: null, disposers: [], channel: null,
|
|
390
|
+
// Writers for the context registers this plugin DECLARED, keyed by contribution URI.
|
|
391
|
+
// Holding them here is what makes "a plugin may only set its own registers" structural
|
|
392
|
+
// rather than a check: there is no setter for a key it did not declare.
|
|
393
|
+
registers: new Map(),
|
|
376
394
|
hasUi: false, responsive: false, frame: null, frames: new Set(),
|
|
377
395
|
// Storage scopes (defaulted for records saved before this field existed).
|
|
378
396
|
storage: record.storage || (record.grants?.includes('storage') ? grantedStorageScopes(record.manifest, record.trust) : { plugin: false, domain: false }),
|
|
@@ -445,6 +463,21 @@ export class PluginHost {
|
|
|
445
463
|
this._probing = false;
|
|
446
464
|
}
|
|
447
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Run one of a plugin's own commands, in its frame.
|
|
468
|
+
*
|
|
469
|
+
* Public because `InvokePluginCommandAction` is what reaches it now, rather than a closure
|
|
470
|
+
* captured at registration. Addressed by short NAME: the URI is the host's way of keeping
|
|
471
|
+
* two plugins' `status` commands apart and means nothing on the other side.
|
|
472
|
+
*
|
|
473
|
+
* A plugin that is not running answers undefined rather than throwing. Its commands are
|
|
474
|
+
* already filtered out of the palette by `isAvailable`, so getting here at all means a
|
|
475
|
+
* stale reference, which is not worth an error.
|
|
476
|
+
*/
|
|
477
|
+
invokeCommand(pluginId, name, args = []) {
|
|
478
|
+
return this.plugins.get(pluginId)?.channel?.call('command:execute', { id: name, args });
|
|
479
|
+
}
|
|
480
|
+
|
|
448
481
|
setHeartbeat(ms) {
|
|
449
482
|
this.heartbeatMs = ms;
|
|
450
483
|
this.#stopHeartbeat();
|
|
@@ -112,7 +112,11 @@ export class PluginRpcRouter {
|
|
|
112
112
|
// can read, addressed by its contribution URI.
|
|
113
113
|
case 'context:setRegister': {
|
|
114
114
|
const slot = this.#ownContribution(record, params.name, 'register');
|
|
115
|
-
|
|
115
|
+
// The writer for a register the plugin declared. There is no path to a key it did
|
|
116
|
+
// not declare, because it was never handed a setter for one.
|
|
117
|
+
const write = record.registers?.get(slot.uri);
|
|
118
|
+
if (!write) throw new Error(`Register "${params.name}" is not available`);
|
|
119
|
+
write(params.value);
|
|
116
120
|
return { ok: true };
|
|
117
121
|
}
|
|
118
122
|
// Package resources — opaque byte handles (transferred, no host URLs). Code
|
|
@@ -170,7 +174,7 @@ export class PluginRpcRouter {
|
|
|
170
174
|
case 'ui:showPanel':
|
|
171
175
|
cap('ui');
|
|
172
176
|
record.hasUi = true;
|
|
173
|
-
this.platform.openPluginPanel
|
|
177
|
+
this.platform.openPluginPanel(pid);
|
|
174
178
|
this.onChange();
|
|
175
179
|
return { ok: true };
|
|
176
180
|
|
|
@@ -43,12 +43,10 @@ const ACTIVATES_ITSELF = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'])
|
|
|
43
43
|
export class SpatialNavigationService {
|
|
44
44
|
/**
|
|
45
45
|
* @param {object} deps
|
|
46
|
-
* @param {import('./workbench.js').WorkbenchService} deps.workbench
|
|
47
46
|
* @param {import('./viewport.js').ViewportService} deps.viewport
|
|
48
47
|
* @param {Window} [deps.window]
|
|
49
48
|
*/
|
|
50
49
|
constructor({ workbench, viewport, window: win = globalThis }) {
|
|
51
|
-
this.workbench = workbench;
|
|
52
50
|
this.viewport = viewport;
|
|
53
51
|
this.window = win;
|
|
54
52
|
this.active = false;
|
|
@@ -33,12 +33,10 @@ export class ViewportService {
|
|
|
33
33
|
* @param {object} [deps]
|
|
34
34
|
* @param {Window} [deps.window] the window to measure and listen to
|
|
35
35
|
* @param {import('./settings.js').SettingsService} [deps.settings]
|
|
36
|
-
* @param {import('./context.js').ContextKeyService} [deps.context]
|
|
37
36
|
*/
|
|
38
|
-
constructor({ window: win = globalThis, settings = null
|
|
37
|
+
constructor({ window: win = globalThis, settings = null } = {}) {
|
|
39
38
|
this.window = win;
|
|
40
39
|
this.settings = settings;
|
|
41
|
-
this.context = context;
|
|
42
40
|
// A URL override outranks the setting: it is how someone checks the phone layout on
|
|
43
41
|
// a laptop, and how the e2e suite drives each shell without a device farm.
|
|
44
42
|
this.urlOverride = readUrlOverride(win);
|
|
@@ -82,9 +80,6 @@ export class ViewportService {
|
|
|
82
80
|
#publish(vp) {
|
|
83
81
|
// Context keys so plugin `when` clauses and keybindings can target a form factor
|
|
84
82
|
// the same way they target a view.
|
|
85
|
-
this.context?.set('viewport.mode', vp.mode);
|
|
86
|
-
this.context?.set('viewport.phone', vp.mode === 'phone');
|
|
87
|
-
this.context?.set('viewport.tv', vp.mode === 'tv');
|
|
88
83
|
// The root element carries it too, so CSS can respond without every rule needing a
|
|
89
84
|
// media query that would disagree with the JS branch above it.
|
|
90
85
|
const el = this.window.document?.documentElement;
|
|
@@ -19,11 +19,9 @@ import { canTranscribeLocally, localAvailability, installLocal, listen } from '.
|
|
|
19
19
|
export class VoiceSearchService {
|
|
20
20
|
/**
|
|
21
21
|
* @param {object} deps
|
|
22
|
-
* @param {import('./workbench.js').WorkbenchService} deps.workbench
|
|
23
22
|
* @param {object} deps.notifications
|
|
24
23
|
*/
|
|
25
|
-
constructor({
|
|
26
|
-
this.workbench = workbench;
|
|
24
|
+
constructor({ notifications, settings } = {}) {
|
|
27
25
|
this.notifications = notifications;
|
|
28
26
|
this.settings = settings;
|
|
29
27
|
this.session = null;
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
// a context (a flat key→value map from the ContextKeyService) and returns a
|
|
4
4
|
// boolean.
|
|
5
5
|
//
|
|
6
|
-
// Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re
|
|
6
|
+
// Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re/flags, && , ||,
|
|
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
|
+
// The compiled predicate carries `.keys`: every context key the expression names. That is
|
|
11
|
+
// what lets a clause be WATCHED rather than polled — see ContextRegistry#watch, which
|
|
12
|
+
// derives a cell over exactly those keys, so a clause about `view.active` stops recomputing
|
|
13
|
+
// every time the selection changes. Collected while parsing because that is the only place
|
|
14
|
+
// the distinction between a key and a literal is known.
|
|
15
|
+
//
|
|
10
16
|
// A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
|
|
11
17
|
// contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
|
|
12
18
|
// Registers are addressed by their full URI precisely because they're contributions
|
|
@@ -19,10 +25,16 @@ export function compileWhen(expr) {
|
|
|
19
25
|
if (cache.has(expr)) return cache.get(expr);
|
|
20
26
|
let fn;
|
|
21
27
|
try {
|
|
22
|
-
|
|
28
|
+
const parser = new Parser(expr);
|
|
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]);
|
|
23
32
|
} catch (err) {
|
|
24
33
|
console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
|
|
25
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([]);
|
|
26
38
|
}
|
|
27
39
|
cache.set(expr, fn);
|
|
28
40
|
return fn;
|
|
@@ -32,15 +44,21 @@ export function evaluateWhen(expr, ctx) {
|
|
|
32
44
|
return compileWhen(expr)(ctx || {});
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/** Every context key `expr` names — `[]` for a constant or unparseable clause. */
|
|
48
|
+
export function keysOf(expr) {
|
|
49
|
+
return compileWhen(expr).keys ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
// Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
|
|
36
53
|
// slashes would otherwise start a /…/ literal and swallow the rest of the expression.
|
|
37
|
-
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])
|
|
54
|
+
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/[a-z]*|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
|
|
38
55
|
|
|
39
56
|
class Parser {
|
|
40
57
|
constructor(src) {
|
|
41
58
|
this.src = src;
|
|
42
59
|
this.tokens = this.#lex(src);
|
|
43
60
|
this.pos = 0;
|
|
61
|
+
this.keys = new Set();
|
|
44
62
|
}
|
|
45
63
|
#lex(src) {
|
|
46
64
|
const out = [];
|
|
@@ -137,6 +155,7 @@ class Parser {
|
|
|
137
155
|
return () => s;
|
|
138
156
|
}
|
|
139
157
|
// A context key.
|
|
158
|
+
this.keys.add(t);
|
|
140
159
|
return (c) => c[t];
|
|
141
160
|
}
|
|
142
161
|
}
|
|
@@ -458,12 +458,23 @@ kbd {
|
|
|
458
458
|
.scrim { position: fixed; inset: 0; background: #0006; backdrop-filter: blur(1.5px); z-index: 40; }
|
|
459
459
|
|
|
460
460
|
.palette {
|
|
461
|
-
position: fixed; top: 12vh; left: 50%;
|
|
461
|
+
position: fixed; top: 12vh; left: 50%; translate: -50% 0;
|
|
462
462
|
width: min(640px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
|
|
463
463
|
border-radius: 12px; box-shadow: var(--shadow); z-index: 50; overflow: hidden;
|
|
464
464
|
animation: pop .13s var(--ease);
|
|
465
465
|
}
|
|
466
|
-
|
|
466
|
+
/* Transform-neutral on purpose.
|
|
467
|
+
|
|
468
|
+
This used to end at `translate(-50%, 0)` — the command palette's horizontal centring,
|
|
469
|
+
baked into a shared animation. `animation-fill-mode` is `none`, so when the animation
|
|
470
|
+
finished the transform reverted to the element's own and anything that did not happen to
|
|
471
|
+
want exactly that offset jumped by the difference: the context menu, plugin panel and
|
|
472
|
+
inbox each snapped right by half their width, and the dialog dropped half its height
|
|
473
|
+
because the keyframe kept the X centring and lost the Y.
|
|
474
|
+
|
|
475
|
+
Elements that need centring now do it with the standalone `translate` property, which
|
|
476
|
+
composes with `transform` instead of competing for the same slot. */
|
|
477
|
+
@keyframes pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
467
478
|
.palette .search { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
468
479
|
.palette .search input { flex: 1; background: transparent; border: 0; outline: none; font-size: 15px; color: var(--text); }
|
|
469
480
|
.palette .results { max-height: 52vh; overflow-y: auto; padding: 6px; }
|
|
@@ -501,7 +512,7 @@ kbd {
|
|
|
501
512
|
.palette .none .spinner { width: 13px; height: 13px; }
|
|
502
513
|
|
|
503
514
|
.dialog {
|
|
504
|
-
position: fixed; top: 50%; left: 50%;
|
|
515
|
+
position: fixed; top: 50%; left: 50%; translate: -50% -50%;
|
|
505
516
|
width: min(440px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
|
|
506
517
|
border-radius: 12px; box-shadow: var(--shadow); z-index: 50; padding: 20px; animation: pop .13s var(--ease);
|
|
507
518
|
}
|
|
@@ -535,7 +546,11 @@ kbd {
|
|
|
535
546
|
border: 1px solid var(--border-strong); border-radius: 9px; box-shadow: var(--shadow); padding: 5px;
|
|
536
547
|
animation: pop .1s var(--ease);
|
|
537
548
|
}
|
|
538
|
-
|
|
549
|
+
/* `width: 100%` because these are <button>s, and a form control computes `width: auto` as
|
|
550
|
+
fit-content rather than stretching the way a block does — so the row, and with it the
|
|
551
|
+
hover target, was only as wide as its label. `.kbd`'s `margin-left: auto` below is the
|
|
552
|
+
evidence of the intent: pushing a shortcut to the far right needs a full-width row. */
|
|
553
|
+
.menu .mi { display: flex; width: 100%; box-sizing: border-box; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 6px; cursor: default; }
|
|
539
554
|
.menu .mi:hover { background: var(--bg-active); }
|
|
540
555
|
.menu .mi.danger:hover { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
|
|
541
556
|
.menu .mi .kbd { margin-left: auto; color: var(--text-faint); }
|
|
@@ -664,6 +679,61 @@ kbd {
|
|
|
664
679
|
.review-meta { color: var(--text-faint); font-size: 11px; margin-top: 6px; }
|
|
665
680
|
.dialog.review .row-actions { padding: 14px 18px; border-top: 1px solid var(--border); margin: 0; }
|
|
666
681
|
|
|
682
|
+
/* The admin console's action rows: several small buttons side by side, which the settings
|
|
683
|
+
`.control` column is not otherwise shaped for. */
|
|
684
|
+
/* A row whose control is a GROUP of buttons rather than one input.
|
|
685
|
+
`.setting .control` is a fixed 200px column, which is right for a select or a toggle and
|
|
686
|
+
far too narrow for three buttons — they wrapped 2-then-1 and sat jammed against the
|
|
687
|
+
edge. Such a row wraps instead, giving the buttons their own full-width line under the
|
|
688
|
+
description, so the longest label stops squeezing the group. */
|
|
689
|
+
.setting.stacked { flex-wrap: wrap; }
|
|
690
|
+
.setting.stacked .info { flex: 1 1 100%; }
|
|
691
|
+
.setting.stacked .control { width: 100%; margin-top: 12px; }
|
|
692
|
+
.admin .control.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
693
|
+
.admin .control.admin-actions .btn { white-space: nowrap; }
|
|
694
|
+
.admin .control .mono.muted { color: var(--text-faint); }
|
|
695
|
+
|
|
696
|
+
/* The rotation bill, itemised. Only where a provider actually charges for the traffic. */
|
|
697
|
+
.rot-bill { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px;
|
|
698
|
+
background: color-mix(in srgb, var(--text) 4%, transparent); }
|
|
699
|
+
.rot-bill .rb-line { display: flex; justify-content: space-between; gap: 12px;
|
|
700
|
+
font-size: 12px; color: var(--text-dim); padding: 2px 0; }
|
|
701
|
+
.rot-bill .rb-line.total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 5px; color: var(--text); }
|
|
702
|
+
.rot-bill .rb-asof { font-size: 11px; color: var(--text-faint); margin-top: 5px; }
|
|
703
|
+
|
|
704
|
+
/* ---- Encryption, on the collection form --------------------------------- */
|
|
705
|
+
.enc-section { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 4px; }
|
|
706
|
+
.enc-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
|
|
707
|
+
.enc-hint { color: var(--text-faint); font-size: 11.5px; margin-top: 4px; }
|
|
708
|
+
.enc-body { margin-top: 10px; }
|
|
709
|
+
.enc-body .field { margin-bottom: 8px; }
|
|
710
|
+
.enc-problem { display: flex; align-items: center; gap: 6px; color: var(--warn); font-size: 12px; margin: 6px 0; }
|
|
711
|
+
/* What it does and does not do, next to the toggle rather than in a help page: the moment
|
|
712
|
+
someone turns this on is the moment the scope matters. */
|
|
713
|
+
.enc-scope { margin-top: 10px; padding: 10px 12px; border-radius: 8px;
|
|
714
|
+
background: color-mix(in srgb, var(--text) 4%, transparent); }
|
|
715
|
+
.enc-scope .es-title { font-size: 11px; font-weight: 650; text-transform: uppercase;
|
|
716
|
+
letter-spacing: 0.04em; color: var(--text-dim); margin-bottom: 3px; }
|
|
717
|
+
.enc-scope .es-title + .es-line { margin-bottom: 8px; }
|
|
718
|
+
.enc-scope .es-line { font-size: 12px; line-height: 1.5; color: var(--text-dim); }
|
|
719
|
+
.enc-scope .es-line.note { margin-top: 8px; color: var(--text-faint); }
|
|
720
|
+
|
|
721
|
+
/* Provenance warning. Sits between the review and the Install button rather than beside
|
|
722
|
+
the name, because a badge up in the header is something you read past. */
|
|
723
|
+
.review-warning { padding: 12px 18px; border-top: 1px solid var(--border);
|
|
724
|
+
background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
|
725
|
+
.review-warning .rw-head { display: flex; align-items: center; gap: 7px; color: var(--warn);
|
|
726
|
+
font-weight: 600; font-size: 13px; }
|
|
727
|
+
.review-warning .rw-detail { color: var(--text-dim); font-size: 12.5px; line-height: 1.5; margin-top: 5px; }
|
|
728
|
+
.review-warning .rw-ack { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px;
|
|
729
|
+
font-size: 12.5px; color: var(--text); cursor: pointer; }
|
|
730
|
+
.review-warning .rw-ack input { margin-top: 2px; }
|
|
731
|
+
/* An unsigned package is the one someone is most likely to wave through, so it is the
|
|
732
|
+
loudest; the others are informational. */
|
|
733
|
+
.review-warning.unverified { background: color-mix(in srgb, var(--danger) 10%, transparent); }
|
|
734
|
+
.review-warning.unverified .rw-head { color: var(--danger); }
|
|
735
|
+
.btn.primary.disabled, .btn.primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
736
|
+
|
|
667
737
|
/* ---- Media / audiobook opener ------------------------------------------- */
|
|
668
738
|
.viewer { height: 100%; display: flex; flex-direction: column; }
|
|
669
739
|
.viewer.text pre { margin: 0; padding: 18px 20px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; color: var(--text); }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Running a menu item or a list row.
|
|
2
|
+
//
|
|
3
|
+
// These used to carry a `run` closure — `run: () => ui.engine.dispatch(new ExecCommandAction('explorer.open', node))` — which
|
|
4
|
+
// made a menu item a piece of behaviour wearing a data shape. Nothing could inspect one,
|
|
5
|
+
// nothing could contribute one from outside the module that built it, and the engine saw
|
|
6
|
+
// only whatever the closure happened to do.
|
|
7
|
+
//
|
|
8
|
+
// They carry `actions` now: a list of Action instances, which is what an item IS — a label,
|
|
9
|
+
// an icon, and what happens if you pick it. Almost all of them turned out to be exactly one
|
|
10
|
+
// `ExecCommandAction`, which is the shape the statusItem view already used.
|
|
11
|
+
//
|
|
12
|
+
// A LIST rather than a single action because a few items genuinely do two things: opening a
|
|
13
|
+
// file from the search modal opens it and then closes the modal. Sequencing them here keeps
|
|
14
|
+
// that out of the item.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} ui
|
|
18
|
+
* @param {{actions?: object[]}} item
|
|
19
|
+
*/
|
|
20
|
+
export function activate(ui, item) {
|
|
21
|
+
for (const action of item?.actions || []) ui.engine.dispatch(action);
|
|
22
|
+
}
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
import { dd } from '../../runtime.js';
|
|
7
7
|
import { icon } from '../icon.js';
|
|
8
8
|
import { notificationBell, principalChip } from './social.js';
|
|
9
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
9
10
|
|
|
10
11
|
const { div, button, span, img } = dd;
|
|
11
12
|
|
|
12
13
|
const ITEMS = [
|
|
13
14
|
{ id: 'home', icon: 'search', title: 'Search & files', command: 'workbench.view.home' },
|
|
14
15
|
{ id: 'plugins', icon: 'plug', title: 'Plugins', command: 'workbench.view.plugins' },
|
|
16
|
+
// Where an administrator goes. A destination rather than a route because this app does
|
|
17
|
+
// not put its state in the address bar — see adminView.js.
|
|
18
|
+
{ id: 'admin', icon: 'book', title: 'Administration', command: 'workbench.view.admin' },
|
|
15
19
|
];
|
|
16
20
|
|
|
17
21
|
export default function activityBar(state, ui) {
|
|
@@ -19,17 +23,17 @@ export default function activityBar(state, ui) {
|
|
|
19
23
|
const pluginCount = state.plugins?.filter((p) => p.status === 'active').length || 0;
|
|
20
24
|
return div({ className: 'activitybar' },
|
|
21
25
|
button({ className: 'brand-mark', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
|
|
22
|
-
.on({ click: () => ui.
|
|
26
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }),
|
|
23
27
|
...ITEMS.map((it) =>
|
|
24
28
|
button({ className: `item ${active === it.id ? 'active' : ''}`, title: it.title },
|
|
25
29
|
icon(it.icon, { size: 22 }),
|
|
26
30
|
it.id === 'plugins' && pluginCount ? span({ className: 'badge' }, String(pluginCount)) : null,
|
|
27
|
-
).on({ click: () => ui.
|
|
31
|
+
).on({ click: () => ui.engine.dispatch(new ExecCommandAction(it.command)) }),
|
|
28
32
|
),
|
|
29
33
|
div({ className: 'spacer' }),
|
|
30
34
|
notificationBell(state, ui),
|
|
31
35
|
button({ className: `item ${active === 'settings' ? 'active' : ''}`, title: 'Settings' }, icon('gear', { size: 21 }))
|
|
32
|
-
.on({ click: () => ui.
|
|
36
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.openSettings')) }),
|
|
33
37
|
principalChip(state),
|
|
34
38
|
);
|
|
35
39
|
}
|