@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,154 +1,42 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// What is left of the data services, plus the derivations that were methods on them.
|
|
2
|
+
//
|
|
3
|
+
// The explorer, search and API-key services were state bags — a `state`, a `cell`, and a
|
|
4
|
+
// `set` that wrote both — and are slices now (bl/state.js). TransfersService stays,
|
|
5
|
+
// because it holds things a state snapshot cannot: AbortControllers, per-file retry
|
|
6
|
+
// thunks closing over the File itself, and a projection into the activity list.
|
|
7
|
+
//
|
|
8
|
+
// The functions below were METHODS on those bags. They are derivations, so they belong to
|
|
9
|
+
// whoever is asking rather than to whatever happens to hold the data.
|
|
6
10
|
|
|
7
11
|
import { cell } from '../runtime.js';
|
|
8
12
|
|
|
9
|
-
export class ExplorerService {
|
|
10
|
-
constructor(settings) {
|
|
11
|
-
this.settings = settings;
|
|
12
|
-
this.state = {
|
|
13
|
-
items: [], loading: false, error: null,
|
|
14
|
-
selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
|
|
15
|
-
// No collection until one is chosen or created. `gate` is 'create' | 'choose' | null
|
|
16
|
-
// — when set, it is the ONLY thing the workbench shows, because every request needs
|
|
17
|
-
// a collection and there is nothing sensible to render without one.
|
|
18
|
-
collectionId: null, collections: [], canCreateCollection: false, gate: null,
|
|
19
|
-
// `stats` is the whole collection; `items` is the page on screen. Keeping both
|
|
20
|
-
// is what lets the UI say "500 of 3,006" instead of quietly claiming 500.
|
|
21
|
-
stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
|
|
22
|
-
};
|
|
23
|
-
this.cell = cell(this.state);
|
|
24
|
-
}
|
|
25
|
-
observe() {
|
|
26
|
-
return this.cell;
|
|
27
|
-
}
|
|
28
|
-
set(patch) {
|
|
29
|
-
this.state = { ...this.state, ...patch };
|
|
30
|
-
this.cell.setValue(this.state);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* @param {string[]} ids
|
|
34
|
-
* @param {{additive?: boolean, nodes?: object[]}} [opts] `nodes` is the caller's own
|
|
35
|
-
* copy of what it selected — pass it whenever you have it (see selectedNodes).
|
|
36
|
-
*/
|
|
37
|
-
select(ids, { additive = false, nodes = null } = {}) {
|
|
38
|
-
const next = additive ? Array.from(new Set([...this.state.selection, ...ids])) : ids;
|
|
39
|
-
// Selecting what is already selected must not emit. The launcher syncs the
|
|
40
|
-
// highlighted row into here on every mouseenter, and a state push per mouse move
|
|
41
|
-
// would re-render the list under the pointer.
|
|
42
|
-
const same = next.length === this.state.selection.length
|
|
43
|
-
&& next.every((id, i) => id === this.state.selection[i]);
|
|
44
|
-
if (same) return;
|
|
45
|
-
this.set({ selection: next, selectionNodes: nodes && !additive ? nodes : null });
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* The nodes the selection refers to.
|
|
49
|
-
*
|
|
50
|
-
* Resolving ids against `items` only finds rows on the LOADED PAGE of the CURRENT
|
|
51
|
-
* collection — and the launcher's rows come from search (which the server scopes to
|
|
52
|
-
* every readable collection) and from recents (which survive a collection switch).
|
|
53
|
-
* So every row reached by searching resolved to nothing, and rename / move-to-trash /
|
|
54
|
-
* copy-link returned silently while `explorer.hasSelection` said there was a
|
|
55
|
-
* selection. Preferring the nodes the selecting caller already held fixes the whole
|
|
56
|
-
* class; the `items` lookup stays for callers that only have ids.
|
|
57
|
-
*/
|
|
58
|
-
selectedNodes() {
|
|
59
|
-
const held = this.state.selectionNodes;
|
|
60
|
-
if (held?.length) return held.filter((n) => this.state.selection.includes(n.id));
|
|
61
|
-
return this.state.items.filter((i) => this.state.selection.includes(i.id));
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
13
|
|
|
65
14
|
/**
|
|
66
|
-
*
|
|
15
|
+
* The nodes the selection refers to — a pure function of explorer state.
|
|
67
16
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
17
|
+
* It was a method, which meant every caller reached for the service to ask a question about
|
|
18
|
+
* data it could already see. As a function it can be used two ways without either being a
|
|
19
|
+
* back door: the explorer QUERY folds it into the view, so the UI is handed the answer; and
|
|
20
|
+
* an effect that needs it right now computes it from the state it already holds.
|
|
71
21
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
22
|
+
* Resolving ids against `items` only finds rows on the LOADED PAGE of the CURRENT
|
|
23
|
+
* collection — and the launcher's rows come from search (which the server scopes to every
|
|
24
|
+
* readable collection) and from recents (which survive a collection switch). So every row
|
|
25
|
+
* reached by searching resolved to nothing, and rename / move-to-trash / copy-link returned
|
|
26
|
+
* silently while `explorer.hasSelection` said there was a selection. Preferring the nodes
|
|
27
|
+
* the selecting caller already held fixes the whole class; the `items` lookup stays for
|
|
28
|
+
* callers that only have ids.
|
|
75
29
|
*/
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
draft: null,
|
|
84
|
-
};
|
|
85
|
-
this.cell = cell(this.state);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Open the mint form, empty. */
|
|
89
|
-
startDraft() {
|
|
90
|
-
this.set({ draft: { name: '', expiresInDays: '', caps: {} }, error: null });
|
|
91
|
-
}
|
|
92
|
-
cancelDraft() {
|
|
93
|
-
this.set({ draft: null });
|
|
94
|
-
}
|
|
95
|
-
patchDraft(patch) {
|
|
96
|
-
if (!this.state.draft) return;
|
|
97
|
-
this.set({ draft: { ...this.state.draft, ...patch } });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Toggle one capability on one collection in the draft.
|
|
102
|
-
*
|
|
103
|
-
* `admin` is not treated specially here — it is offered as itself and the server
|
|
104
|
-
* expands it. Pre-ticking read/write/delete when admin is chosen would suggest they
|
|
105
|
-
* are separable afterwards, and they are not.
|
|
106
|
-
*/
|
|
107
|
-
toggleCap(collectionId, capability) {
|
|
108
|
-
if (!this.state.draft) return;
|
|
109
|
-
const caps = { ...this.state.draft.caps };
|
|
110
|
-
const held = new Set(caps[collectionId] || []);
|
|
111
|
-
if (held.has(capability)) held.delete(capability);
|
|
112
|
-
else held.add(capability);
|
|
113
|
-
if (held.size) caps[collectionId] = [...held];
|
|
114
|
-
else delete caps[collectionId];
|
|
115
|
-
this.patchDraft({ caps });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The draft as the API wants it, or null when it would grant nothing. */
|
|
119
|
-
draftScopes() {
|
|
120
|
-
const caps = this.state.draft?.caps || {};
|
|
121
|
-
const scopes = Object.entries(caps)
|
|
122
|
-
.filter(([, list]) => list.length)
|
|
123
|
-
.map(([collectionId, capabilities]) => ({ collectionId, capabilities }));
|
|
124
|
-
return scopes.length ? scopes : null;
|
|
125
|
-
}
|
|
126
|
-
observe() {
|
|
127
|
-
return this.cell;
|
|
128
|
-
}
|
|
129
|
-
set(patch) {
|
|
130
|
-
this.state = { ...this.state, ...patch };
|
|
131
|
-
this.cell.setValue(this.state);
|
|
132
|
-
}
|
|
133
|
-
/** Forget the freshly minted secret. Called on dismiss, and after a copy. */
|
|
134
|
-
clearMinted() {
|
|
135
|
-
if (this.state.minted) this.set({ minted: null });
|
|
136
|
-
}
|
|
30
|
+
export function selectedNodesOf(state) {
|
|
31
|
+
// Tolerant of a partial state: a view is computed on every emission, including the first
|
|
32
|
+
// one, and a projection that throws takes the whole query down rather than the field.
|
|
33
|
+
const selection = state?.selection || [];
|
|
34
|
+
const held = state?.selectionNodes;
|
|
35
|
+
if (held?.length) return held.filter((n) => selection.includes(n.id));
|
|
36
|
+
return (state?.items || []).filter((i) => selection.includes(i.id));
|
|
137
37
|
}
|
|
138
38
|
|
|
139
|
-
|
|
140
|
-
constructor() {
|
|
141
|
-
this.state = { query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false, paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null };
|
|
142
|
-
this.cell = cell(this.state);
|
|
143
|
-
}
|
|
144
|
-
observe() {
|
|
145
|
-
return this.cell;
|
|
146
|
-
}
|
|
147
|
-
set(patch) {
|
|
148
|
-
this.state = { ...this.state, ...patch };
|
|
149
|
-
this.cell.setValue(this.state);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
39
|
+
|
|
152
40
|
|
|
153
41
|
/**
|
|
154
42
|
* Uploads in flight.
|
|
@@ -278,3 +166,51 @@ export class TransfersService {
|
|
|
278
166
|
this.#emit();
|
|
279
167
|
}
|
|
280
168
|
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The API-key draft as the API wants it, or null when it would grant nothing.
|
|
173
|
+
*
|
|
174
|
+
* A pure function rather than a method, for the same reason as `selectedNodesOf`: it is a
|
|
175
|
+
* derivation, so it belongs to the view that shows it and to the action that submits it,
|
|
176
|
+
* not to the resource that happens to hold the draft.
|
|
177
|
+
*/
|
|
178
|
+
export function draftScopesOf(state) {
|
|
179
|
+
const caps = state?.draft?.caps || {};
|
|
180
|
+
const scopes = Object.entries(caps)
|
|
181
|
+
.filter(([, list]) => list.length)
|
|
182
|
+
.map(([collectionId, capabilities]) => ({ collectionId, capabilities }));
|
|
183
|
+
return scopes.length ? scopes : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The collection switcher's menu, derived from what the explorer knows.
|
|
189
|
+
*
|
|
190
|
+
* A pure function for the same reason as `selectedNodesOf`: it answers a question about
|
|
191
|
+
* state, so it belongs to the view that shows it. It used to be a closure hung on `app` by
|
|
192
|
+
* `registerCommands` — reachable only through that one field, and impossible to see from
|
|
193
|
+
* the component that rendered it.
|
|
194
|
+
*
|
|
195
|
+
* Items carry `actions`, like every other menu item; see ui/activate.js.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} state explorer state
|
|
198
|
+
* @param {(id?: string) => object} switchTo builds the action for picking a collection
|
|
199
|
+
* @param {() => object} create builds the action for making a new one
|
|
200
|
+
*/
|
|
201
|
+
export function collectionMenuOf(state, switchTo, create) {
|
|
202
|
+
// No fallback: this only decides which row gets a tick, and with nothing open the answer
|
|
203
|
+
// is that none of them do. `|| 'default'` ticked a collection the user had not chosen,
|
|
204
|
+
// and on a drive with one actually called "default", the wrong one.
|
|
205
|
+
const current = state?.collectionId;
|
|
206
|
+
const items = (state?.collections || []).map((c) => ({
|
|
207
|
+
label: c.name || c.id,
|
|
208
|
+
icon: c.id === current ? 'check' : 'files',
|
|
209
|
+
actions: [switchTo(c.id)],
|
|
210
|
+
}));
|
|
211
|
+
if (state?.canCreateCollection) {
|
|
212
|
+
if (items.length) items.push({ sep: true });
|
|
213
|
+
items.push({ label: 'New collection…', icon: 'plus', actions: [create()] });
|
|
214
|
+
}
|
|
215
|
+
return items;
|
|
216
|
+
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { cell } from '../runtime.js';
|
|
9
9
|
|
|
10
10
|
export class SocialService {
|
|
11
|
-
constructor(platform) {
|
|
11
|
+
constructor(platform, offline = null) {
|
|
12
|
+
// How a comment or tag written while disconnected reaches the server later.
|
|
13
|
+
this.offline = offline;
|
|
12
14
|
this.platform = platform;
|
|
13
15
|
this.api = platform.api;
|
|
14
16
|
this.state = {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The drive's own state, as named slices.
|
|
2
|
+
//
|
|
3
|
+
// These were classes: an `ExplorerService`, a `SearchClientService`, an `ApiKeysService`,
|
|
4
|
+
// each holding a `state` field, a `cell`, and a `set` that wrote both. Nothing else. A
|
|
5
|
+
// service should cover something complex and offer it through a narrow door — these
|
|
6
|
+
// covered an object.
|
|
7
|
+
//
|
|
8
|
+
// Two things came of that shape and both are gone with it.
|
|
9
|
+
//
|
|
10
|
+
// TWO DOORS. Actions read `.state` and queries read `.cell`, and the two are only equal by
|
|
11
|
+
// habit: most services wrote `this.state = {...}; this.cell.setValue(this.state)`, but not
|
|
12
|
+
// all did, and `settings` still has a cell holding `effective()` and no `state` field at
|
|
13
|
+
// all. A slice has one value and one way to read it.
|
|
14
|
+
//
|
|
15
|
+
// LOGIC IN THE HOLDER. `toggleCap` computed a capability set inside the service while an
|
|
16
|
+
// action stood in front of it forwarding two arguments — and dropped one of them, which is
|
|
17
|
+
// how a minted key came to be rejected by the server. Arithmetic over state belongs to the
|
|
18
|
+
// action that decides to change it; the slice only holds.
|
|
19
|
+
//
|
|
20
|
+
// Each slice is still its OWN provider, so `static deps = ['explorer']` keeps meaning what
|
|
21
|
+
// it says. A single `appState` covering everything would put the whole drive back behind
|
|
22
|
+
// one lease, which is what naming the resources individually was for.
|
|
23
|
+
|
|
24
|
+
import { cell } from '../runtime.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One named piece of the drive's state.
|
|
28
|
+
*
|
|
29
|
+
* `set` merges, because every caller was already merging and doing it here removes the
|
|
30
|
+
* chance of a caller replacing a slice by forgetting to spread it. `replace` exists for
|
|
31
|
+
* the cases that genuinely mean "all of it" — a fresh panel stack, an emptied list.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} initial
|
|
34
|
+
*/
|
|
35
|
+
export function slice(initial = {}) {
|
|
36
|
+
const held = cell(initial);
|
|
37
|
+
return {
|
|
38
|
+
/** The cell, for a query to watch. */
|
|
39
|
+
observe: () => held,
|
|
40
|
+
/** The value, for an action about to decide something from it. */
|
|
41
|
+
get: () => held.getValue(),
|
|
42
|
+
/** Merge a patch in. A new object every time — a cell compares with Object.is. */
|
|
43
|
+
set: (patch) => held.setValue({ ...held.getValue(), ...patch }),
|
|
44
|
+
/** Replace it outright, for when a patch would be a lie. */
|
|
45
|
+
replace: (next) => held.setValue(next),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Items, selection, the open collection, and the gate. */
|
|
50
|
+
export const explorerState = (settings) => slice({
|
|
51
|
+
items: [], loading: false, error: null,
|
|
52
|
+
selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
|
|
53
|
+
// No collection until one is chosen or created. `gate` is 'create' | 'choose' | null —
|
|
54
|
+
// when set, it is the ONLY thing the workbench shows, because every request needs a
|
|
55
|
+
// collection and there is nothing sensible to render without one.
|
|
56
|
+
collectionId: null, collections: [], canCreateCollection: false, gate: null,
|
|
57
|
+
// `stats` is the whole collection; `items` is the page on screen. Keeping both is what
|
|
58
|
+
// lets the UI say "500 of 3,006" instead of quietly claiming 500.
|
|
59
|
+
stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Query text, results, and the palette's separate file list. */
|
|
63
|
+
export const searchState = () => slice({
|
|
64
|
+
query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false,
|
|
65
|
+
paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The admin API-key list.
|
|
70
|
+
*
|
|
71
|
+
* `minted` holds the one secret a mint returns. It lives here, in memory, and is dropped
|
|
72
|
+
* the moment the admin dismisses it — the server cannot show it again, so the UI is the
|
|
73
|
+
* only place it ever exists and it must not outlive the tab.
|
|
74
|
+
*
|
|
75
|
+
* `draft` is the mint form. Held in state rather than in the DOM so the section stays a
|
|
76
|
+
* pure function of state, and so a half-filled form survives a re-render caused by
|
|
77
|
+
* something else on the settings screen.
|
|
78
|
+
*/
|
|
79
|
+
export const apiKeysState = () => slice({
|
|
80
|
+
keys: [], loading: false, loaded: false, error: null, minted: null, busy: null, draft: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What the UI is in the middle of doing — a keybinding mid-capture, text typed into a
|
|
85
|
+
* dialog that has not been submitted, the boxes ticked in a plugin review.
|
|
86
|
+
*
|
|
87
|
+
* None of it is drive state; all of it decides what is on screen, which is what makes it
|
|
88
|
+
* engine state. Keyed by component, so `set` here replaces one key rather than merging a
|
|
89
|
+
* patch — see SetViewStateAction.
|
|
90
|
+
*/
|
|
91
|
+
export const viewState = () => slice({});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The shell's transient overlays: the command palette, a modal dialog, the right-click
|
|
95
|
+
* menu, and the plugin popup panel.
|
|
96
|
+
*
|
|
97
|
+
* Four things that are independent of the panel stack and of each other, which is why they
|
|
98
|
+
* were split out of the workbench in the first place. What they are NOT is complex enough
|
|
99
|
+
* to need a service — the class was four setters and a `wrapIndex`, with an action standing
|
|
100
|
+
* in front of each one. The wrapping now lives in the action that moves a cursor, which is
|
|
101
|
+
* where it was needed: MovePaletteAction lost the count argument once already, precisely
|
|
102
|
+
* because deciding and writing were on opposite sides of a forwarding layer.
|
|
103
|
+
*/
|
|
104
|
+
export const overlayState = () => slice({
|
|
105
|
+
palette: null, // { mode: 'commands'|'files', query, index } when open
|
|
106
|
+
dialog: null,
|
|
107
|
+
contextMenu: null,
|
|
108
|
+
pluginPanel: null,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The shell itself: which activity is showing, and the launcher's own cursor.
|
|
113
|
+
*
|
|
114
|
+
* The panel stack is NOT here — that is NavigationService, which stays a service because it
|
|
115
|
+
* mirrors the stack into browser history and persists recents, neither of which a state bag
|
|
116
|
+
* can do.
|
|
117
|
+
*/
|
|
118
|
+
export const workbenchState = () => slice({
|
|
119
|
+
activity: 'home', // home (stack) | plugins | settings
|
|
120
|
+
sidebarVisible: true,
|
|
121
|
+
launch: { query: '', index: 0 },
|
|
122
|
+
searchModal: false, // the double-shift modal search overlay
|
|
123
|
+
// Phone chrome: which bottom sheet is up, if any ('status' | 'more'). A phone has no room
|
|
124
|
+
// for a permanent status bar or a left rail, so both fold into a sheet pulled up on demand.
|
|
125
|
+
sheet: null,
|
|
126
|
+
infoPanel: false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** Wrap a list cursor by `delta` within `[0, count)` — the palette and the launcher share it. */
|
|
130
|
+
export function wrapIndex(index, delta, count) {
|
|
131
|
+
return (index + delta + count) % count;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What the open collection's key is doing.
|
|
136
|
+
*
|
|
137
|
+
* Rotation is long-running and lives on the server, so this is a local view of it kept in
|
|
138
|
+
* step by polling while the settings screen is open — see `RotationQuery`. It is state
|
|
139
|
+
* rather than a fetch-per-render because two things read it (the progress line and whether
|
|
140
|
+
* Start is offered) and they must not disagree.
|
|
141
|
+
*/
|
|
142
|
+
export const rotationState = () => slice({
|
|
143
|
+
collectionId: null, rotation: null, estimate: null, loading: false, error: null, busy: false,
|
|
144
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What the drive is doing and how full it is — one answer, for every surface that says so.
|
|
2
|
+
//
|
|
3
|
+
// This lived in the status bar and was imported from there by the phone chrome, which is
|
|
4
|
+
// how it was already admitting to being shared: two shells rendering the same facts in
|
|
5
|
+
// different shapes. It is a derivation over four resources, so it belongs here, and it is
|
|
6
|
+
// reached as the `statusFacts` query rather than recomputed per render.
|
|
7
|
+
//
|
|
8
|
+
// The reason it has to be derived ONCE rather than twice is on the record: the phone bar
|
|
9
|
+
// rendered `collectionId` raw and showed an opaque `col_…` where the desktop bar showed the
|
|
10
|
+
// collection's name — the same fact, said two ways, because each surface worked it out.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The facts every shell reports.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ex: object, tr: object, act: object, off: object}} slices
|
|
16
|
+
*/
|
|
17
|
+
export function statusFactsOf({ ex = {}, tr = {}, act = {}, off = {} } = {}) {
|
|
18
|
+
const items = ex.items || [];
|
|
19
|
+
const tasks = act.tasks || [];
|
|
20
|
+
return {
|
|
21
|
+
// `null` with nothing open — `collectionLabelOf` renders that as "no collection". The
|
|
22
|
+
// old fallback made the bar name a collection that may not exist, on a drive where the
|
|
23
|
+
// user had not yet chosen one.
|
|
24
|
+
collectionId: ex.collectionId ?? null,
|
|
25
|
+
// What to CALL it, so the phone shell and the desktop bar cannot end up saying
|
|
26
|
+
// different things.
|
|
27
|
+
collectionLabel: collectionLabelOf(ex),
|
|
28
|
+
// The COLLECTION's totals when the server could give them, not the page's. Summing what
|
|
29
|
+
// happens to be loaded reports a 3,000-file drive as 500 files — a wrong number, not a
|
|
30
|
+
// rounded one. Falls back to the page only when the server didn't say.
|
|
31
|
+
totalItems: ex.stats?.items ?? items.length,
|
|
32
|
+
// Whether that total is the COLLECTION's or just the page we happen to hold. With more
|
|
33
|
+
// pages waiting, the page length is a floor, not a total, and must read as one.
|
|
34
|
+
totalKnown: ex.stats?.items != null,
|
|
35
|
+
totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
|
|
36
|
+
shown: items.length,
|
|
37
|
+
partial: !!ex.nextCursor,
|
|
38
|
+
usage: ex.usage ?? null,
|
|
39
|
+
usageLevel: usageLevelOf(ex.usage),
|
|
40
|
+
uploading: (tr.items || []).filter((t) => t.status === 'active'),
|
|
41
|
+
running: tasks.filter((t) => t.status === 'running'),
|
|
42
|
+
issues: act.issues || [],
|
|
43
|
+
off: { online: true, pins: [], queued: 0, syncing: false, ...off },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The open collection's name, or the plainest true thing when there isn't one. */
|
|
48
|
+
export function collectionLabelOf(ex) {
|
|
49
|
+
if (!ex?.collectionId) return 'no collection';
|
|
50
|
+
const match = (ex.collections || []).find((c) => c.id === ex.collectionId);
|
|
51
|
+
return match?.name || ex.collectionId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How worried to be about space, or '' when there is nothing to say.
|
|
56
|
+
*
|
|
57
|
+
* A filesystem or NAS can answer exactly, and that is where it matters: a disk fills up and
|
|
58
|
+
* every upload starts failing with no warning that anything was coming. An object store has
|
|
59
|
+
* no equivalent number, so this answers '' and the meter is not drawn at all rather than
|
|
60
|
+
* being drawn meaninglessly.
|
|
61
|
+
*
|
|
62
|
+
* The thresholds are a judgement about when someone still has time to act, which is why
|
|
63
|
+
* they are here and not in whichever bar happens to be on screen.
|
|
64
|
+
*/
|
|
65
|
+
export function usageLevelOf(usage) {
|
|
66
|
+
if (!usage?.total) return '';
|
|
67
|
+
const free = usage.available / usage.total;
|
|
68
|
+
return free < 0.05 ? 'critical' : free < 0.1 ? 'low' : '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one thing worth saying about the drive right now, most urgent first.
|
|
73
|
+
*
|
|
74
|
+
* The phone has room for a single glyph, so something has to decide what outranks what:
|
|
75
|
+
* being offline beats a standing problem, a standing problem beats work in progress, and
|
|
76
|
+
* anything beats running low on space. That ordering is a claim about which condition a
|
|
77
|
+
* person most needs to know, which makes it a decision rather than a rendering.
|
|
78
|
+
*
|
|
79
|
+
* Answers a KIND, not an icon. Which glyph draws a kind is the shell's business.
|
|
80
|
+
*/
|
|
81
|
+
export function driveConditionOf(facts) {
|
|
82
|
+
const f = facts || {};
|
|
83
|
+
if (!f.off?.online) return { kind: 'offline', label: 'Offline' };
|
|
84
|
+
if (f.issues?.length) {
|
|
85
|
+
return {
|
|
86
|
+
kind: 'issues',
|
|
87
|
+
label: `${f.issues.length} need${f.issues.length === 1 ? 's' : ''} attention`,
|
|
88
|
+
count: f.issues.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (f.running?.length || f.uploading?.length || f.off?.syncing) {
|
|
92
|
+
return { kind: 'working', label: 'Working…', count: (f.running?.length || 0) + (f.uploading?.length || 0) };
|
|
93
|
+
}
|
|
94
|
+
if (f.usageLevel) return { kind: 'lowSpace', label: 'Low on space' };
|
|
95
|
+
return { kind: 'idle', label: 'Status' };
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// What a plugin package's signature actually means.
|
|
2
|
+
//
|
|
3
|
+
// This is a security classification, not a label. It decided the same four states in two
|
|
4
|
+
// different components — the installed-plugin list and the install-review dialog — with two
|
|
5
|
+
// different sets of words, and the distinction that matters most was the one that drifted:
|
|
6
|
+
//
|
|
7
|
+
// `invalid` is NOT `unverified`. Unverified means nobody signed it, which is the ordinary
|
|
8
|
+
// case for a plugin you got from a friend. Invalid means it WAS signed and the signature
|
|
9
|
+
// does not verify — the bytes are not the bytes that were signed. That is the one state
|
|
10
|
+
// implying someone altered the package, and the list once rendered it in the same amber as
|
|
11
|
+
// the benign case, because the two `if` ladders were maintained separately and one of them
|
|
12
|
+
// was missing a branch.
|
|
13
|
+
//
|
|
14
|
+
// So the classification lives here, once, and the components choose their own wording from
|
|
15
|
+
// `status`. What is a security judgement is in the business layer; how many characters fit
|
|
16
|
+
// in a badge is not.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a package may be installed at all, and on what terms.
|
|
20
|
+
*
|
|
21
|
+
* Three answers, because the states differ in kind rather than in degree:
|
|
22
|
+
*
|
|
23
|
+
* - `invalid` is REFUSED. It means the package was signed and the signature does not
|
|
24
|
+
* verify — the bytes are not the bytes that were signed. No development workflow
|
|
25
|
+
* produces that; it means the package was altered in transit or at rest. There is
|
|
26
|
+
* nothing for a user to weigh, so they are not asked.
|
|
27
|
+
*
|
|
28
|
+
* - `unverified` is installable but only DELIBERATELY. Nobody signed it, which is the
|
|
29
|
+
* ordinary state of a plugin you are writing: you cannot sign a package you are still
|
|
30
|
+
* changing. So it stays possible, behind an acknowledgement that says what is being
|
|
31
|
+
* given up — no publisher, no proof the code is what its author shipped.
|
|
32
|
+
*
|
|
33
|
+
* - `signed` carries a real signature whose domain does not vouch for the key. That is a
|
|
34
|
+
* claim to a namespace rather than a proof of one, so it is said prominently and left
|
|
35
|
+
* to the user; refusing it would block a valid publisher whose assetlinks are merely
|
|
36
|
+
* misconfigured.
|
|
37
|
+
*
|
|
38
|
+
* This is also what closes namespace squatting. A package claiming another publisher's
|
|
39
|
+
* domain is unverified by construction — the real domain does not publish its key — so it
|
|
40
|
+
* cannot be installed without the user being told exactly that.
|
|
41
|
+
*/
|
|
42
|
+
export function installPolicyFor(trust) {
|
|
43
|
+
const t = describeTrust(trust);
|
|
44
|
+
if (t.status === 'invalid') {
|
|
45
|
+
return {
|
|
46
|
+
status: t.status,
|
|
47
|
+
allowed: false,
|
|
48
|
+
requiresAcknowledgement: false,
|
|
49
|
+
headline: 'This package has been altered',
|
|
50
|
+
detail: `${t.explanation}. It was signed, and the signature does not match its contents — so it is not what its author published. Trove will not install it.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (t.status === 'unverified') {
|
|
54
|
+
return {
|
|
55
|
+
status: t.status,
|
|
56
|
+
allowed: true,
|
|
57
|
+
requiresAcknowledgement: true,
|
|
58
|
+
headline: 'Unsigned — for development only',
|
|
59
|
+
detail: 'Nobody has signed this package, so there is no way to tell who wrote it or whether it has been altered since. Install it only if you built it yourself or you trust wherever you got it from.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (t.status === 'signed') {
|
|
63
|
+
return {
|
|
64
|
+
status: t.status,
|
|
65
|
+
allowed: true,
|
|
66
|
+
requiresAcknowledgement: false,
|
|
67
|
+
headline: 'Signed, but the domain does not vouch for the key',
|
|
68
|
+
detail: `${t.explanation}. The signature is real; what is unproven is that ${t.domain || 'the publisher'} authorised it.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { status: t.status, allowed: true, requiresAcknowledgement: false, headline: null, detail: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {{status?: string, domain?: string, reason?: string}|null} trust
|
|
76
|
+
* @returns {{status: string, tone: string, icon: string, domain: string|null, explanation: string, severity: number}}
|
|
77
|
+
*/
|
|
78
|
+
export function describeTrust(trust) {
|
|
79
|
+
const t = trust || {};
|
|
80
|
+
switch (t.status) {
|
|
81
|
+
case 'verified':
|
|
82
|
+
return {
|
|
83
|
+
status: 'verified', tone: 'verified', icon: 'check', domain: t.domain || null,
|
|
84
|
+
explanation: t.domain
|
|
85
|
+
? `Signed by a key published at ${t.domain}`
|
|
86
|
+
: 'Signed by a key the publishing domain vouches for',
|
|
87
|
+
severity: 0,
|
|
88
|
+
};
|
|
89
|
+
case 'signed':
|
|
90
|
+
return {
|
|
91
|
+
status: 'signed', tone: 'signed', icon: 'info', domain: t.domain || null,
|
|
92
|
+
explanation: t.reason || 'Signed, but the domain does not vouch for the key',
|
|
93
|
+
severity: 1,
|
|
94
|
+
};
|
|
95
|
+
case 'invalid':
|
|
96
|
+
return {
|
|
97
|
+
status: 'invalid', tone: 'invalid', icon: 'warn', domain: t.domain || null,
|
|
98
|
+
// Said plainly. This is the only state that means tampering rather than absence.
|
|
99
|
+
explanation: t.reason || 'The signature did not verify — this package may have been altered',
|
|
100
|
+
severity: 3,
|
|
101
|
+
};
|
|
102
|
+
default:
|
|
103
|
+
return {
|
|
104
|
+
status: 'unverified', tone: 'unverified', icon: 'warn', domain: t.domain || null,
|
|
105
|
+
explanation: t.reason || 'This plugin is not signed',
|
|
106
|
+
severity: 2,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// View state: what the UI is in the middle of doing.
|
|
2
|
+
//
|
|
3
|
+
// Which keybinding is mid-capture, what has been typed into a dialog that has not been
|
|
4
|
+
// submitted, which capabilities are ticked in a plugin review. None of it is drive state —
|
|
5
|
+
// it is thrown away when the dialog closes — but all of it decides what is on screen, and
|
|
6
|
+
// anything that decides what is on screen is engine state. A component reads it through a
|
|
7
|
+
// query and writes it through an action, the same as everything else.
|
|
8
|
+
//
|
|
9
|
+
// The history is worth keeping because it went wrong twice in the same direction. It began
|
|
10
|
+
// as module-level `let`s mutated in place, with a `rerender()` hook threaded through
|
|
11
|
+
// fourteen modules so the leaf that changed one could poke the root into redrawing — data
|
|
12
|
+
// down, invalidation back up out of band. Moving it into a cell fixed the invalidation and
|
|
13
|
+
// left a second version of the same mistake: components still READ it by calling the module
|
|
14
|
+
// during their own render, and two of them WROTE to it there as well, to lazily initialise a
|
|
15
|
+
// default. A render that writes state is a render with a side effect.
|
|
16
|
+
//
|
|
17
|
+
// So this is a resource now, and the lazy-initialise trick is gone: where a component used
|
|
18
|
+
// to write a default on first render, it now computes one and writes nothing until the user
|
|
19
|
+
// does something. `ref` is what makes that possible — it records which dialog instance the
|
|
20
|
+
// value belongs to, so a stale entry is simply ignored rather than needing to be cleared.
|
|
21
|
+
//
|
|
22
|
+
// The holder itself is a slice in bl/state.js; what stays here is how to read one.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The slice for `key`, or `fallback` when it belongs to a different dialog instance.
|
|
26
|
+
*
|
|
27
|
+
* Two components keep a draft that must reset when the dialog is reopened. They used to do
|
|
28
|
+
* that by writing the default during the render that noticed the mismatch. Deriving it
|
|
29
|
+
* instead means the render stays a pure function of the state it was given, and the first
|
|
30
|
+
* write happens when the user actually changes something.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} slice the view-state slice from the query
|
|
33
|
+
* @param {string} key
|
|
34
|
+
* @param {object} ref the dialog instance the value must belong to
|
|
35
|
+
* @param {any} fallback
|
|
36
|
+
*/
|
|
37
|
+
export function draftFor(slice, key, ref, fallback) {
|
|
38
|
+
const held = slice?.[key];
|
|
39
|
+
return held && held.ref === ref ? held : { ref, ...fallback };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where a prompt dialog's typed value lives while it is being typed. */
|
|
43
|
+
export const PROMPT = 'promptValue';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What has been typed into the open prompt, or its initial value.
|
|
47
|
+
*
|
|
48
|
+
* The prompt kept this in a `let` closed over by the render and mutated by the input
|
|
49
|
+
* handler, and handed it back through an `onSubmit` callback — which meant a FUNCTION was
|
|
50
|
+
* living in the dialog spec, and the dialog spec is engine state. Keeping the value here
|
|
51
|
+
* instead lets a prompt carry actions like everything else, and each action reads what was
|
|
52
|
+
* typed rather than being handed it.
|
|
53
|
+
*
|
|
54
|
+
* `ref` ties it to the dialog instance, so reopening starts from the new initial value
|
|
55
|
+
* rather than whatever the last prompt left behind.
|
|
56
|
+
*/
|
|
57
|
+
export function promptValueOf(slice, dialog) {
|
|
58
|
+
const held = slice?.[PROMPT];
|
|
59
|
+
return held && held.ref === dialog ? held.value : (dialog?.value ?? '');
|
|
60
|
+
}
|