@3sln/trove 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/package.json +2 -1
- package/packages/core/src/collections/index.js +71 -10
- package/packages/core/src/encryption/keys.js +4 -3
- package/packages/core/src/encryption/policy.js +14 -7
- package/packages/core/src/encryption/rotation.js +46 -12
- package/packages/core/src/errors.js +8 -0
- package/packages/core/src/index.js +8 -2
- package/packages/core/src/indexers/contribution.js +18 -17
- package/packages/core/src/indexing.js +8 -4
- package/packages/core/src/issues.js +8 -0
- package/packages/core/src/links.js +21 -9
- package/packages/core/src/metadata/interface.js +22 -0
- package/packages/core/src/metadata/memory.js +15 -0
- package/packages/core/src/metadata/sqlite.js +23 -1
- package/packages/core/src/plugins/contributions.js +13 -0
- package/packages/core/src/rateLimit.js +253 -0
- package/packages/core/src/scan.js +44 -16
- package/packages/core/src/sidecar/document.js +18 -14
- package/packages/core/src/sidecar/index.js +9 -10
- package/packages/core/src/sidecar/manager.js +10 -15
- package/packages/core/src/uploads.js +40 -15
- package/packages/core/src/vfs.js +59 -34
- package/packages/plugin-sdk/src/browser.js +155 -0
- package/packages/plugin-sdk/src/protocol.js +13 -1
- package/packages/server/src/access/externalEvaluation.js +17 -0
- package/packages/server/src/adapters/bun.js +1 -2
- package/packages/server/src/adapters/node.js +1 -2
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +17 -34
- package/packages/server/src/engine/providers/core.js +112 -14
- package/packages/server/src/index.js +160 -105
- package/packages/server/src/mcp/index.js +7 -12
- package/packages/server/src/mcp/tools.js +24 -29
- package/packages/server/src/router.js +31 -8
- package/packages/server/src/routes.js +74 -113
- package/packages/server/src/scope.js +78 -0
- package/packages/web/dist/assets/main-828yzsr7.js +511 -0
- package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +226 -104
- package/packages/web/src/bl/activity.js +32 -82
- package/packages/web/src/bl/index.js +23 -11
- package/packages/web/src/bl/launcher.js +2 -9
- package/packages/web/src/bl/links.js +31 -18
- package/packages/web/src/bl/offline.js +24 -10
- package/packages/web/src/bl/pluginInstall.js +29 -26
- package/packages/web/src/bl/queries.js +76 -68
- package/packages/web/src/bl/services.js +22 -14
- package/packages/web/src/bl/social.js +39 -25
- package/packages/web/src/bl/state.js +22 -4
- package/packages/web/src/bl/viewState.js +9 -0
- package/packages/web/src/bl/watchQuery.js +1 -8
- package/packages/web/src/dispatch.js +26 -0
- package/packages/web/src/platform/api.js +32 -0
- package/packages/web/src/platform/commands.js +14 -15
- package/packages/web/src/platform/context.js +3 -58
- package/packages/web/src/platform/contributions.js +1 -35
- package/packages/web/src/platform/fileChunks.js +281 -0
- package/packages/web/src/platform/index.js +8 -9
- package/packages/web/src/platform/mediaUrls.js +10 -0
- package/packages/web/src/platform/navigation.js +35 -21
- package/packages/web/src/platform/pickers.js +39 -19
- package/packages/web/src/platform/pluginHost.js +4 -1
- package/packages/web/src/platform/pluginRpc.js +47 -2
- package/packages/web/src/platform/spatialNav.js +1 -1
- package/packages/web/src/platform/viewport.js +14 -6
- package/packages/web/src/platform/voiceSearch.js +17 -9
- package/packages/web/src/platform/whenclause.js +9 -19
- package/packages/web/src/runtime.js +2 -37
- package/packages/web/src/ui/activate.js +18 -2
- package/packages/web/src/ui/components/activityPanel.js +1 -1
- package/packages/web/src/ui/components/adminView.js +4 -7
- package/packages/web/src/ui/components/editorArea.js +10 -1
- package/packages/web/src/ui/components/launcher.js +29 -13
- package/packages/web/src/ui/components/openers/index.js +17 -22
- package/packages/web/src/ui/components/openers/markdown.js +2 -2
- package/packages/web/src/ui/components/overlays.js +31 -5
- package/packages/web/src/ui/components/pluginReview.js +7 -4
- package/packages/web/src/ui/components/pluginsView.js +1 -1
- package/packages/web/src/ui/components/settingsView.js +30 -27
- package/packages/web/src/ui/components/statusBar.js +8 -6
- package/packages/web/src/ui/components/views/grid.js +2 -2
- package/packages/web/src/ui/components/views/parts.js +1 -1
- package/packages/web/src/ui/compositions/workbench.js +20 -6
- package/packages/web/src/ui/sanitize.js +0 -5
- package/packages/web/src/workbench.js +37 -19
- package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
- package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
// observers", `boot`/`kill` bracket a query's life, and a lease holds the providers it needs
|
|
8
8
|
// for exactly that long.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// That move is DONE. The state bags became slices (see bl/state.js) and most of these
|
|
11
|
+
// queries now view a slice rather than wrapping a service — which is why the base class is
|
|
12
|
+
// `CellQuery`: one cell, viewed, whatever is behind it. `ViewQuery` is the other shape,
|
|
13
|
+
// composing several cells into an answer the UI actually asks for.
|
|
14
|
+
//
|
|
15
|
+
// Four resources stayed services because they cover something a state bag cannot — history
|
|
16
|
+
// and recents, polling, a queue that replays on reconnect — and they answer `get()` and
|
|
17
|
+
// `observe()` like everything else, so a query cannot tell which it is looking at.
|
|
14
18
|
//
|
|
15
19
|
// INSTANCE IDENTITY IS THE SHARING KEY. ngin keys live realizations by the query INSTANCE —
|
|
16
20
|
// `#controllers` is a Map keyed on the object — and nothing anywhere looks at the class or
|
|
@@ -32,7 +36,10 @@
|
|
|
32
36
|
import { Query } from '@3sln/ngin';
|
|
33
37
|
import { queryOf } from './intern.js';
|
|
34
38
|
import { selectedNodesOf, draftScopesOf, collectionMenuOf, collectionAdminOf } from './services.js';
|
|
35
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction, LoadGrantsAction,
|
|
41
|
+
LoadApiKeysAction, ShowCollectionAccessAction,
|
|
42
|
+
} from './actions.js';
|
|
36
43
|
import { ASSOC_KEY, describeOpener } from './openers.js';
|
|
37
44
|
import { rankCommands } from './match.js';
|
|
38
45
|
import { statusFactsOf, driveConditionOf } from './status.js';
|
|
@@ -41,20 +48,22 @@ import { pickView } from './views.js';
|
|
|
41
48
|
import { prettyKey } from '../platform/keybindings.js';
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
|
-
* A live query over
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* (`
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* A live query over ONE cell — whatever holds it.
|
|
52
|
+
*
|
|
53
|
+
* Named for what it does rather than for what used to be behind it: most of these view a
|
|
54
|
+
* slice now, not a service, and `ServiceQuery` told a reader its dep was a service when
|
|
55
|
+
* usually it is not. `ViewQuery` below is the other shape, composing several cells.
|
|
56
|
+
*
|
|
57
|
+
* The resource exposes dodo's Cell protocol — `onDirty(fn)` to learn it changed,
|
|
58
|
+
* `getValue()` to read it — and a query wants push. Bridging is two lines, so rather than
|
|
59
|
+
* write them per resource this takes the cell as a function of the leased one.
|
|
60
|
+
*
|
|
61
|
+
* It reads the CELL and nothing else. That is not a stylistic preference: `settings`' cell
|
|
62
|
+
* holds `effective()`, the defaults merged with the overrides, which no field anywhere
|
|
63
|
+
* holds. Reading the cell is what makes these queries and the shell's snapshot describe the
|
|
64
|
+
* same state rather than two subtly different ones.
|
|
56
65
|
*/
|
|
57
|
-
class
|
|
66
|
+
class CellQuery extends Query {
|
|
58
67
|
/**
|
|
59
68
|
* @param {string} dep the engine resource this views
|
|
60
69
|
* @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
|
|
@@ -66,12 +75,15 @@ class ServiceQuery extends Query {
|
|
|
66
75
|
* @param {string} dep the engine resource this views
|
|
67
76
|
* @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
|
|
68
77
|
* @param {(value: any, resource: object) => any} [project] shape the value into a view
|
|
78
|
+
* @param {{bootAction?: object}} [opts] dispatched when the first observer arrives — for
|
|
79
|
+
* a value that has to be FETCHED before there is anything to view.
|
|
69
80
|
*/
|
|
70
|
-
constructor(dep, cellOf, project) {
|
|
81
|
+
constructor(dep, cellOf, project, { bootAction = null } = {}) {
|
|
71
82
|
super();
|
|
72
83
|
this.dep = dep;
|
|
73
84
|
this.cellOf = cellOf;
|
|
74
85
|
this.project = project;
|
|
86
|
+
if (bootAction) this.bootAction = bootAction;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
@@ -141,7 +153,7 @@ function release(resources, query) {
|
|
|
141
153
|
* answer is the difference between a component knowing what is selected and a component
|
|
142
154
|
* knowing how selection resolution works.
|
|
143
155
|
*/
|
|
144
|
-
export const explorer = new
|
|
156
|
+
export const explorer = new CellQuery('explorer', (r) => r.observe(), (v) => ({
|
|
145
157
|
...v,
|
|
146
158
|
selectedNodes: selectedNodesOf(v),
|
|
147
159
|
// The collection switcher's rows. In the view because it is a question about state, and
|
|
@@ -160,13 +172,13 @@ export const explorer = new ServiceQuery('explorer', (r) => r.observe(), (v) =>
|
|
|
160
172
|
}),
|
|
161
173
|
}));
|
|
162
174
|
/** Query text, results, and the palette's file list. */
|
|
163
|
-
export const search = new
|
|
175
|
+
export const search = new CellQuery('search', (r) => r.observe());
|
|
164
176
|
/** Uploads and downloads in flight. */
|
|
165
|
-
export const transfers = new
|
|
177
|
+
export const transfers = new CellQuery('transfers', (r) => r.observe());
|
|
166
178
|
/** Running tasks and standing issues, both sides of the wire. */
|
|
167
|
-
export const activity = new
|
|
179
|
+
export const activity = new CellQuery('activity', (r) => r.observe());
|
|
168
180
|
/** Conversations, tags and backlinks for the open item. */
|
|
169
|
-
export const social = new
|
|
181
|
+
export const social = new CellQuery('social', (r) => r.observe());
|
|
170
182
|
/**
|
|
171
183
|
* Online state, pinned files, and the queue waiting to sync.
|
|
172
184
|
*
|
|
@@ -174,7 +186,7 @@ export const social = new ServiceQuery('social', (r) => r.observe());
|
|
|
174
186
|
* calling a method on the service to find out. A Set rather than a repeated scan: the
|
|
175
187
|
* question is asked once per row.
|
|
176
188
|
*/
|
|
177
|
-
export const offline = new
|
|
189
|
+
export const offline = new CellQuery('offline', (r) => r.observe(),
|
|
178
190
|
(v) => ({ ...v, pinnedIds: new Set((v?.pins || []).map((p) => p.id)) }));
|
|
179
191
|
/**
|
|
180
192
|
* The open collection's key, and any rotation running over it.
|
|
@@ -198,7 +210,9 @@ class RotationView extends Query {
|
|
|
198
210
|
constructor(collectionId) {
|
|
199
211
|
super();
|
|
200
212
|
this.collectionId = collectionId;
|
|
201
|
-
|
|
213
|
+
// The id is PASSED, so the load stops depending on `explorer` happening to agree with
|
|
214
|
+
// the query's key — which is what makes the key mean anything at all.
|
|
215
|
+
this.bootAction = new LoadRotationAction(collectionId);
|
|
202
216
|
}
|
|
203
217
|
|
|
204
218
|
boot(r, { notify }) {
|
|
@@ -206,7 +220,7 @@ class RotationView extends Query {
|
|
|
206
220
|
notify(cell.getValue());
|
|
207
221
|
const off = cell.onDirty(() => notify(cell.getValue()));
|
|
208
222
|
const timer = setInterval(() => {
|
|
209
|
-
if (r.rotation.get().rotation?.status === 'running') r.engine.dispatch(new LoadRotationAction());
|
|
223
|
+
if (r.rotation.get().rotation?.status === 'running') r.engine.dispatch(new LoadRotationAction(this.collectionId));
|
|
210
224
|
}, 2500);
|
|
211
225
|
hold(r, this, [off, () => clearInterval(timer)]);
|
|
212
226
|
}
|
|
@@ -246,22 +260,33 @@ class GrantsView extends Query {
|
|
|
246
260
|
}
|
|
247
261
|
}
|
|
248
262
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
/**
|
|
264
|
+
* The admin API-key list, with the unsubmitted draft resolved into what it would grant.
|
|
265
|
+
*
|
|
266
|
+
* `bootAction`, like `rotationFor` and `grantsFor`: it loads when the settings region is
|
|
267
|
+
* first realized and the list is admin-only, so most sessions never ask. The alternative
|
|
268
|
+
* was what this section used to do — dispatch from inside a render behind a module-level
|
|
269
|
+
* "have I asked yet" flag, which is a render with a side effect, and module state the
|
|
270
|
+
* engine cannot see or reset. A second `createWorkbench` on the page (a documented
|
|
271
|
+
* embedding entry point) found the flag already true against a fresh slice and never
|
|
272
|
+
* loaded at all.
|
|
273
|
+
*/
|
|
274
|
+
export const apiKeys = new CellQuery('apiKeys', (r) => r.observe(),
|
|
275
|
+
(v) => ({ ...v, draftScopes: draftScopesOf(v) }),
|
|
276
|
+
{ bootAction: new LoadApiKeysAction() });
|
|
252
277
|
|
|
253
278
|
// --- the shell -----------------------------------------------------------------
|
|
254
279
|
|
|
255
280
|
/** Which activity is showing, and the rest of the shell's own state. */
|
|
256
|
-
export const workbench = new
|
|
281
|
+
export const workbench = new CellQuery('workbench', (r) => r.observe());
|
|
257
282
|
/** The tab and panel stack. */
|
|
258
|
-
export const navigation = new
|
|
283
|
+
export const navigation = new CellQuery('navigation', (r) => r.observe());
|
|
259
284
|
/** Dialogs, menus and panels. */
|
|
260
|
-
export const overlay = new
|
|
285
|
+
export const overlay = new CellQuery('overlay', (r) => r.observe());
|
|
261
286
|
/** Toasts. */
|
|
262
|
-
export const notifications = new
|
|
287
|
+
export const notifications = new CellQuery('notifications', (r) => r.observe());
|
|
263
288
|
/** Settings, defaults merged with overrides. */
|
|
264
|
-
export const settings = new
|
|
289
|
+
export const settings = new CellQuery('settings', (r) => r.observe());
|
|
265
290
|
/**
|
|
266
291
|
* The settings SCHEMA, grouped by category, for the screen that edits them.
|
|
267
292
|
*
|
|
@@ -269,42 +294,24 @@ export const settings = new ServiceQuery('settings', (r) => r.observe());
|
|
|
269
294
|
* values keyed by setting name — adding a `groups` key to it would put a made-up entry in
|
|
270
295
|
* among the real ones.
|
|
271
296
|
*/
|
|
272
|
-
export const settingsGroups = new
|
|
297
|
+
export const settingsGroups = new CellQuery('settings', (r) => r.observe(), (_v, r) => r.grouped());
|
|
273
298
|
|
|
274
299
|
/** The when-clause keys: what is selected, what is open, what is focused. */
|
|
275
|
-
export const context = new
|
|
300
|
+
export const context = new CellQuery('context', (r) => r.observe());
|
|
276
301
|
/** Phone, desktop or TV. */
|
|
277
|
-
export const viewport = new
|
|
302
|
+
export const viewport = new CellQuery('viewport', (r) => r.observe());
|
|
278
303
|
/**
|
|
279
304
|
* Whether this browser can transcribe on-device, and whether it is listening now.
|
|
280
305
|
*
|
|
281
306
|
* `canListen` is folded in: it is a question about the state, so a component should not
|
|
282
307
|
* have to call the service to find out.
|
|
283
308
|
*/
|
|
284
|
-
export const voice = new
|
|
309
|
+
export const voice = new CellQuery('voice', (r) => r.observe(),
|
|
285
310
|
(v, r) => ({ ...v, canListen: !!r.canListen?.() }));
|
|
286
311
|
/** Installed plugins; null where the plugin host is not installed. */
|
|
287
|
-
export const plugins = new
|
|
312
|
+
export const plugins = new CellQuery('plugins', (r) => r?.observe?.());
|
|
288
313
|
/** What the UI is in the middle of doing: drafts, captures, ticked boxes. See viewState.js. */
|
|
289
|
-
export const viewState = new
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Contributions of one type — status items, openers, views.
|
|
293
|
-
*
|
|
294
|
-
* Parameterised, so it MUST memoise: a fresh instance per render would boot a second live
|
|
295
|
-
* realization every frame and never share one. Keyed by the type, which is the only thing
|
|
296
|
-
* that distinguishes them.
|
|
297
|
-
*/
|
|
298
|
-
export const contributionsOfType = (type) => ContributionsOfType.of(type);
|
|
299
|
-
|
|
300
|
-
class ContributionsOfType extends ServiceQuery {
|
|
301
|
-
static of = queryOf(ContributionsOfType);
|
|
302
|
-
|
|
303
|
-
constructor(type) {
|
|
304
|
-
super('contributions', (r) => r.observeType(type));
|
|
305
|
-
this.type = type;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
314
|
+
export const viewState = new CellQuery('viewState', (r) => r.observe());
|
|
308
315
|
|
|
309
316
|
// --- view queries ---------------------------------------------------------------
|
|
310
317
|
//
|
|
@@ -372,8 +379,6 @@ const registries = (r) => [
|
|
|
372
379
|
* No `when` expression and no handler. A component shows the title, greys out what is
|
|
373
380
|
* disabled, and dispatches `ExecCommandAction(id)`.
|
|
374
381
|
*/
|
|
375
|
-
export const paletteCommands = new ViewQuery(REGISTRY_DEPS, registries, paletteCommandsOf);
|
|
376
|
-
|
|
377
382
|
function paletteCommandsOf(r) {
|
|
378
383
|
return r.commands.paletteCommands().map((c) => ({
|
|
379
384
|
id: c.id,
|
|
@@ -381,12 +386,15 @@ function paletteCommandsOf(r) {
|
|
|
381
386
|
category: c.category ?? null,
|
|
382
387
|
icon: c.icon ?? null,
|
|
383
388
|
keybinding: r.keybindings.labelFor(c.id),
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
389
|
+
// Only `available` — the plugin behind this command being reachable. There was an
|
|
390
|
+
// `enabled` beside it, under a paragraph explaining that collapsing the two would tag a
|
|
391
|
+
// context-disabled command as "offline", and the code made the distinction impossible:
|
|
392
|
+
// `paletteCommands()` has already dropped every when-clause failure, so for every row
|
|
393
|
+
// that survives, `isEnabled` re-evaluates the same clause against the same registry and
|
|
394
|
+
// agrees with `isAvailable` by construction. Nothing rendered it. If greying a gated
|
|
395
|
+
// command out with a reason is wanted, do the other half — stop filtering in
|
|
396
|
+
// `paletteCommands()` — rather than keeping the label for a state that cannot occur.
|
|
388
397
|
available: r.commands.isAvailable(c),
|
|
389
|
-
enabled: r.commands.isEnabled(c.id),
|
|
390
398
|
}));
|
|
391
399
|
}
|
|
392
400
|
|
|
@@ -635,7 +643,7 @@ export const openerAssociations = new ViewQuery(REGISTRY_DEPS, registries, (r) =
|
|
|
635
643
|
*/
|
|
636
644
|
export const sidecarFor = (nodeId) => Sidecar.of(nodeId);
|
|
637
645
|
|
|
638
|
-
class Sidecar extends
|
|
646
|
+
class Sidecar extends CellQuery {
|
|
639
647
|
static of = queryOf(Sidecar);
|
|
640
648
|
|
|
641
649
|
constructor(nodeId) {
|
|
@@ -706,4 +714,4 @@ function commandKeysOf(r) {
|
|
|
706
714
|
* PENDING while the request was in flight. Both go away when the fetch belongs to the
|
|
707
715
|
* provider: the cell already holds null, and reading a cell is all this does.
|
|
708
716
|
*/
|
|
709
|
-
export const capabilities = new
|
|
717
|
+
export const capabilities = new CellQuery('capabilities', (r) => r);
|
|
@@ -51,10 +51,12 @@ export function selectedNodesOf(state) {
|
|
|
51
51
|
* fact is how they drift.
|
|
52
52
|
*/
|
|
53
53
|
export class TransfersService {
|
|
54
|
+
#state;
|
|
55
|
+
|
|
54
56
|
/** @param {import('./activity.js').ActivityService} [activity] */
|
|
55
57
|
constructor(activity = null) {
|
|
56
|
-
this
|
|
57
|
-
this.cell = cell(this
|
|
58
|
+
this.#state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error, retryable }
|
|
59
|
+
this.cell = cell(this.#state);
|
|
58
60
|
this._controllers = new Map();
|
|
59
61
|
this.activity = activity;
|
|
60
62
|
this._tasks = new Map(); // transfer id -> activity task handle
|
|
@@ -67,11 +69,17 @@ export class TransfersService {
|
|
|
67
69
|
// and the alternative is asking the user to find the file and drag it in again.
|
|
68
70
|
this._retries = new Map();
|
|
69
71
|
}
|
|
72
|
+
/** The value, for whoever is about to decide something from it. See bl/state.js: one
|
|
73
|
+
* value, one way to read it — a public `state` field is a second, writable door. */
|
|
74
|
+
get() {
|
|
75
|
+
return this.#state;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
observe() {
|
|
71
79
|
return this.cell;
|
|
72
80
|
}
|
|
73
81
|
#emit() {
|
|
74
|
-
this.cell.setValue(this
|
|
82
|
+
this.cell.setValue(this.#state);
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
85
|
* @param {{retry?: () => Promise<any>}} [opts] how to run this transfer again, if it can be
|
|
@@ -79,8 +87,8 @@ export class TransfersService {
|
|
|
79
87
|
start(id, name, total, controller, { retry = null } = {}) {
|
|
80
88
|
this._controllers.set(id, controller);
|
|
81
89
|
if (retry) this._retries.set(id, retry);
|
|
82
|
-
this
|
|
83
|
-
items: [...this
|
|
90
|
+
this.#state = {
|
|
91
|
+
items: [...this.#state.items, {
|
|
84
92
|
id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null,
|
|
85
93
|
// Surfaced in state so the tray renders from a snapshot rather than interrogating
|
|
86
94
|
// the service — it is a fact about the row, like `status`.
|
|
@@ -109,7 +117,7 @@ export class TransfersService {
|
|
|
109
117
|
retry(id) {
|
|
110
118
|
const again = this._retries.get(id);
|
|
111
119
|
if (!again) return null;
|
|
112
|
-
const item = this
|
|
120
|
+
const item = this.#state.items.find((t) => t.id === id);
|
|
113
121
|
if (!item || item.status === 'active') return null;
|
|
114
122
|
return again();
|
|
115
123
|
}
|
|
@@ -117,9 +125,9 @@ export class TransfersService {
|
|
|
117
125
|
/** Put an existing row back into flight — see `retry`. */
|
|
118
126
|
restart(id, controller) {
|
|
119
127
|
this._controllers.set(id, controller);
|
|
120
|
-
const item = this
|
|
121
|
-
this
|
|
122
|
-
items: this
|
|
128
|
+
const item = this.#state.items.find((t) => t.id === id);
|
|
129
|
+
this.#state = {
|
|
130
|
+
items: this.#state.items.map((t) => (t.id === id
|
|
123
131
|
? { ...t, status: 'active', error: null, ratio: 0, loaded: 0 }
|
|
124
132
|
: t)),
|
|
125
133
|
};
|
|
@@ -130,12 +138,12 @@ export class TransfersService {
|
|
|
130
138
|
this.#task(id, item?.name || 'file', item?.total || null);
|
|
131
139
|
}
|
|
132
140
|
progress(id, { loaded, total, ratio }) {
|
|
133
|
-
this
|
|
141
|
+
this.#state = { items: this.#state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
|
|
134
142
|
this.#emit();
|
|
135
143
|
this._tasks.get(id)?.progress({ done: loaded, total: total || null });
|
|
136
144
|
}
|
|
137
145
|
finish(id, status = 'done', error = null) {
|
|
138
|
-
this
|
|
146
|
+
this.#state = { items: this.#state.items.map((t) => (t.id === id ? { ...t, status, error, ratio: status === 'done' ? 1 : t.ratio } : t)) };
|
|
139
147
|
this._controllers.delete(id);
|
|
140
148
|
this.#emit();
|
|
141
149
|
const task = this._tasks.get(id);
|
|
@@ -152,17 +160,17 @@ export class TransfersService {
|
|
|
152
160
|
this.finish(id, 'cancelled');
|
|
153
161
|
}
|
|
154
162
|
dismiss(id) {
|
|
155
|
-
this
|
|
163
|
+
this.#state = { items: this.#state.items.filter((t) => t.id !== id) };
|
|
156
164
|
this._retries.delete(id);
|
|
157
165
|
this.#emit();
|
|
158
166
|
}
|
|
159
167
|
clearDone() {
|
|
160
|
-
const kept = this
|
|
168
|
+
const kept = this.#state.items.filter((t) => t.status === 'active');
|
|
161
169
|
const keptIds = new Set(kept.map((t) => t.id));
|
|
162
170
|
// Drop the retry thunks of the rows that just left, so a dismissed upload does not
|
|
163
171
|
// hold its File alive for the rest of the session.
|
|
164
172
|
for (const id of [...this._retries.keys()]) if (!keptIds.has(id)) this._retries.delete(id);
|
|
165
|
-
this
|
|
173
|
+
this.#state = { items: kept };
|
|
166
174
|
this.#emit();
|
|
167
175
|
}
|
|
168
176
|
}
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
import { cell } from '../runtime.js';
|
|
9
9
|
|
|
10
10
|
export class SocialService {
|
|
11
|
+
#state;
|
|
12
|
+
|
|
11
13
|
constructor(platform, offline = null) {
|
|
12
14
|
// How a comment or tag written while disconnected reaches the server later.
|
|
13
15
|
this.offline = offline;
|
|
14
16
|
this.platform = platform;
|
|
15
17
|
this.api = platform.api;
|
|
16
|
-
this
|
|
18
|
+
this.#state = {
|
|
17
19
|
me: null,
|
|
18
20
|
notifications: { items: [], unread: 0 },
|
|
19
21
|
inboxOpen: false,
|
|
@@ -24,16 +26,28 @@ export class SocialService {
|
|
|
24
26
|
posting: false,
|
|
25
27
|
replyTo: null, // { id, author } when composing a reply
|
|
26
28
|
};
|
|
27
|
-
this.cell = cell(this
|
|
29
|
+
this.cell = cell(this.#state);
|
|
28
30
|
this._pollTimer = null;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The value, for whoever is about to decide something from it.
|
|
35
|
+
*
|
|
36
|
+
* The same door every slice offers. Actions read `.state` and queries read `.cell`, and
|
|
37
|
+
* the two are only equal by habit — bl/state.js says so, and says a resource has one
|
|
38
|
+
* value and one way to read it. `state` is also a public field on a service, which is one
|
|
39
|
+
* typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
|
|
40
|
+
*/
|
|
41
|
+
get() {
|
|
42
|
+
return this.#state;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
observe() {
|
|
32
46
|
return this.cell;
|
|
33
47
|
}
|
|
34
48
|
#set(patch) {
|
|
35
|
-
this
|
|
36
|
-
this.cell.setValue(this
|
|
49
|
+
this.#state = { ...this.#state, ...patch };
|
|
50
|
+
this.cell.setValue(this.#state);
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
async init() {
|
|
@@ -60,14 +74,14 @@ export class SocialService {
|
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
toggleInbox(open) {
|
|
63
|
-
const next = open ?? !this
|
|
77
|
+
const next = open ?? !this.#state.inboxOpen;
|
|
64
78
|
this.#set({ inboxOpen: next });
|
|
65
|
-
if (next && this
|
|
79
|
+
if (next && this.#state.notifications.unread) this.markAllRead();
|
|
66
80
|
}
|
|
67
81
|
async markAllRead() {
|
|
68
82
|
try {
|
|
69
83
|
await this.api.markNotificationsRead();
|
|
70
|
-
this.#set({ notifications: { ...this
|
|
84
|
+
this.#set({ notifications: { ...this.#state.notifications, unread: 0, items: this.#state.notifications.items.map((i) => ({ ...i, read: true })) } });
|
|
71
85
|
} catch { /* ignore */ }
|
|
72
86
|
}
|
|
73
87
|
|
|
@@ -83,10 +97,10 @@ export class SocialService {
|
|
|
83
97
|
// same guard loadBacklinks already had. Without it, opening A then quickly opening
|
|
84
98
|
// B let A resolve last, so `state.sidecar.nodeId` became A while B was on screen
|
|
85
99
|
// and comment()/addTag()/removeTag() all wrote to A.
|
|
86
|
-
if (this
|
|
100
|
+
if (this.#state.sidecar?.nodeId !== nodeId) return;
|
|
87
101
|
this.#set({ sidecar: { ...view, loading: false } });
|
|
88
102
|
} catch (err) {
|
|
89
|
-
if (this
|
|
103
|
+
if (this.#state.sidecar?.nodeId !== nodeId) return;
|
|
90
104
|
this.#set({ sidecar: { nodeId, loading: false, error: err.message, tags: [], comments: [] } });
|
|
91
105
|
}
|
|
92
106
|
}
|
|
@@ -102,26 +116,26 @@ export class SocialService {
|
|
|
102
116
|
try {
|
|
103
117
|
const res = await this.api.backlinks(nodeId);
|
|
104
118
|
// A slower request for a previously-open item must not land on this one.
|
|
105
|
-
if (this
|
|
119
|
+
if (this.#state.backlinks?.nodeId !== nodeId) return;
|
|
106
120
|
this.#set({ backlinks: { nodeId, items: res.items || [], loading: false, error: null } });
|
|
107
121
|
} catch (err) {
|
|
108
|
-
if (this
|
|
122
|
+
if (this.#state.backlinks?.nodeId !== nodeId) return;
|
|
109
123
|
this.#set({ backlinks: { nodeId, items: [], loading: false, error: err.message } });
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
126
|
async #reload() {
|
|
113
|
-
if (this
|
|
127
|
+
if (this.#state.sidecar?.nodeId) await this.loadSidecar(this.#state.sidecar.nodeId);
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
setReplyTo(target) {
|
|
117
131
|
this.#set({ replyTo: target });
|
|
118
132
|
}
|
|
119
133
|
async comment(body) {
|
|
120
|
-
const nodeId = this
|
|
134
|
+
const nodeId = this.#state.sidecar?.nodeId;
|
|
121
135
|
if (!nodeId || !body.trim()) return;
|
|
122
|
-
const parentId = this
|
|
136
|
+
const parentId = this.#state.replyTo?.id || null;
|
|
123
137
|
// Offline → queue the op; it replays (CRDT-merges) on reconnect.
|
|
124
|
-
if (this.offline && !this.offline.
|
|
138
|
+
if (this.offline && !this.offline.get().online) {
|
|
125
139
|
await this.offline.queueOp({ method: 'POST', path: `/api/items/${encodeURIComponent(nodeId)}/comments`, body: { body, parentId } });
|
|
126
140
|
this.#set({ replyTo: null });
|
|
127
141
|
this.platform.notifications.info('Offline — your comment will post when you reconnect.');
|
|
@@ -139,14 +153,14 @@ export class SocialService {
|
|
|
139
153
|
}
|
|
140
154
|
}
|
|
141
155
|
async deleteComment(cid) {
|
|
142
|
-
const nodeId = this
|
|
156
|
+
const nodeId = this.#state.sidecar?.nodeId;
|
|
143
157
|
await this.api.deleteComment(nodeId, cid).catch((e) => this.platform.notifications.error(e.message));
|
|
144
158
|
await this.#reload();
|
|
145
159
|
}
|
|
146
160
|
async react(cid, emoji) {
|
|
147
|
-
const nodeId = this
|
|
148
|
-
const comment = findComment(this
|
|
149
|
-
const on = !(comment?.reactions?.[emoji] || []).includes(this
|
|
161
|
+
const nodeId = this.#state.sidecar?.nodeId;
|
|
162
|
+
const comment = findComment(this.#state.sidecar?.comments || [], cid);
|
|
163
|
+
const on = !(comment?.reactions?.[emoji] || []).includes(this.#state.me?.id);
|
|
150
164
|
try {
|
|
151
165
|
await this.api.reactComment(nodeId, cid, emoji, on);
|
|
152
166
|
} catch (err) {
|
|
@@ -155,9 +169,9 @@ export class SocialService {
|
|
|
155
169
|
await this.#reload();
|
|
156
170
|
}
|
|
157
171
|
async addTag(name, value) {
|
|
158
|
-
const nodeId = this
|
|
172
|
+
const nodeId = this.#state.sidecar?.nodeId;
|
|
159
173
|
if (!name.trim()) return;
|
|
160
|
-
if (this.offline && !this.offline.
|
|
174
|
+
if (this.offline && !this.offline.get().online) {
|
|
161
175
|
await this.offline.queueOp({ method: 'POST', path: `/api/items/${encodeURIComponent(nodeId)}/tags`, body: { name: name.trim(), value } });
|
|
162
176
|
this.platform.notifications.info('Offline — tag will sync when you reconnect.');
|
|
163
177
|
return;
|
|
@@ -166,9 +180,9 @@ export class SocialService {
|
|
|
166
180
|
await this.#reload();
|
|
167
181
|
}
|
|
168
182
|
async removeTag(name) {
|
|
169
|
-
const nodeId = this
|
|
183
|
+
const nodeId = this.#state.sidecar?.nodeId;
|
|
170
184
|
// Offline → queue like addTag so the removal isn't silently lost.
|
|
171
|
-
if (this.offline && !this.offline.
|
|
185
|
+
if (this.offline && !this.offline.get().online) {
|
|
172
186
|
await this.offline.queueOp({ method: 'DELETE', path: `/api/items/${encodeURIComponent(nodeId)}/tags/${encodeURIComponent(name)}` });
|
|
173
187
|
this.platform.notifications.info('Offline — tag removal will sync when you reconnect.');
|
|
174
188
|
return;
|
|
@@ -184,7 +198,7 @@ export class SocialService {
|
|
|
184
198
|
// --- web push --------------------------------------------------------------
|
|
185
199
|
|
|
186
200
|
async #detectPush() {
|
|
187
|
-
if (!this
|
|
201
|
+
if (!this.#state.pushSupported) return;
|
|
188
202
|
try {
|
|
189
203
|
const reg = await navigator.serviceWorker.getRegistration();
|
|
190
204
|
const sub = await reg?.pushManager?.getSubscription();
|
|
@@ -193,7 +207,7 @@ export class SocialService {
|
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
async enablePush() {
|
|
196
|
-
if (!this
|
|
210
|
+
if (!this.#state.pushSupported) {
|
|
197
211
|
this.platform.notifications.warn('Push notifications are not supported in this browser.');
|
|
198
212
|
return;
|
|
199
213
|
}
|
|
@@ -27,8 +27,9 @@ import { cell } from '../runtime.js';
|
|
|
27
27
|
* One named piece of the drive's state.
|
|
28
28
|
*
|
|
29
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
|
|
31
|
-
* the cases that genuinely mean
|
|
30
|
+
* chance of a caller replacing a slice by forgetting to spread it. There is no `replace`:
|
|
31
|
+
* one existed for "the cases that genuinely mean all of it" and no caller ever turned out
|
|
32
|
+
* to mean that, so a slice has exactly one way to be written.
|
|
32
33
|
*
|
|
33
34
|
* @param {object} initial
|
|
34
35
|
*/
|
|
@@ -41,8 +42,6 @@ export function slice(initial = {}) {
|
|
|
41
42
|
get: () => held.getValue(),
|
|
42
43
|
/** Merge a patch in. A new object every time — a cell compares with Object.is. */
|
|
43
44
|
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
45
|
};
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -57,12 +56,21 @@ export const explorerState = (settings) => slice({
|
|
|
57
56
|
// `stats` is the whole collection; `items` is the page on screen. Keeping both is what
|
|
58
57
|
// lets the UI say "500 of 3,006" instead of quietly claiming 500.
|
|
59
58
|
stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
|
|
59
|
+
// The selected NODES, not just their ids — the primary read path in `selectedNodesOf`,
|
|
60
|
+
// with `items` as the fallback for when a selection was made somewhere `items` covers.
|
|
61
|
+
// That function exists to end the class of silent bug where rename/trash/copy-link
|
|
62
|
+
// returned quietly while `hasSelection` said there was something to act on.
|
|
63
|
+
selectionNodes: null,
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
/** Query text, results, and the palette's separate file list. */
|
|
63
67
|
export const searchState = () => slice({
|
|
64
68
|
query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false,
|
|
65
69
|
paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null,
|
|
70
|
+
// `filtered` is a tag/type filter rather than a query; `offline` says the results came
|
|
71
|
+
// from the local pinned index; `resolved` is the query the results on screen are FOR,
|
|
72
|
+
// and it is what `pickView` reads to decide whether to show them at all.
|
|
73
|
+
filtered: false, offline: false, resolved: null,
|
|
66
74
|
});
|
|
67
75
|
|
|
68
76
|
/**
|
|
@@ -78,6 +86,10 @@ export const searchState = () => slice({
|
|
|
78
86
|
*/
|
|
79
87
|
export const apiKeysState = () => slice({
|
|
80
88
|
keys: [], loading: false, loaded: false, error: null, minted: null, busy: null, draft: null,
|
|
89
|
+
// `forbidden` is not `error`. A non-admin's 403 is the correct answer to this request, so
|
|
90
|
+
// the section is simply absent for them; anything else is a failure worth reporting, with
|
|
91
|
+
// a way to try again. Conflating the two rendered a blank space for both.
|
|
92
|
+
forbidden: false,
|
|
81
93
|
});
|
|
82
94
|
|
|
83
95
|
/**
|
|
@@ -106,6 +118,12 @@ export const overlayState = () => slice({
|
|
|
106
118
|
dialog: null,
|
|
107
119
|
contextMenu: null,
|
|
108
120
|
pluginPanel: null,
|
|
121
|
+
// The activity panel. It lived inside ActivityService's state, beside `tasks` and
|
|
122
|
+
// `issues` — the one field there with no justification while its neighbours had five
|
|
123
|
+
// lines of it — which made CloseOverlaysAction lease a task/issue poller in order to
|
|
124
|
+
// close a panel, and broke form on the sixth rung of an Escape ladder whose other five
|
|
125
|
+
// go through here.
|
|
126
|
+
activityPanel: false,
|
|
109
127
|
});
|
|
110
128
|
|
|
111
129
|
/**
|
|
@@ -42,6 +42,15 @@ export function draftFor(slice, key, ref, fallback) {
|
|
|
42
42
|
/** Where a prompt dialog's typed value lives while it is being typed. */
|
|
43
43
|
export const PROMPT = 'promptValue';
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Where the plugin review's ticked capabilities live.
|
|
47
|
+
*
|
|
48
|
+
* Named here rather than privately in the component, because the action that performs the
|
|
49
|
+
* install READS it — the dialog is data, so what the user ticked has to be state the action
|
|
50
|
+
* can find rather than something a callback closes over.
|
|
51
|
+
*/
|
|
52
|
+
export const PLUGIN_REVIEW = 'pluginReview';
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
55
|
* What has been typed into the open prompt, or its initial value.
|
|
47
56
|
*
|
|
@@ -59,14 +59,7 @@ export function watchQuery(engine, queryInstance) {
|
|
|
59
59
|
}
|
|
60
60
|
let cell = forEngine.get(queryInstance);
|
|
61
61
|
if (!cell) {
|
|
62
|
-
|
|
63
|
-
// fetched over the network, say. Without one the cell is PENDING, and PENDING reaching
|
|
64
|
-
// a `watch` renders its placeholder: fine for a region that can show an empty bar for a
|
|
65
|
-
// frame, wrong for a snapshot feeding the whole shell, which would blank until the
|
|
66
|
-
// request came back. The initial belongs to the query rather than to this call, so it
|
|
67
|
-
// cannot differ between two call sites reading the same thing.
|
|
68
|
-
const initial = 'initial' in queryInstance ? queryInstance.initial : PENDING;
|
|
69
|
-
cell = fromObservable(engine.query(queryInstance), { initial });
|
|
62
|
+
cell = fromObservable(engine.query(queryInstance), { initial: PENDING });
|
|
70
63
|
forEngine.set(queryInstance, cell);
|
|
71
64
|
}
|
|
72
65
|
return cell;
|