@3sln/trove 0.0.7 → 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/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- 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 +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- 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 +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- 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 +141 -145
- 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 +63 -11
- 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 +77 -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 +169 -43
- 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-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -1,74 +1,169 @@
|
|
|
1
|
-
//
|
|
2
|
-
// describe the workbench's current state (which view is focused, whether a file
|
|
3
|
-
// is open, its type, whether the palette is showing…). when-clauses evaluate
|
|
4
|
-
// against it, so commands/keybindings/menus light up and dim as state changes.
|
|
1
|
+
// ContextRegistry — the named facts a when-clause is evaluated against.
|
|
5
2
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// A REGISTRY of cells, not a bag of values, and the difference is the whole design:
|
|
4
|
+
//
|
|
5
|
+
// - Every key is backed by a cell somebody OWNS. The registry never hands the cell out,
|
|
6
|
+
// only its value, so a key can be read by anyone and written by exactly one thing.
|
|
7
|
+
// - A contributor registers while it exists and unregisters when it is disposed, so the
|
|
8
|
+
// set of keys is a fact about what is currently installed rather than a pile that only
|
|
9
|
+
// grows.
|
|
10
|
+
// - Because the backings are cells, a derived layer sits on top for free: a built-in key
|
|
11
|
+
// is a `derive` over the resource it summarises, and so cannot go stale.
|
|
12
|
+
//
|
|
13
|
+
// That last point is why this replaced a plain map with setters. Context keys are DERIVED
|
|
14
|
+
// state — what is selected, what is open, what is focused — and they were maintained by
|
|
15
|
+
// pushing: OverlayService wrote `palette.open` as a side effect of opening the palette,
|
|
16
|
+
// NavigationService wrote three `editor.*` keys, WorkbenchService four more, ViewportService
|
|
17
|
+
// three, and an effect in bl/index.js the explorer's two. Five writers for one derivation,
|
|
18
|
+
// and the bug that shape produces is on the record: selecting a file never flipped
|
|
19
|
+
// `explorer.hasSelection`, so the Delete keybinding silently did nothing, because only
|
|
20
|
+
// NavigateAction had been taught to set it. The fix at the time was to add another writer.
|
|
21
|
+
//
|
|
22
|
+
// Nothing sets a built-in key now. They are derived — see bl/context.js.
|
|
23
|
+
|
|
24
|
+
import { cell, derive } from '../runtime.js';
|
|
25
|
+
import { evaluateWhen, compileWhen } from './whenclause.js';
|
|
10
26
|
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
/** A cell whose value never changes — for facts about the machine rather than the drive. */
|
|
28
|
+
const constant = (value) => ({ onDirty: () => () => {}, getValue: () => value });
|
|
29
|
+
|
|
30
|
+
export class ContextRegistry {
|
|
31
|
+
#cells = new Map(); // key -> a Cell its owner holds
|
|
32
|
+
#offs = new Map(); // key -> unsubscribe
|
|
33
|
+
#slots = new Map(); // key -> a stable cell that outlives any one owner
|
|
34
|
+
#snapshot = cell({});
|
|
13
35
|
|
|
14
|
-
export class ContextKeyService {
|
|
15
36
|
constructor(initial = {}) {
|
|
16
|
-
|
|
37
|
+
const fixed = {
|
|
17
38
|
platform: navigatorPlatform(),
|
|
18
39
|
isMac: /mac/i.test(navigatorPlatform()),
|
|
19
40
|
...initial,
|
|
20
41
|
};
|
|
21
|
-
|
|
42
|
+
for (const [key, value] of Object.entries(fixed)) this.register(key, constant(value));
|
|
22
43
|
}
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Back `key` with a cell. Returns the unregister function.
|
|
47
|
+
*
|
|
48
|
+
* The caller keeps the cell, and that is what makes it the owner: writing means writing
|
|
49
|
+
* something the registry cannot reach. Registering a key twice is refused rather than
|
|
50
|
+
* silently taken over — two owners for one fact is exactly the confusion this shape
|
|
51
|
+
* exists to prevent, so it should be loud.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @param {{onDirty: Function, getValue: Function}} source
|
|
55
|
+
* @returns {() => void} unregister
|
|
56
|
+
*/
|
|
57
|
+
register(key, source) {
|
|
58
|
+
if (this.#cells.has(key)) {
|
|
59
|
+
throw new Error(`Context key "${key}" already has an owner`);
|
|
60
|
+
}
|
|
61
|
+
this.#cells.set(key, source);
|
|
62
|
+
this.#offs.set(key, source.onDirty(() => this.#changed(key)));
|
|
63
|
+
this.#changed(key); // gaining an owner is itself a change
|
|
64
|
+
return () => this.unregister(key);
|
|
26
65
|
}
|
|
27
|
-
|
|
28
|
-
|
|
66
|
+
|
|
67
|
+
unregister(key) {
|
|
68
|
+
const off = this.#offs.get(key);
|
|
69
|
+
if (!off) return;
|
|
70
|
+
off();
|
|
71
|
+
this.#offs.delete(key);
|
|
72
|
+
this.#cells.delete(key);
|
|
73
|
+
this.#changed(key); // and so is losing one
|
|
29
74
|
}
|
|
30
|
-
|
|
31
|
-
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A cell for one key, whether or not anything owns it yet.
|
|
78
|
+
*
|
|
79
|
+
* The indirection is the point. A keymap naming a plugin's register is parsed long before
|
|
80
|
+
* that plugin installs, and the plugin may later be uninstalled — so a watcher cannot
|
|
81
|
+
* hold the OWNER's cell, which does not exist at either end. It holds this slot, which
|
|
82
|
+
* reads through to whoever currently owns the key and goes dirty when ownership changes
|
|
83
|
+
* as well as when the value does. Unowned reads as `undefined`, which every clause
|
|
84
|
+
* already treats as falsy.
|
|
85
|
+
*
|
|
86
|
+
* Slots are kept once created. They are bounded by the number of distinct keys any
|
|
87
|
+
* when-clause has ever named, which is small and does not grow with use.
|
|
88
|
+
*/
|
|
89
|
+
cellFor(key) {
|
|
90
|
+
let slot = this.#slots.get(key);
|
|
91
|
+
if (!slot) {
|
|
92
|
+
const listeners = new Set();
|
|
93
|
+
slot = {
|
|
94
|
+
listeners,
|
|
95
|
+
onDirty: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
|
|
96
|
+
getValue: () => this.#cells.get(key)?.getValue(),
|
|
97
|
+
};
|
|
98
|
+
this.#slots.set(key, slot);
|
|
99
|
+
}
|
|
100
|
+
return slot;
|
|
32
101
|
}
|
|
33
102
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
103
|
+
/**
|
|
104
|
+
* A when-clause as a live boolean.
|
|
105
|
+
*
|
|
106
|
+
* Derived over exactly the keys the expression NAMES (the parser collects them — see
|
|
107
|
+
* whenclause.js), so a clause about `view.active` does not recompute when the selection
|
|
108
|
+
* changes. That is the difference from `evaluate`, which reads the whole snapshot: a
|
|
109
|
+
* consumer holding many clauses — the palette holds one per registered command — stops
|
|
110
|
+
* re-running all of them on every unrelated change.
|
|
111
|
+
*
|
|
112
|
+
* A clause naming nothing derives over nothing and is computed once, which is right: a
|
|
113
|
+
* constant cannot become false.
|
|
114
|
+
*/
|
|
115
|
+
watch(expr) {
|
|
116
|
+
const predicate = compileWhen(expr);
|
|
117
|
+
const keys = predicate.keys ?? [];
|
|
118
|
+
return derive(keys.map((key) => this.cellFor(key)), (...values) => {
|
|
119
|
+
const ctx = {};
|
|
120
|
+
for (let i = 0; i < keys.length; i++) ctx[keys[i]] = values[i];
|
|
121
|
+
return predicate(ctx);
|
|
122
|
+
});
|
|
38
123
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
next[k] = v;
|
|
45
|
-
changed = true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (changed) {
|
|
49
|
-
this.state = next;
|
|
50
|
-
this.cell.setValue(next);
|
|
51
|
-
}
|
|
124
|
+
|
|
125
|
+
#changed(key) {
|
|
126
|
+
this.#recompute();
|
|
127
|
+
const slot = this.#slots.get(key);
|
|
128
|
+
if (slot) for (const fn of [...slot.listeners]) fn();
|
|
52
129
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Own a key outright: the registry makes the cell and hands back the writer.
|
|
133
|
+
*
|
|
134
|
+
* For the facts that genuinely are pushed rather than derived — a plugin setting its own
|
|
135
|
+
* declared register over RPC. The write capability IS the returned `set`, so holding a
|
|
136
|
+
* reference to the registry is not enough to change anything.
|
|
137
|
+
*/
|
|
138
|
+
own(key, initial = undefined) {
|
|
139
|
+
const held = cell(initial);
|
|
140
|
+
const dispose = this.register(key, held);
|
|
141
|
+
return { set: (value) => held.setValue(value), dispose };
|
|
58
142
|
}
|
|
59
143
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
144
|
+
#recompute() {
|
|
145
|
+
const next = {};
|
|
146
|
+
for (const [key, source] of this.#cells) next[key] = source.getValue();
|
|
147
|
+
this.#snapshot.setValue(next);
|
|
63
148
|
}
|
|
64
149
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
/** Every key and its current value. */
|
|
151
|
+
snapshot() {
|
|
152
|
+
return this.#snapshot.getValue();
|
|
153
|
+
}
|
|
154
|
+
get(key) {
|
|
155
|
+
return this.#cells.get(key)?.getValue();
|
|
156
|
+
}
|
|
157
|
+
has(key) {
|
|
158
|
+
return this.#cells.has(key);
|
|
159
|
+
}
|
|
160
|
+
observe() {
|
|
161
|
+
return this.#snapshot;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True if `whenExpr` holds against the current context. */
|
|
165
|
+
evaluate(whenExpr) {
|
|
166
|
+
return evaluateWhen(whenExpr, this.snapshot());
|
|
72
167
|
}
|
|
73
168
|
}
|
|
74
169
|
|
|
@@ -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;
|