@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
|
@@ -5,64 +5,160 @@
|
|
|
5
5
|
// dispatch follow-up Actions (the choreographer pattern).
|
|
6
6
|
|
|
7
7
|
import { Engine, Provider } from '@3sln/ngin';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { cell } from '../runtime.js';
|
|
9
|
+
import { registerCoreContext, registerViewportContext } from './context.js';
|
|
10
|
+
import { TransfersService } from './services.js';
|
|
11
|
+
import { explorerState, searchState, apiKeysState, viewState as viewStateSlice, overlayState, workbenchState, rotationState } from './state.js';
|
|
12
|
+
import { NavigationService } from '../platform/navigation.js';
|
|
10
13
|
import { SocialService } from './social.js';
|
|
11
14
|
import { OfflineService } from './offline.js';
|
|
12
15
|
import { ActivityService } from './activity.js';
|
|
13
16
|
import { registerCommands } from './commands.js';
|
|
14
|
-
import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction } from './actions.js';
|
|
17
|
+
import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction, OpenPluginPanelAction } from './actions.js';
|
|
15
18
|
|
|
16
19
|
export function createApp(platform) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
+
// Named slices, not services — see bl/state.js. Each is still its own provider, so a
|
|
21
|
+
// lease naming `explorer` still says what it touches.
|
|
22
|
+
const explorer = explorerState(platform.settings);
|
|
23
|
+
const search = searchState();
|
|
24
|
+
const apiKeys = apiKeysState();
|
|
25
|
+
const rotation = rotationState();
|
|
20
26
|
// One place for "what's running" and "what's stuck", covering both sides of the wire.
|
|
21
27
|
const activity = new ActivityService(platform);
|
|
28
|
+
// What the UI is in the middle of doing — see bl/state.js. A resource, because it
|
|
29
|
+
// decides what is on screen.
|
|
30
|
+
const viewState = viewStateSlice();
|
|
31
|
+
// The shell, split into what it actually is. `workbench` and `overlay` are slices; the
|
|
32
|
+
// panel stack stays a service, because it mirrors itself into browser history and
|
|
33
|
+
// persists recents — neither of which a state bag can do. The WorkbenchService that used
|
|
34
|
+
// to sit over all three was a 17-method delegation facade, and the provider graph could
|
|
35
|
+
// not see through it: closing a dialog leased the whole shell.
|
|
36
|
+
const workbench = workbenchState();
|
|
37
|
+
const overlay = overlayState();
|
|
38
|
+
const navigation = new NavigationService();
|
|
22
39
|
const transfers = new TransfersService(activity);
|
|
23
|
-
|
|
40
|
+
// Offline first: social queues comment and tag writes through it while disconnected, so
|
|
41
|
+
// it is a dependency rather than something bolted on afterwards. It used to be assigned
|
|
42
|
+
// onto `social` after both existed, which reads as optional and is not — a social service
|
|
43
|
+
// built without it silently drops every offline write.
|
|
24
44
|
const offline = new OfflineService(platform);
|
|
25
|
-
social
|
|
45
|
+
const social = new SocialService(platform, offline);
|
|
26
46
|
|
|
27
|
-
const app = { platform, explorer, search, transfers, social, offline, activity, apiKeys, engine: null };
|
|
47
|
+
const app = { platform, explorer, search, transfers, social, offline, activity, apiKeys, rotation, workbench, overlay, navigation, engine: null };
|
|
28
48
|
|
|
49
|
+
// Every resource the engine has, named. The engine's STATE is the state of its
|
|
50
|
+
// resources, so the single `app` provider this started with made that state one opaque
|
|
51
|
+
// blob: every query and every action leased the whole world, and a lease that always
|
|
52
|
+
// covers everything tells you nothing about what a piece of work touches or how long it
|
|
53
|
+
// needs it.
|
|
54
|
+
//
|
|
55
|
+
// `app` is gone. It survived longest as the thing command handlers closed over, and went
|
|
56
|
+
// when they stopped being closures — see bl/commands.js. Every lease now names what it
|
|
57
|
+
// touches, and `static deps = ['explorer']` means what it says.
|
|
29
58
|
const engine = new Engine({
|
|
30
|
-
providers: {
|
|
31
|
-
|
|
32
|
-
|
|
59
|
+
providers: {
|
|
60
|
+
// Where a live query keeps its subscription — see bl/queries.js. A provider, so it
|
|
61
|
+
// is per engine; keyed by the query instance, so an entry belongs to exactly one
|
|
62
|
+
// realization. A query instance is shared (it is interned), so it is the wrong place
|
|
63
|
+
// to put anything that is true only while it is being watched.
|
|
64
|
+
appState: Provider.fromSingleton(new Map()),
|
|
33
65
|
|
|
34
|
-
|
|
66
|
+
// The drive.
|
|
67
|
+
explorer: Provider.fromSingleton(explorer),
|
|
68
|
+
search: Provider.fromSingleton(search),
|
|
69
|
+
transfers: Provider.fromSingleton(transfers),
|
|
70
|
+
social: Provider.fromSingleton(social),
|
|
71
|
+
offline: Provider.fromSingleton(offline),
|
|
72
|
+
activity: Provider.fromSingleton(activity),
|
|
73
|
+
apiKeys: Provider.fromSingleton(apiKeys),
|
|
74
|
+
rotation: Provider.fromSingleton(rotation),
|
|
75
|
+
viewState: Provider.fromSingleton(viewState),
|
|
35
76
|
|
|
36
|
-
|
|
37
|
-
|
|
77
|
+
// The engine itself, for actions that choreograph — dispatching a follow-up rather
|
|
78
|
+
// than doing the follow-up's work inline. LAZY because the engine does not exist
|
|
79
|
+
// when its own providers are declared; by the time anything leases this, it does.
|
|
80
|
+
// ngin's execute context carries a dispatchFeed but no dispatcher, so this is the
|
|
81
|
+
// seam for it.
|
|
82
|
+
engine: Provider.fromLazySingleton(async () => engine, () => {}),
|
|
38
83
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
// The shell.
|
|
85
|
+
workbench: Provider.fromSingleton(workbench),
|
|
86
|
+
overlay: Provider.fromSingleton(overlay),
|
|
87
|
+
navigation: Provider.fromSingleton(navigation),
|
|
88
|
+
settings: Provider.fromSingleton(platform.settings),
|
|
89
|
+
notifications: Provider.fromSingleton(platform.notifications),
|
|
90
|
+
context: Provider.fromSingleton(platform.context),
|
|
91
|
+
commands: Provider.fromSingleton(platform.commands),
|
|
92
|
+
keybindings: Provider.fromSingleton(platform.keybindings),
|
|
93
|
+
contributions: Provider.fromSingleton(platform.contributions),
|
|
94
|
+
viewport: Provider.fromSingleton(platform.viewport),
|
|
95
|
+
voice: Provider.fromSingleton(platform.voice),
|
|
96
|
+
|
|
97
|
+
// The outside world.
|
|
98
|
+
api: Provider.fromSingleton(platform.api),
|
|
54
99
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
// What this deployment can do: which storage drivers it offers, whether it can
|
|
101
|
+
// suggest searches, how transfers reach the bucket.
|
|
102
|
+
//
|
|
103
|
+
// A PROVIDER, not an action and not a query holding a promise. Nobody asks for
|
|
104
|
+
// capabilities — they are ambient facts other things consult in order to decide, so
|
|
105
|
+
// there is no intent to dispatch and nothing to put on the feed. It was a query that
|
|
106
|
+
// memoised `api.capabilities()` on its own instance to avoid re-fetching, which is a
|
|
107
|
+
// cache with no invalidation living inside a view.
|
|
108
|
+
//
|
|
109
|
+
// It provides a CELL rather than the value, and the fetch is deliberately not
|
|
110
|
+
// awaited. Awaiting would make every lease of this block until the server answered,
|
|
111
|
+
// so a query that merely consults capabilities would be PENDING and the shell would
|
|
112
|
+
// render nothing until the round trip finished. The cell starts null — "not known
|
|
113
|
+
// yet", which every reader already treats correctly — and fills in, so anything
|
|
114
|
+
// watching simply re-projects when the answer lands.
|
|
115
|
+
//
|
|
116
|
+
// One fetch for the life of the page, because `fromLazySingleton` memoises on the
|
|
117
|
+
// creation promise. That is the provider's job, which is where it belongs.
|
|
118
|
+
// Closes over `platform.api` rather than declaring `{ deps: ['api'] }`: a provider's
|
|
119
|
+
// deps arrive as the PROVIDER INSTANCES, not as resources, so the dependency form
|
|
120
|
+
// would hand over something you must `obtain()` and release. Every other provider
|
|
121
|
+
// here closes over what it needs, and this is the same client either way.
|
|
122
|
+
capabilities: Provider.fromLazySingleton(
|
|
123
|
+
async () => {
|
|
124
|
+
const held = cell(null);
|
|
125
|
+
platform.api.capabilities().then(
|
|
126
|
+
(caps) => held.setValue(caps),
|
|
127
|
+
// A drive whose capabilities cannot be read still works; every reader treats
|
|
128
|
+
// null as "not known" and falls back. Failing the provider would take the
|
|
129
|
+
// whole shell down over an optional answer.
|
|
130
|
+
() => {},
|
|
131
|
+
);
|
|
132
|
+
return held;
|
|
133
|
+
},
|
|
134
|
+
() => {},
|
|
135
|
+
),
|
|
136
|
+
mediaUrls: Provider.fromSingleton(platform.mediaUrls),
|
|
137
|
+
plugins: Provider.fromSingleton(platform.plugins),
|
|
138
|
+
},
|
|
65
139
|
});
|
|
140
|
+
app.engine = engine;
|
|
141
|
+
|
|
142
|
+
// How a command reaches the engine. A command resolves to actions and the CommandService
|
|
143
|
+
// dispatches them; it is built with the platform, before the engine exists, so the
|
|
144
|
+
// dispatcher is handed over here.
|
|
145
|
+
//
|
|
146
|
+
// This is the boundary, not a shortcut: a keystroke and a plugin's RPC both originate
|
|
147
|
+
// outside the engine, and one of them has to carry the intent in. Everything past this
|
|
148
|
+
// point is an action on the feed.
|
|
149
|
+
platform.commands.dispatch = (action) => engine.dispatch(action);
|
|
150
|
+
// The same seam for the two other places that originate outside the engine: a docked
|
|
151
|
+
// plugin frame opening a file, and the plugin RPC asking for its panel.
|
|
152
|
+
platform.dispatch = (action) => engine.dispatch(action);
|
|
153
|
+
platform.openPluginPanel = (pluginId) => engine.dispatch(new OpenPluginPanelAction(pluginId));
|
|
154
|
+
|
|
155
|
+
registerCommands(app);
|
|
156
|
+
|
|
157
|
+
// Every built-in when-clause key, derived from the resource it summarises. Registered
|
|
158
|
+
// here because this is where those resources exist; see bl/context.js for the list and
|
|
159
|
+
// for why deriving them is not the same as moving the writes.
|
|
160
|
+
registerCoreContext(platform.context, { workbench, overlay, navigation, explorer });
|
|
161
|
+
registerViewportContext(platform.context, platform.viewport);
|
|
66
162
|
|
|
67
163
|
social.init();
|
|
68
164
|
offline.init();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One instance per logical query.
|
|
2
|
+
//
|
|
3
|
+
// ngin shares a live query realization by INSTANCE identity — its controller map is keyed on
|
|
4
|
+
// the object, and neither the class nor the fields are consulted. That is fine for a query
|
|
5
|
+
// with no arguments, which can be a module singleton. It is a trap for a parameterised one:
|
|
6
|
+
// `new MediaUrl('n1')` in two places is two realizations of the same question, so two minted
|
|
7
|
+
// URLs and two leases for one file, and nothing fails to say so.
|
|
8
|
+
//
|
|
9
|
+
// So a parameterised query declares a shared factory and is asked for, not constructed:
|
|
10
|
+
//
|
|
11
|
+
// class MediaUrl extends ViewQuery {
|
|
12
|
+
// static of = queryOf(MediaUrl);
|
|
13
|
+
// constructor(nodeId) { ... }
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// MediaUrl.of('n1') // same id, same instance, one realization
|
|
17
|
+
//
|
|
18
|
+
// A field holding a factory rather than a base class or a mixin, which is what makes this
|
|
19
|
+
// small: the query keeps whatever base it already had, each factory closes over its own
|
|
20
|
+
// table, and there is no name resolved through a prototype chain for anything to shadow.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Eviction, without a policy to get wrong.
|
|
24
|
+
*
|
|
25
|
+
* Entries are weak, and a finalizer sweeps the key once the instance is collected. That is
|
|
26
|
+
* sound because LIVENESS PINS THE INSTANCE: ngin's controller map is a plain Map holding the
|
|
27
|
+
* query as a key, deleted only on teardown, so a query with observers is strongly reachable
|
|
28
|
+
* and cannot be collected. An idle one can be, and re-asking simply builds a fresh instance.
|
|
29
|
+
*
|
|
30
|
+
* The alternative — an LRU with a cap — is actively wrong here: evicting a LIVE entry means
|
|
31
|
+
* the next `of()` mints a second instance while the first is still running, which is the
|
|
32
|
+
* exact bug this exists to prevent, arriving on a timer.
|
|
33
|
+
*
|
|
34
|
+
* (This leans on ngin's default controller map being strong. `hooks.createQueryControllersMap`
|
|
35
|
+
* could replace it with a weak one; we do not, and a weak one would break this.)
|
|
36
|
+
*/
|
|
37
|
+
const sweeper = new FinalizationRegistry(({ table, key }) => {
|
|
38
|
+
// Only if it is still the dead entry — a fresh instance may have claimed the key already.
|
|
39
|
+
if (table.get(key) && !table.get(key).deref()) table.delete(key);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reject anything a canonical key cannot capture.
|
|
44
|
+
*
|
|
45
|
+
* A function stringifies to `undefined` in JSON, so two queries taking different callbacks
|
|
46
|
+
* would key identically and silently share one realization — the very bug this exists to
|
|
47
|
+
* prevent, reintroduced by the fix. Fail at the call site instead. A query with arguments
|
|
48
|
+
* like that passes its own key function and never comes through here.
|
|
49
|
+
*/
|
|
50
|
+
function assertKeyable(value, path) {
|
|
51
|
+
const t = typeof value;
|
|
52
|
+
if (value === null || t === 'string' || t === 'number' || t === 'boolean') return;
|
|
53
|
+
if (t !== 'object') {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`Query argument ${path} is a ${t}, which cannot be part of a sharing key. ` +
|
|
56
|
+
'Pass an id and let the query look the thing up, or give queryOf a key function.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) return value.forEach((v, i) => assertKeyable(v, `${path}[${i}]`));
|
|
60
|
+
const proto = Object.getPrototypeOf(value);
|
|
61
|
+
if (proto === Object.prototype || proto === null) {
|
|
62
|
+
return Object.entries(value).forEach(([k, v]) => assertKeyable(v, `${path}.${k}`));
|
|
63
|
+
}
|
|
64
|
+
throw new TypeError(
|
|
65
|
+
`Query argument ${path} is a ${value.constructor?.name ?? 'non-plain object'}, which ` +
|
|
66
|
+
'cannot be part of a sharing key. Pass an id and let the query look the thing up, ' +
|
|
67
|
+
'or give queryOf a key function.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A canonical string for a value: object keys sorted, so `{a,b}` and `{b,a}` are one key. */
|
|
72
|
+
export function stableKey(value) {
|
|
73
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
74
|
+
if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`;
|
|
75
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableKey(value[k])}`).join(',')}}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The default key: every argument, in order, canonically. Types count, so 1 and '1' differ. */
|
|
79
|
+
export function keyOfArgs(...args) {
|
|
80
|
+
args.forEach((a, i) => assertKeyable(a, `#${i}`));
|
|
81
|
+
return args.map(stableKey).join('|');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A factory returning the shared instance of `Class` for a given argument list.
|
|
86
|
+
*
|
|
87
|
+
* class Thing extends ViewQuery { static of = queryOf(Thing); }
|
|
88
|
+
*
|
|
89
|
+
* `key` defaults to the arguments, canonically. Pass one to share more coarsely than the
|
|
90
|
+
* arguments do — an option that changes how something is displayed but not what is fetched
|
|
91
|
+
* should not split one realization into two — or to key arguments the default refuses:
|
|
92
|
+
*
|
|
93
|
+
* static of = queryOf(Thing, (nodeId, opts) => nodeId);
|
|
94
|
+
*
|
|
95
|
+
* A key rather than a hash plus a comparator: a comparator only earns its complexity when
|
|
96
|
+
* keys can collide, and a canonical key does not collide.
|
|
97
|
+
*
|
|
98
|
+
* The class is captured, so a subclass inheriting this field would build the PARENT —
|
|
99
|
+
* `Sub.of('x')` returning a `Base` is the sort of wrong that reads as right. The factory is
|
|
100
|
+
* a plain function rather than an arrow so that `this` is the class it was reached through,
|
|
101
|
+
* and it refuses to run when that is not the class it was built for. A subclass that wants
|
|
102
|
+
* sharing declares its own `static of`.
|
|
103
|
+
*
|
|
104
|
+
* A detached call — `const of = Thing.of; of('x')` — has no receiver at all, and in a module
|
|
105
|
+
* `this` is `undefined` rather than the global. That is the ordinary way to pass the factory
|
|
106
|
+
* around, so it is allowed; only a DIFFERENT class is an error.
|
|
107
|
+
*
|
|
108
|
+
* @param {Function} Class the query class, referenced from inside its own body
|
|
109
|
+
* @param {(...args: any[]) => string} [key]
|
|
110
|
+
*/
|
|
111
|
+
export function queryOf(Class, key = keyOfArgs) {
|
|
112
|
+
const table = new Map();
|
|
113
|
+
return function of(...args) {
|
|
114
|
+
if (this !== undefined && this !== Class) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`${this?.name ?? 'A subclass'}.of() would build a ${Class.name}, because \`of\` ` +
|
|
117
|
+
`captures the class it was declared on. Give ${this?.name ?? 'the subclass'} its own ` +
|
|
118
|
+
'`static of = queryOf(...)`, or call it on ' + Class.name + ' directly.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const k = key(...args);
|
|
122
|
+
const existing = table.get(k)?.deref();
|
|
123
|
+
if (existing) return existing;
|
|
124
|
+
const instance = new Class(...args);
|
|
125
|
+
table.set(k, new WeakRef(instance));
|
|
126
|
+
sweeper.register(instance, { table, key: k });
|
|
127
|
+
return instance;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// What the launcher is showing.
|
|
2
|
+
//
|
|
3
|
+
// This was ~100 lines inside the launcher COMPONENT, deciding — mid-render, where nothing
|
|
4
|
+
// else could see it — which nodes are on screen, what each heading says, whether "All
|
|
5
|
+
// items" is a lie because another page is waiting, and what the empty state should read
|
|
6
|
+
// when a load FAILED as against when the drive is genuinely empty. Every one of those is a
|
|
7
|
+
// question about engine state.
|
|
8
|
+
//
|
|
9
|
+
// It also made a smaller thing impossible: `pickView` needs the nodes on screen, and while
|
|
10
|
+
// they were assembled in a render they looked like render-layer data. They are not — they
|
|
11
|
+
// come from the explorer and search resources. Deriving them here is what lets the choice
|
|
12
|
+
// of view become engine state too.
|
|
13
|
+
//
|
|
14
|
+
// Groups are DATA. A header control is `{ icon, label, title, actions }`, not a button —
|
|
15
|
+
// the launcher used to put a rendered vnode in `group.action`, which is the same
|
|
16
|
+
// carrying-a-handle problem that menu items had before they carried `actions`.
|
|
17
|
+
|
|
18
|
+
import { parseTagQuery, filterLabel } from './tagQuery.js';
|
|
19
|
+
import {
|
|
20
|
+
ExecCommandAction, CloseSearchModalAction, OpenFileAction, SearchAction, FilterAction,
|
|
21
|
+
SetLaunchQueryAction,
|
|
22
|
+
} from './actions.js';
|
|
23
|
+
|
|
24
|
+
/** Which mode the typed query puts the launcher in. */
|
|
25
|
+
export function launcherMode(query) {
|
|
26
|
+
const q = query || '';
|
|
27
|
+
return q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const iconFor = (node) => {
|
|
31
|
+
const t = node?.contentType || '';
|
|
32
|
+
if (t.startsWith('image/')) return 'file-image';
|
|
33
|
+
if (t.startsWith('audio/')) return 'file-audio';
|
|
34
|
+
if (t.startsWith('video/')) return 'file-video';
|
|
35
|
+
return 'file-text';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Everything you can do to a file, as descriptions.
|
|
40
|
+
*
|
|
41
|
+
* `pinned` and `keys` are resolved by the caller because both are questions about engine
|
|
42
|
+
* state: whether THIS file is already available offline, and what the shortcut for a
|
|
43
|
+
* command actually is on this machine. Hardcoding "⌘⇧L" told a Windows user about a key
|
|
44
|
+
* they do not have, and told everyone the default even after they had rebound it.
|
|
45
|
+
*/
|
|
46
|
+
export function fileMenuOf(node, { pinned = false, keys = {} } = {}) {
|
|
47
|
+
const kbd = (id) => keys[id] || undefined;
|
|
48
|
+
return [
|
|
49
|
+
{ label: 'Open', icon: 'file-text', actions: [new ExecCommandAction('explorer.open', node)] },
|
|
50
|
+
{ label: 'Download', icon: 'download', actions: [new ExecCommandAction('explorer.download', node)] },
|
|
51
|
+
// Labelled by destination rather than by format: one goes in a document, the other
|
|
52
|
+
// goes to a person.
|
|
53
|
+
{ label: 'Copy shareable link', icon: 'link', actions: [new ExecCommandAction('explorer.copyShareLink')] },
|
|
54
|
+
{ label: 'Copy trove: link', icon: 'link', kbd: kbd('explorer.copyLink'), actions: [new ExecCommandAction('explorer.copyLink')] },
|
|
55
|
+
{ sep: true },
|
|
56
|
+
{ label: 'Rename…', actions: [new ExecCommandAction('explorer.rename')] },
|
|
57
|
+
pinned
|
|
58
|
+
? { label: 'Remove from offline', icon: 'close', actions: [new ExecCommandAction('offline.unpin', node)] }
|
|
59
|
+
: { label: 'Make available offline', icon: 'download', actions: [new ExecCommandAction('offline.pin', node)] },
|
|
60
|
+
{ sep: true },
|
|
61
|
+
{ label: 'Move to trash', icon: 'trash', danger: true, kbd: kbd('explorer.delete'), actions: [new ExecCommandAction('explorer.delete')] },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What you can do to something already in the trash. */
|
|
66
|
+
export function trashMenuOf(node) {
|
|
67
|
+
return [
|
|
68
|
+
{ label: 'Restore', icon: 'refresh', actions: [new ExecCommandAction('explorer.restore', node.id)] },
|
|
69
|
+
{ sep: true },
|
|
70
|
+
{ label: 'Delete forever', icon: 'trash', danger: true, actions: [new ExecCommandAction('explorer.purgeOne', node.id)] },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One row for a file.
|
|
76
|
+
*
|
|
77
|
+
* `menu` stays a thunk. A drive can have hundreds of rows on screen and almost none of
|
|
78
|
+
* their menus are ever opened, so building them all would be work nobody asked for — and a
|
|
79
|
+
* pure function deferring pure work is data as much as the array it would have returned.
|
|
80
|
+
*/
|
|
81
|
+
function fileItem(node, { modal, pinnedIds, keys }) {
|
|
82
|
+
return {
|
|
83
|
+
icon: iconFor(node),
|
|
84
|
+
title: node.name,
|
|
85
|
+
detail: node.contentType || '',
|
|
86
|
+
node,
|
|
87
|
+
// From the modal search, `reset` starts a fresh viewer stack; then close it.
|
|
88
|
+
actions: [
|
|
89
|
+
new OpenFileAction(node, { reset: !!modal }),
|
|
90
|
+
...(modal ? [new CloseSearchModalAction()] : []),
|
|
91
|
+
],
|
|
92
|
+
menu: () => fileMenuOf(node, { pinned: pinnedIds.has(node.id), keys }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The groups on screen.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} s the slices this reads, already plain
|
|
100
|
+
* @param {boolean} modal whether this is the double-shift overlay rather than the home
|
|
101
|
+
* screen — the one input that is genuinely about which INSTANCE is rendering, since both
|
|
102
|
+
* are on screen at once when the overlay is up
|
|
103
|
+
*/
|
|
104
|
+
export function launcherGroupsOf(s, modal = false) {
|
|
105
|
+
const { ex = {}, se = {}, nav = {}, query = '', commandMatches = [], pinnedIds = new Set(), keys = {} } = s;
|
|
106
|
+
const mode = launcherMode(query);
|
|
107
|
+
const row = (node) => fileItem(node, { modal, pinnedIds, keys });
|
|
108
|
+
const closeModal = modal ? [new CloseSearchModalAction()] : [];
|
|
109
|
+
|
|
110
|
+
if (mode === 'command') {
|
|
111
|
+
return [{
|
|
112
|
+
id: 'commands',
|
|
113
|
+
title: 'Commands',
|
|
114
|
+
items: commandMatches.map((c) => ({
|
|
115
|
+
icon: 'command', title: c.title, detail: c.category, badge: 'command',
|
|
116
|
+
actions: [new ExecCommandAction(c.id), ...closeModal],
|
|
117
|
+
})),
|
|
118
|
+
empty: 'No matching commands.',
|
|
119
|
+
}];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { text, filters } = parseTagQuery(query);
|
|
123
|
+
const nodes = (se.results || []).map((r) => r.node);
|
|
124
|
+
const err = se.error;
|
|
125
|
+
// A one-click retry so a transient search/filter failure isn't a dead end.
|
|
126
|
+
const retry = (actions) => (err ? [{ icon: 'refresh', label: 'Retry', title: 'Retry', actions }] : []);
|
|
127
|
+
|
|
128
|
+
// Drive-wide tag/property filter (server-side), optionally narrowed by free text.
|
|
129
|
+
if (filters.length) {
|
|
130
|
+
const label = filters.map(filterLabel).join(' ') + (text.trim() ? ` · "${text.trim()}"` : '');
|
|
131
|
+
return [{
|
|
132
|
+
id: 'filtered',
|
|
133
|
+
title: se.loading ? 'Filtering…' : err ? 'Filter failed' : 'Filtered',
|
|
134
|
+
// Shown as typed: a tag is a value, not a heading.
|
|
135
|
+
verbatim: se.loading || err ? null : label,
|
|
136
|
+
controls: retry([new FilterAction(filters, text)]),
|
|
137
|
+
items: nodes.map(row),
|
|
138
|
+
empty: se.loading ? 'Filtering…' : err ? `Couldn’t filter: ${err}` : 'No files match those filters.',
|
|
139
|
+
}];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Free-text search.
|
|
143
|
+
if (text.trim()) {
|
|
144
|
+
return [{
|
|
145
|
+
id: 'results',
|
|
146
|
+
title: se.loading ? 'Searching…' : err ? 'Search failed' : 'Results',
|
|
147
|
+
controls: retry([new SearchAction(query)]),
|
|
148
|
+
items: nodes.map(row),
|
|
149
|
+
empty: se.loading ? 'Searching…' : err ? `Couldn’t search: ${err}` : 'No files match.',
|
|
150
|
+
}];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Home: recents, then everything in the collection. There is nothing to descend into —
|
|
154
|
+
// this is the "show me everything" fallback for when search isn't the answer.
|
|
155
|
+
const groups = [];
|
|
156
|
+
const recents = (nav.recents || []).map(row);
|
|
157
|
+
if (recents.length) groups.push({ id: 'recent', title: 'Recent', items: recents });
|
|
158
|
+
|
|
159
|
+
const shown = (ex.items || []).length;
|
|
160
|
+
// `stats` is the collection; `items` is the page. When the server didn't report stats we
|
|
161
|
+
// only know the page — and saying "500" while a next page exists is a claim about the
|
|
162
|
+
// drive that is false, so say "500+" instead.
|
|
163
|
+
const knownTotal = ex.stats?.items ?? null;
|
|
164
|
+
const total = knownTotal ?? shown;
|
|
165
|
+
const totalLabel = knownTotal != null ? knownTotal.toLocaleString() : `${shown.toLocaleString()}+`;
|
|
166
|
+
const items = (ex.items || []).map(row);
|
|
167
|
+
// A partial list must not be titled "All items" — that is a claim about the drive, and on
|
|
168
|
+
// a collection bigger than one page it is false. Say what is on screen, and offer the rest
|
|
169
|
+
// rather than leaving it unreachable.
|
|
170
|
+
if (ex.nextCursor) {
|
|
171
|
+
items.push({
|
|
172
|
+
icon: 'refresh',
|
|
173
|
+
title: ex.loadingMore ? 'Loading…'
|
|
174
|
+
: knownTotal != null ? `Show more (${(knownTotal - shown).toLocaleString()} more)` : 'Show more',
|
|
175
|
+
detail: 'or search to jump straight to something',
|
|
176
|
+
actions: [new ExecCommandAction('explorer.loadMore')],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The trash, when it has been opened. Not shown by default: it is a place you go to
|
|
181
|
+
// recover a mistake, not part of browsing the drive.
|
|
182
|
+
if (ex.trash) {
|
|
183
|
+
groups.push({
|
|
184
|
+
id: 'trash',
|
|
185
|
+
// One line, not two: a header reading "0 items" above a body reading "the trash is
|
|
186
|
+
// empty" is the same sentence twice, permanently parked above the drive.
|
|
187
|
+
title: ex.trash.length
|
|
188
|
+
? `Trash · ${ex.trash.length} item${ex.trash.length === 1 ? '' : 's'}`
|
|
189
|
+
: 'Trash',
|
|
190
|
+
controls: [
|
|
191
|
+
...(ex.trash.length
|
|
192
|
+
? [{ icon: 'trash', label: 'Empty trash', title: 'Destroy everything in the trash', actions: [new ExecCommandAction('explorer.emptyTrash')] }]
|
|
193
|
+
: []),
|
|
194
|
+
// The way back out. Opening the trash used to be one-way until a page reload.
|
|
195
|
+
{ icon: 'close', label: 'Close', title: 'Hide the trash', actions: [new ExecCommandAction('explorer.hideTrash')] },
|
|
196
|
+
],
|
|
197
|
+
items: (ex.trash || []).map((n) => ({
|
|
198
|
+
icon: 'trash',
|
|
199
|
+
title: n.name,
|
|
200
|
+
detail: `deleted ${new Date(n.deletedAt).toLocaleString()} — restore`,
|
|
201
|
+
actions: [new ExecCommandAction('explorer.restore', n.id)],
|
|
202
|
+
menu: () => trashMenuOf(n),
|
|
203
|
+
})),
|
|
204
|
+
empty: 'The trash is empty.',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
groups.push({
|
|
209
|
+
id: 'all',
|
|
210
|
+
title: ex.nextCursor ? `All items · showing ${shown.toLocaleString()} of ${totalLabel}` : 'All items',
|
|
211
|
+
// The empty state told people to upload a file, and on desktop and TV there was nothing
|
|
212
|
+
// anywhere that would let them. (Phone has the + in its bottom bar.)
|
|
213
|
+
controls: modal ? [] : [{ icon: 'upload', label: 'Upload', title: 'Upload files to this collection', actions: [new ExecCommandAction('explorer.upload')] }],
|
|
214
|
+
items,
|
|
215
|
+
// Don't show a false "empty" when the load actually FAILED (e.g. server unreachable) —
|
|
216
|
+
// say so, so the user knows to retry rather than believing the collection is empty. (A
|
|
217
|
+
// toast also fires, but the persistent state must be honest.)
|
|
218
|
+
empty: ex.loading ? 'Loading…' : ex.error ? `Couldn’t load this collection: ${ex.error}` : 'Nothing here yet — upload a file to get started.',
|
|
219
|
+
});
|
|
220
|
+
return groups;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The suggestions shown when a search came back with nothing.
|
|
225
|
+
*
|
|
226
|
+
* Two halves, both settled by the `launcherContent` query: whether an offer applies at all
|
|
227
|
+
* — a search ran, it succeeded, it found nothing — and what to suggest, which the server
|
|
228
|
+
* decides and reaches the engine through the `capabilities` resource. Each example carries
|
|
229
|
+
* the actions that run it, so the component only draws them.
|
|
230
|
+
*/
|
|
231
|
+
export function searchHelpOf({ eligible = false, caps = null } = {}) {
|
|
232
|
+
if (!eligible) return null;
|
|
233
|
+
const p = caps?.searchPrompt;
|
|
234
|
+
if (!p?.hint && !p?.examples?.length) return null;
|
|
235
|
+
return {
|
|
236
|
+
hint: p.hint || null,
|
|
237
|
+
examples: (p.examples || []).map((ex) => {
|
|
238
|
+
const { text, filters } = parseTagQuery(ex.query);
|
|
239
|
+
return {
|
|
240
|
+
query: ex.query,
|
|
241
|
+
label: ex.label || null,
|
|
242
|
+
// Running an example is the same two steps as typing it: put it in the box, then
|
|
243
|
+
// ask whichever of search or filter that spelling means.
|
|
244
|
+
actions: [
|
|
245
|
+
new SetLaunchQueryAction(ex.query),
|
|
246
|
+
filters.length ? new FilterAction(filters, text) : new SearchAction(text),
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -29,7 +29,7 @@ export async function openTroveLink(ui, uri, { from } = {}) {
|
|
|
29
29
|
try {
|
|
30
30
|
const res = await ui.platform.api.stat(uri);
|
|
31
31
|
if (!res?.node) throw new Error('not found');
|
|
32
|
-
ui.
|
|
32
|
+
ui.engine.dispatch(new OpenFileAction(res.node));
|
|
33
33
|
return res.node;
|
|
34
34
|
} catch (err) {
|
|
35
35
|
notify.warn(describeBrokenLink(ref, err, from));
|