@3sln/trove 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -1,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.
|
|
@@ -165,11 +53,19 @@ export class SearchClientService {
|
|
|
165
53
|
export class TransfersService {
|
|
166
54
|
/** @param {import('./activity.js').ActivityService} [activity] */
|
|
167
55
|
constructor(activity = null) {
|
|
168
|
-
this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error }
|
|
56
|
+
this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error, retryable }
|
|
169
57
|
this.cell = cell(this.state);
|
|
170
58
|
this._controllers = new Map();
|
|
171
59
|
this.activity = activity;
|
|
172
60
|
this._tasks = new Map(); // transfer id -> activity task handle
|
|
61
|
+
// How to run this transfer again. Held here rather than in state because it closes
|
|
62
|
+
// over the File itself, which is not something a state snapshot should carry.
|
|
63
|
+
//
|
|
64
|
+
// It exists because automatic retry cannot cover everything: a lost upload session is
|
|
65
|
+
// `notFound`, which is correctly classified non-retryable and will never succeed on
|
|
66
|
+
// its own no matter how many times it is tried. Someone has to decide to start over,
|
|
67
|
+
// and the alternative is asking the user to find the file and drag it in again.
|
|
68
|
+
this._retries = new Map();
|
|
173
69
|
}
|
|
174
70
|
observe() {
|
|
175
71
|
return this.cell;
|
|
@@ -177,16 +73,62 @@ export class TransfersService {
|
|
|
177
73
|
#emit() {
|
|
178
74
|
this.cell.setValue(this.state);
|
|
179
75
|
}
|
|
180
|
-
|
|
76
|
+
/**
|
|
77
|
+
* @param {{retry?: () => Promise<any>}} [opts] how to run this transfer again, if it can be
|
|
78
|
+
*/
|
|
79
|
+
start(id, name, total, controller, { retry = null } = {}) {
|
|
181
80
|
this._controllers.set(id, controller);
|
|
182
|
-
|
|
81
|
+
if (retry) this._retries.set(id, retry);
|
|
82
|
+
this.state = {
|
|
83
|
+
items: [...this.state.items, {
|
|
84
|
+
id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null,
|
|
85
|
+
// Surfaced in state so the tray renders from a snapshot rather than interrogating
|
|
86
|
+
// the service — it is a fact about the row, like `status`.
|
|
87
|
+
retryable: !!retry,
|
|
88
|
+
}],
|
|
89
|
+
};
|
|
183
90
|
this.#emit();
|
|
91
|
+
this.#task(id, name, total);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#task(id, name, total) {
|
|
184
95
|
const task = this.activity?.start({
|
|
185
96
|
kind: 'transfer', title: `Uploading ${name}`, total: total || null, unit: 'bytes',
|
|
186
97
|
onCancel: () => this.cancel(id),
|
|
187
98
|
});
|
|
188
99
|
if (task) this._tasks.set(id, task);
|
|
189
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run a failed transfer again, in place.
|
|
104
|
+
*
|
|
105
|
+
* The same row rather than a new one: a retry is another attempt at the thing the user
|
|
106
|
+
* already asked for, and a tray that grew an entry per attempt would report one upload
|
|
107
|
+
* as four.
|
|
108
|
+
*/
|
|
109
|
+
retry(id) {
|
|
110
|
+
const again = this._retries.get(id);
|
|
111
|
+
if (!again) return null;
|
|
112
|
+
const item = this.state.items.find((t) => t.id === id);
|
|
113
|
+
if (!item || item.status === 'active') return null;
|
|
114
|
+
return again();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Put an existing row back into flight — see `retry`. */
|
|
118
|
+
restart(id, controller) {
|
|
119
|
+
this._controllers.set(id, controller);
|
|
120
|
+
const item = this.state.items.find((t) => t.id === id);
|
|
121
|
+
this.state = {
|
|
122
|
+
items: this.state.items.map((t) => (t.id === id
|
|
123
|
+
? { ...t, status: 'active', error: null, ratio: 0, loaded: 0 }
|
|
124
|
+
: t)),
|
|
125
|
+
};
|
|
126
|
+
this.#emit();
|
|
127
|
+
// A fresh activity task: the previous one already ended as failed, and reporting
|
|
128
|
+
// progress into a finished task would leave the panel showing a failure that is
|
|
129
|
+
// actively being retried.
|
|
130
|
+
this.#task(id, item?.name || 'file', item?.total || null);
|
|
131
|
+
}
|
|
190
132
|
progress(id, { loaded, total, ratio }) {
|
|
191
133
|
this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
|
|
192
134
|
this.#emit();
|
|
@@ -211,10 +153,64 @@ export class TransfersService {
|
|
|
211
153
|
}
|
|
212
154
|
dismiss(id) {
|
|
213
155
|
this.state = { items: this.state.items.filter((t) => t.id !== id) };
|
|
156
|
+
this._retries.delete(id);
|
|
214
157
|
this.#emit();
|
|
215
158
|
}
|
|
216
159
|
clearDone() {
|
|
217
|
-
|
|
160
|
+
const kept = this.state.items.filter((t) => t.status === 'active');
|
|
161
|
+
const keptIds = new Set(kept.map((t) => t.id));
|
|
162
|
+
// Drop the retry thunks of the rows that just left, so a dismissed upload does not
|
|
163
|
+
// hold its File alive for the rest of the session.
|
|
164
|
+
for (const id of [...this._retries.keys()]) if (!keptIds.has(id)) this._retries.delete(id);
|
|
165
|
+
this.state = { items: kept };
|
|
218
166
|
this.#emit();
|
|
219
167
|
}
|
|
220
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
|
+
}
|