@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
|
@@ -6,7 +6,10 @@ import { dd } from '../../runtime.js';
|
|
|
6
6
|
import { icon, iconForNode } from '../icon.js';
|
|
7
7
|
import { bytes } from '../format.js';
|
|
8
8
|
import { pluginReview } from './pluginReview.js';
|
|
9
|
-
import { typeKeyFor, typeLabelFor
|
|
9
|
+
import { typeKeyFor, typeLabelFor } from '../../bl/openers.js';
|
|
10
|
+
import { draftFor, promptValueOf, PROMPT } from '../../bl/viewState.js';
|
|
11
|
+
import { CancelTransferAction, ClearFinishedTransfersAction, CloseContextMenuAction, CloseDialogAction, ClosePluginPanelAction, CreateCollectionFromFormAction, DismissNotificationAction, DismissTransferAction, OpenInPanelAction, PatchDraftAction, RememberOpenerAction, RetryTransferAction, SetViewStateAction, UpdateDialogAction } from '../../bl/actions.js';
|
|
12
|
+
import { activate } from '../activate.js';
|
|
10
13
|
|
|
11
14
|
const { div, span, button, input, h3, p, select, option, label, textarea } = dd;
|
|
12
15
|
|
|
@@ -14,30 +17,38 @@ const { div, span, button, input, h3, p, select, option, label, textarea } = dd;
|
|
|
14
17
|
export function dialog(state, ui) {
|
|
15
18
|
const d = state.overlay.dialog;
|
|
16
19
|
if (!d) return null;
|
|
17
|
-
if (d.kind === 'collection') return collectionDialog(d, ui);
|
|
18
|
-
if (d.kind === 'plugin-review') return pluginReview(d, ui);
|
|
20
|
+
if (d.kind === 'collection') return collectionDialog(d, ui, state.caps, state.view);
|
|
21
|
+
if (d.kind === 'plugin-review') return pluginReview(d, ui, state.view);
|
|
19
22
|
if (d.kind === 'opener-chooser') return openerChooserDialog(d, ui);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
// Both kinds carry ACTIONS — what happens if you say yes — rather than a callback. A
|
|
24
|
+
// prompt's typed value is engine state (see bl/viewState.js), so its actions read what
|
|
25
|
+
// was typed instead of being handed it, and the dialog spec holds no functions.
|
|
26
|
+
//
|
|
27
|
+
// A confirm closes itself here; a prompt's action closes first and then acts, because it
|
|
28
|
+
// has to read the value before the dialog goes.
|
|
29
|
+
const value = promptValueOf(state.view, d);
|
|
30
|
+
const submit = () => {
|
|
31
|
+
if (d.kind === 'confirm') ui.engine.dispatch(new CloseDialogAction());
|
|
32
|
+
activate(ui, { actions: d.confirmActions });
|
|
33
|
+
};
|
|
23
34
|
return div({},
|
|
24
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
35
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
25
36
|
div({ className: 'dialog' },
|
|
26
37
|
h3(d.title),
|
|
27
38
|
d.body ? div({ className: 'body' }, d.body) : null,
|
|
28
39
|
d.kind === 'prompt'
|
|
29
40
|
? div({ className: 'field' },
|
|
30
41
|
d.label ? span({ $styling: { 'font-size': '12px', color: 'var(--text-dim)' } }, d.label) : null,
|
|
31
|
-
input({ className: 'input', value
|
|
42
|
+
input({ className: 'input', value, placeholder: d.placeholder || '', autofocus: true })
|
|
32
43
|
.on({
|
|
33
|
-
input: (e) => { value
|
|
34
|
-
keydown: (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape')
|
|
44
|
+
input: (e) => ui.engine.dispatch(new SetViewStateAction(PROMPT, { ref: d, value: e.target.value })),
|
|
45
|
+
keydown: (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') ui.engine.dispatch(new CloseDialogAction()); },
|
|
35
46
|
$attach: (el) => queueMicrotask(() => { el.focus(); el.select(); }),
|
|
36
47
|
}),
|
|
37
48
|
)
|
|
38
49
|
: null,
|
|
39
50
|
div({ className: 'row-actions' },
|
|
40
|
-
button({ className: 'btn' }, 'Cancel').on({ click: () =>
|
|
51
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
41
52
|
button({ className: `btn ${d.danger ? 'danger' : 'primary'}` }, d.confirmLabel || 'OK').on({ click: submit }),
|
|
42
53
|
),
|
|
43
54
|
),
|
|
@@ -48,38 +59,41 @@ export function dialog(state, ui) {
|
|
|
48
59
|
// workbench dialog state (updateDialog), so radio/checkbox selection survives
|
|
49
60
|
// re-renders. "Remember" persists a per-file-type association in settings.
|
|
50
61
|
function openerChooserDialog(d, ui) {
|
|
51
|
-
const wb = ui.platform.workbench;
|
|
52
|
-
const platform = ui.platform;
|
|
53
62
|
const selected = d.openerId || d.current || d.openers[0]?.id;
|
|
54
63
|
const remember = !!d.remember;
|
|
55
64
|
const confirm = () => {
|
|
56
|
-
if (remember && selected)
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
if (remember && selected) {
|
|
66
|
+
ui.engine.dispatch(new RememberOpenerAction(typeKeyFor(d.node), selected));
|
|
67
|
+
}
|
|
68
|
+
ui.engine.dispatch(new CloseDialogAction());
|
|
69
|
+
ui.engine.dispatch(new OpenInPanelAction(d.node, selected, { reset: !!d.reset }));
|
|
59
70
|
};
|
|
60
71
|
return div({},
|
|
61
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
72
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
62
73
|
div({ className: 'dialog opener-chooser' },
|
|
63
74
|
h3(`Open “${d.node.name}” with…`),
|
|
64
75
|
div({ className: 'opener-list' },
|
|
65
76
|
...d.openers.map((o) =>
|
|
66
77
|
label({ className: `opener-opt ${o.id === selected ? 'sel' : ''}` },
|
|
67
78
|
input({ type: 'radio', name: 'opener-choice', checked: o.id === selected })
|
|
68
|
-
.on({ change: () =>
|
|
79
|
+
.on({ change: () => ui.engine.dispatch(new UpdateDialogAction({ openerId: o.id })) }),
|
|
69
80
|
icon(o.icon || iconForNode(d.node), { size: 18 }),
|
|
70
81
|
div({ className: 'oo-main' },
|
|
71
82
|
span({ className: 'oo-title' }, o.title || o.id),
|
|
72
|
-
|
|
83
|
+
// Resolved by whoever assembled the list — see describeOpener. Asking the
|
|
84
|
+
// plugin host for a display name mid-render was the last thing keeping
|
|
85
|
+
// `platform` in this dialog.
|
|
86
|
+
span({ className: 'oo-src' }, o.source),
|
|
73
87
|
),
|
|
74
|
-
).on({ click: () =>
|
|
88
|
+
).on({ click: () => ui.engine.dispatch(new UpdateDialogAction({ openerId: o.id })) }),
|
|
75
89
|
),
|
|
76
90
|
),
|
|
77
91
|
label({ className: 'opener-remember' },
|
|
78
|
-
input({ type: 'checkbox', checked: remember }).on({ change: (e) =>
|
|
92
|
+
input({ type: 'checkbox', checked: remember }).on({ change: (e) => ui.engine.dispatch(new UpdateDialogAction({ remember: e.target.checked })) }),
|
|
79
93
|
span(`Always use this for ${typeLabelFor(d.node)}`),
|
|
80
94
|
),
|
|
81
95
|
div({ className: 'row-actions' },
|
|
82
|
-
button({ className: 'btn' }, 'Cancel').on({ click: () =>
|
|
96
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
83
97
|
button({ className: 'btn primary' }, 'Open').on({ click: confirm }),
|
|
84
98
|
),
|
|
85
99
|
),
|
|
@@ -95,19 +109,35 @@ function openerChooserDialog(d, ui) {
|
|
|
95
109
|
//
|
|
96
110
|
// The form persists across re-renders (keyed to the dialog instance) so switching driver
|
|
97
111
|
// keeps what has been typed.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
// The collection dialog's unsubmitted form. Engine state, because changing the storage
|
|
113
|
+
// driver changes which fields are on screen — see bl/viewState.js.
|
|
114
|
+
const COL_FORM = 'collectionDialog';
|
|
115
|
+
function collectionDialog(d, ui, caps, view) {
|
|
116
|
+
const drivers = caps?.storageDrivers || [];
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
// Derived, not written. This used to install the empty form during the render that
|
|
119
|
+
// noticed the dialog had changed — a render with a side effect. `draftFor` answers with
|
|
120
|
+
// the default when the held draft belongs to a different dialog instance, so nothing is
|
|
121
|
+
// written until the user types something.
|
|
122
|
+
const blankForm = {
|
|
123
|
+
name: '', description: '', driver: drivers[0]?.key || '',
|
|
124
|
+
// Off by default and deliberately so: encryption changes what the drive can do for you
|
|
125
|
+
// (see the exposure notes below) and should be a decision, not a default someone
|
|
126
|
+
// discovers later.
|
|
127
|
+
encrypt: false, encryptWhat: 'all', encryptList: '',
|
|
128
|
+
};
|
|
129
|
+
const colState = draftFor(view, COL_FORM, d, { form: blankForm });
|
|
106
130
|
const form = colState.form;
|
|
107
131
|
const driver = drivers.find((x) => x.key === form.driver) || drivers[0];
|
|
132
|
+
// Every field writes through the cell. Only the driver changes which fields are on
|
|
133
|
+
// screen, but writing them all the same way means there is no second rule to remember —
|
|
134
|
+
// and a text field that only mattered on submit was the reason `form` could be mutated
|
|
135
|
+
// in place at all.
|
|
136
|
+
// Merged against STORED state, not against the `form` this render closed over — see
|
|
137
|
+
// PatchDraftAction. Changing two fields before the next render used to lose one.
|
|
108
138
|
const set = (k) => (e) => {
|
|
109
|
-
|
|
110
|
-
|
|
139
|
+
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
|
140
|
+
ui.engine.dispatch(new PatchDraftAction(COL_FORM, colState.ref, { [k]: value }, blankForm));
|
|
111
141
|
};
|
|
112
142
|
|
|
113
143
|
const submit = () => {
|
|
@@ -118,17 +148,28 @@ function collectionDialog(d, ui) {
|
|
|
118
148
|
const v = form[f.name];
|
|
119
149
|
if (v !== undefined && v !== '') store[f.name] = f.type === 'number' ? Number(v) : v;
|
|
120
150
|
}
|
|
121
|
-
|
|
151
|
+
ui.engine.dispatch(new CreateCollectionFromFormAction({
|
|
152
|
+
name: form.name, description: form.description, store,
|
|
153
|
+
encryption: encryptionOf(form),
|
|
154
|
+
}));
|
|
122
155
|
};
|
|
123
156
|
|
|
124
157
|
const field = (lbl, k, ph = '') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
|
|
125
158
|
label(lbl), input({ className: 'input', placeholder: ph }).on({ input: set(k) }));
|
|
126
159
|
|
|
127
|
-
|
|
160
|
+
// The server refuses a configuration that would match nothing, and saying so here is the
|
|
161
|
+
// difference between a form that explains itself and an API error after the fact.
|
|
162
|
+
const encryptionProblem = form.encrypt && form.encryptWhat !== 'all' && !parseList(form.encryptList).length
|
|
163
|
+
? (form.encryptWhat === 'extensions'
|
|
164
|
+
? 'Name at least one extension, or choose every file.'
|
|
165
|
+
: 'Name at least one media type, or choose every file.')
|
|
166
|
+
: null;
|
|
167
|
+
|
|
168
|
+
const ready = !!form.name.trim() && !!form.driver && !encryptionProblem
|
|
128
169
|
&& (driver?.fields || []).every((f) => !f.required || String(form[f.name] ?? '').trim());
|
|
129
170
|
|
|
130
171
|
return div({},
|
|
131
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
172
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
132
173
|
div({ className: 'dialog', $styling: { width: 'min(480px, 94vw)' } },
|
|
133
174
|
h3('New collection'),
|
|
134
175
|
div({ className: 'body' }, 'A collection is a backing store you own. Configure where its files live.'),
|
|
@@ -143,8 +184,9 @@ function collectionDialog(d, ui) {
|
|
|
143
184
|
// misconfiguration — saying so beats an empty dropdown.
|
|
144
185
|
: div({ className: 'body' }, 'This server has no storage drivers registered, so a collection cannot be created.'),
|
|
145
186
|
driver ? storeFields(driver, form, set) : null,
|
|
187
|
+
encryptionSection(form, set, caps, encryptionProblem),
|
|
146
188
|
div({ className: 'row-actions' },
|
|
147
|
-
button({ className: 'btn' }, 'Cancel').on({ click: () =>
|
|
189
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
148
190
|
button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } }, 'Create collection')
|
|
149
191
|
.on({ click: () => ready && submit() }),
|
|
150
192
|
),
|
|
@@ -152,6 +194,78 @@ function collectionDialog(d, ui) {
|
|
|
152
194
|
);
|
|
153
195
|
}
|
|
154
196
|
|
|
197
|
+
/** The rules as the API wants them, or null when encryption is off. */
|
|
198
|
+
function encryptionOf(form) {
|
|
199
|
+
if (!form.encrypt) return null;
|
|
200
|
+
const list = parseList(form.encryptList);
|
|
201
|
+
return {
|
|
202
|
+
enabled: true,
|
|
203
|
+
rules: form.encryptWhat === 'all'
|
|
204
|
+
? { all: true }
|
|
205
|
+
: form.encryptWhat === 'extensions'
|
|
206
|
+
? { extensions: list }
|
|
207
|
+
: { mimeTypes: list },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A comma or space separated list, emptied of blanks. */
|
|
212
|
+
function parseList(text) {
|
|
213
|
+
return String(text || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Turning encryption on, and saying what it does.
|
|
218
|
+
*
|
|
219
|
+
* The scope notes sit HERE rather than in a help page, because the moment someone enables
|
|
220
|
+
* this is the moment the scope matters. What it protects is the storage provider seeing
|
|
221
|
+
* file contents; what it does not protect against is anything that reads through the drive,
|
|
222
|
+
* which includes every indexer. Someone who believes they have just made their files
|
|
223
|
+
* unreadable to the server has been misled by omission.
|
|
224
|
+
*/
|
|
225
|
+
function encryptionSection(form, set, caps, problem) {
|
|
226
|
+
const indexers = (caps?.indexers || []).filter((i) => i.readsContent !== false);
|
|
227
|
+
return div({ className: 'field enc-section', $styling: { 'margin-bottom': '10px' } },
|
|
228
|
+
label({ className: 'enc-toggle' },
|
|
229
|
+
input({ type: 'checkbox', checked: !!form.encrypt }).on({ change: set('encrypt') }),
|
|
230
|
+
span('Encrypt files in this collection'),
|
|
231
|
+
),
|
|
232
|
+
!form.encrypt
|
|
233
|
+
? div({ className: 'enc-hint' }, 'Files are stored exactly as uploaded.')
|
|
234
|
+
: div({ className: 'enc-body' },
|
|
235
|
+
div({ className: 'field' },
|
|
236
|
+
label('What to encrypt'),
|
|
237
|
+
select({ className: 'input' },
|
|
238
|
+
option({ value: 'all', selected: form.encryptWhat === 'all' }, 'Every file'),
|
|
239
|
+
option({ value: 'extensions', selected: form.encryptWhat === 'extensions' }, 'Only these extensions'),
|
|
240
|
+
option({ value: 'mimeTypes', selected: form.encryptWhat === 'mimeTypes' }, 'Only these media types'),
|
|
241
|
+
).on({ change: set('encryptWhat') })),
|
|
242
|
+
form.encryptWhat !== 'all'
|
|
243
|
+
? div({ className: 'field' },
|
|
244
|
+
input({
|
|
245
|
+
className: 'input', value: form.encryptList,
|
|
246
|
+
placeholder: form.encryptWhat === 'extensions' ? '.pdf, .docx, .key' : 'image, application/pdf',
|
|
247
|
+
}).on({ input: set('encryptList') }),
|
|
248
|
+
// A media type matches by its leading part too, which is how someone actually
|
|
249
|
+
// thinks about "encrypt my photos".
|
|
250
|
+
div({ className: 'enc-hint' }, form.encryptWhat === 'extensions'
|
|
251
|
+
? 'Separated by commas. The leading dot is optional.'
|
|
252
|
+
: 'Separated by commas. “image” covers every image type.'))
|
|
253
|
+
: null,
|
|
254
|
+
problem ? div({ className: 'enc-problem' }, icon('warn', { size: 13 }), span(problem)) : null,
|
|
255
|
+
div({ className: 'enc-scope' },
|
|
256
|
+
div({ className: 'es-title' }, 'What this does'),
|
|
257
|
+
div({ className: 'es-line' }, 'Files are sealed in your browser before they are uploaded, so the storage provider only ever holds ciphertext.'),
|
|
258
|
+
div({ className: 'es-title' }, 'What it does not do'),
|
|
259
|
+
div({ className: 'es-line' }, 'It is not end-to-end. The drive holds the key and decrypts for anything that reads through it — previews, downloads, and search indexing.'),
|
|
260
|
+
indexers.length
|
|
261
|
+
? div({ className: 'es-line' }, `These read file contents in the clear: ${indexers.map((i) => i.title || i.id).join(', ')}.`)
|
|
262
|
+
: null,
|
|
263
|
+
div({ className: 'es-line note' }, 'This applies to files uploaded from now on. Turning it off later does not decrypt anything already sealed.'),
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
155
269
|
/** The chosen driver's declared fields, rendered from its descriptor. */
|
|
156
270
|
function storeFields(driver, form, set) {
|
|
157
271
|
if (!driver.fields.length) {
|
|
@@ -182,7 +296,6 @@ function storeFields(driver, form, set) {
|
|
|
182
296
|
export function contextMenu(state, ui) {
|
|
183
297
|
const m = state.overlay.contextMenu;
|
|
184
298
|
if (!m || !m.items?.length) return null;
|
|
185
|
-
const wb = ui.platform.workbench;
|
|
186
299
|
// Clamp on both edges. Anchoring a menu ABOVE its trigger (the status bar opens
|
|
187
300
|
// upward) produces a negative top, and only the far edge used to be clamped.
|
|
188
301
|
// The height is whatever the menu needs OR whatever the window allows, whichever is
|
|
@@ -192,7 +305,7 @@ export function contextMenu(state, ui) {
|
|
|
192
305
|
const x = Math.max(8, Math.min(m.x, window.innerWidth - 220));
|
|
193
306
|
const y = Math.max(8, Math.min(m.y, window.innerHeight - wanted));
|
|
194
307
|
return div({},
|
|
195
|
-
div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () =>
|
|
308
|
+
div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () => ui.engine.dispatch(new CloseContextMenuAction()), contextmenu: (e) => { e.preventDefault(); ui.engine.dispatch(new CloseContextMenuAction()); } }),
|
|
196
309
|
div({ className: 'menu', $styling: { left: x + 'px', top: y + 'px' } },
|
|
197
310
|
...m.items.map((it, i) =>
|
|
198
311
|
it.sep
|
|
@@ -205,9 +318,9 @@ export function contextMenu(state, ui) {
|
|
|
205
318
|
span(it.label),
|
|
206
319
|
it.kbd ? span({ className: 'kbd' }, it.kbd) : null,
|
|
207
320
|
).on({
|
|
208
|
-
click: () => {
|
|
321
|
+
click: () => { ui.engine.dispatch(new CloseContextMenuAction()); activate(ui, it); },
|
|
209
322
|
// The first item is focused on open, so Escape has to get you back out.
|
|
210
|
-
keydown: (e) => { if (e.key === 'Escape') { e.preventDefault();
|
|
323
|
+
keydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); ui.engine.dispatch(new CloseContextMenuAction()); } },
|
|
211
324
|
$attach: (el) => { if (i === 0) queueMicrotask(() => el.focus()); },
|
|
212
325
|
}),
|
|
213
326
|
),
|
|
@@ -224,7 +337,7 @@ export function toasts(state, ui) {
|
|
|
224
337
|
div({ className: `toast ${n.level}` },
|
|
225
338
|
div({ className: 'bar' }),
|
|
226
339
|
div({ className: 'msg' }, n.message),
|
|
227
|
-
button({ className: 'x' }, icon('close', { size: 14 })).on({ click: () => ui.
|
|
340
|
+
button({ className: 'x' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new DismissNotificationAction(n.id)) }),
|
|
228
341
|
).key(n.id),
|
|
229
342
|
),
|
|
230
343
|
);
|
|
@@ -240,7 +353,7 @@ export function transferTray(state, ui) {
|
|
|
240
353
|
span({ $styling: { 'margin-left': '6px' } }, 'Transfers'),
|
|
241
354
|
div({ className: 'actions' },
|
|
242
355
|
button({ className: 'iconbtn', title: 'Clear finished' }, icon('check', { size: 14 }))
|
|
243
|
-
.on({ click: () => ui.
|
|
356
|
+
.on({ click: () => ui.engine.dispatch(new ClearFinishedTransfersAction()) }),
|
|
244
357
|
),
|
|
245
358
|
),
|
|
246
359
|
div({ className: 'items' },
|
|
@@ -250,13 +363,26 @@ export function transferTray(state, ui) {
|
|
|
250
363
|
icon(t.status === 'error' ? 'warn' : t.status === 'done' ? 'check' : 'upload', { size: 14 }),
|
|
251
364
|
span({ className: 'name' }, t.name),
|
|
252
365
|
t.status === 'active'
|
|
253
|
-
? button({ className: 'iconbtn', title: 'Cancel' }, icon('close', { size: 13 })).on({ click: () => ui.
|
|
366
|
+
? button({ className: 'iconbtn', title: 'Cancel' }, icon('close', { size: 13 })).on({ click: () => ui.engine.dispatch(new CancelTransferAction(t.id)) })
|
|
254
367
|
: span({ className: 'pct' }, t.status === 'done' ? bytes(t.total) : t.status),
|
|
255
368
|
),
|
|
256
369
|
t.status === 'active'
|
|
257
370
|
? div({ className: 'progress' }, div({ $styling: { width: `${Math.round(t.ratio * 100)}%` } }))
|
|
258
371
|
: t.error ? div({ $styling: { 'font-size': '11px', color: 'var(--danger)', 'margin-top': '4px' } }, t.error) : null,
|
|
259
372
|
t.status === 'active' ? div({ className: 'pct', $styling: { 'margin-top': '4px' } }, `${bytes(t.loaded)} / ${bytes(t.total)}`) : null,
|
|
373
|
+
// Offered on anything that stopped short, cancelled included — the transport
|
|
374
|
+
// already retries what is worth retrying on its own, so a row that reached this
|
|
375
|
+
// state failed for a reason another automatic attempt would not fix. A lost
|
|
376
|
+
// upload session is the case in point: correctly non-retryable, and correctly
|
|
377
|
+
// something the user can choose to start over.
|
|
378
|
+
(t.status === 'error' || t.status === 'cancelled') && t.retryable
|
|
379
|
+
? div({ className: 'xfer-actions' },
|
|
380
|
+
button({ className: 'btn small' }, icon('refresh', { size: 12 }), span('Retry'))
|
|
381
|
+
.on({ click: () => ui.engine.dispatch(new RetryTransferAction(t.id)) }),
|
|
382
|
+
button({ className: 'btn small ghost' }, 'Dismiss')
|
|
383
|
+
.on({ click: () => ui.engine.dispatch(new DismissTransferAction(t.id)) }),
|
|
384
|
+
)
|
|
385
|
+
: null,
|
|
260
386
|
).key(t.id),
|
|
261
387
|
),
|
|
262
388
|
),
|
|
@@ -272,7 +398,7 @@ export function pluginPanel(state, ui) {
|
|
|
272
398
|
div({ className: 'head' },
|
|
273
399
|
icon('plug', { size: 14 }),
|
|
274
400
|
span(plugin?.name || pid),
|
|
275
|
-
button({ className: 'iconbtn x' }, icon('close', { size: 14 })).on({ click: () => ui.
|
|
401
|
+
button({ className: 'iconbtn x' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new ClosePluginPanelAction()) }),
|
|
276
402
|
),
|
|
277
403
|
// The host mounts the plugin's iframe into this element on attach.
|
|
278
404
|
div({ className: 'host' }).on({
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { dd } from '../../runtime.js';
|
|
15
15
|
import { icon } from '../icon.js';
|
|
16
16
|
import { bytes } from '../format.js';
|
|
17
|
-
import {
|
|
17
|
+
import { usageChip } from './statusBar.js';
|
|
18
|
+
import { CloseSheetAction, ExecCommandAction, OpenSheetAction, ToggleActivityPanelAction, ToggleInboxAction } from '../../bl/actions.js';
|
|
18
19
|
|
|
19
20
|
const { div, button, span, img } = dd;
|
|
20
21
|
|
|
@@ -26,18 +27,22 @@ const TABS = [
|
|
|
26
27
|
{ id: 'plugins', icon: 'plug', label: 'Plugins', command: 'workbench.view.plugins' },
|
|
27
28
|
];
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
30
|
+
// How to DRAW each condition. Which condition is the one worth showing — offline over a
|
|
31
|
+
// standing problem, a standing problem over work in progress — is decided by the
|
|
32
|
+
// `statusFacts` query, because that ordering is a claim about what a person most needs to
|
|
33
|
+
// know rather than a question about glyphs. See bl/status.js.
|
|
34
|
+
const GLYPHS = {
|
|
35
|
+
offline: { icon: 'info', tone: 'warn' },
|
|
36
|
+
issues: { icon: 'warn', tone: 'danger' },
|
|
37
|
+
working: { spinner: true, tone: '' },
|
|
38
|
+
lowSpace: { icon: 'info', tone: 'warn' },
|
|
39
|
+
idle: { icon: 'info', tone: '' },
|
|
40
|
+
};
|
|
37
41
|
|
|
38
42
|
export function phoneTopBar(state, ui) {
|
|
39
|
-
const f =
|
|
40
|
-
const
|
|
43
|
+
const f = state.facts;
|
|
44
|
+
const c = f.condition;
|
|
45
|
+
const g = { ...(GLYPHS[c.kind] || GLYPHS.idle), label: c.label, badge: c.kind === 'issues' ? c.count : 0 };
|
|
41
46
|
// Where you are, at the coarsest level. Deliberately NOT the open file's name: the
|
|
42
47
|
// viewer's own breadcrumb sits directly beneath this bar and already says that, and two
|
|
43
48
|
// rows of chrome repeating "notes.txt" costs a fifth of the screen to say it twice.
|
|
@@ -46,14 +51,14 @@ export function phoneTopBar(state, ui) {
|
|
|
46
51
|
: f.collectionLabel;
|
|
47
52
|
return div({ className: 'phonebar top' },
|
|
48
53
|
button({ className: 'pb-brand', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
|
|
49
|
-
.on({ click: () => ui.
|
|
54
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }),
|
|
50
55
|
div({ className: 'pb-title' }, title),
|
|
51
56
|
button({ className: `pb-status ${g.tone}`, title: g.label, $attrs: { 'aria-label': g.label } },
|
|
52
57
|
g.spinner
|
|
53
58
|
? div({ className: 'spinner', $styling: { width: '15px', height: '15px' } })
|
|
54
59
|
: icon(g.icon, { size: 18 }),
|
|
55
60
|
g.badge ? span({ className: 'pb-badge' }, String(g.badge > 9 ? '9+' : g.badge)) : null,
|
|
56
|
-
).on({ click: () => ui.
|
|
61
|
+
).on({ click: () => ui.engine.dispatch(new OpenSheetAction('status')) }),
|
|
57
62
|
);
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -66,7 +71,7 @@ export function phoneBottomBar(state, ui) {
|
|
|
66
71
|
className: `pb-tab ${!sheet && active === t.id ? 'active' : ''}`,
|
|
67
72
|
$attrs: { 'aria-label': t.label },
|
|
68
73
|
}, icon(t.icon, { size: 21 }), span({ className: 'pb-label' }, t.label))
|
|
69
|
-
.on({ click: () => { ui.
|
|
74
|
+
.on({ click: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(t.command)); } })),
|
|
70
75
|
// Settings is reached FROM the More sheet, so while you are in it "More" is where
|
|
71
76
|
// you are — otherwise all four tabs read as inactive and the bar claims you are
|
|
72
77
|
// nowhere.
|
|
@@ -77,7 +82,7 @@ export function phoneBottomBar(state, ui) {
|
|
|
77
82
|
icon('dots', { size: 21 }),
|
|
78
83
|
unread ? span({ className: 'pb-badge' }, String(unread > 9 ? '9+' : unread)) : null,
|
|
79
84
|
span({ className: 'pb-label' }, 'More'),
|
|
80
|
-
).on({ click: () => ui.
|
|
85
|
+
).on({ click: () => ui.engine.dispatch(new OpenSheetAction('more')) }),
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -97,20 +102,18 @@ function sheetRow({ icon: name, label, value, danger, onClick }) {
|
|
|
97
102
|
export function phoneSheet(state, ui) {
|
|
98
103
|
const which = state.wb.sheet;
|
|
99
104
|
if (!which) return null;
|
|
100
|
-
const wb = ui.platform.workbench;
|
|
101
105
|
return div({ className: 'sheet-wrap' },
|
|
102
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
106
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseSheetAction()) }),
|
|
103
107
|
div({ className: 'sheet' },
|
|
104
|
-
div({ className: 'sheet-grip' }).on({ click: () =>
|
|
108
|
+
div({ className: 'sheet-grip' }).on({ click: () => ui.engine.dispatch(new CloseSheetAction()) }),
|
|
105
109
|
which === 'status' ? statusSheet(state, ui) : moreSheet(state, ui),
|
|
106
110
|
),
|
|
107
111
|
);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
function statusSheet(state, ui) {
|
|
111
|
-
const f =
|
|
112
|
-
const
|
|
113
|
-
const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
|
|
115
|
+
const f = state.facts;
|
|
116
|
+
const go = (cmd) => () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(cmd)); };
|
|
114
117
|
return div({ className: 'sheet-body' },
|
|
115
118
|
div({ className: 'sheet-title' }, f.collectionLabel),
|
|
116
119
|
|
|
@@ -119,14 +122,14 @@ function statusSheet(state, ui) {
|
|
|
119
122
|
? sheetRow({
|
|
120
123
|
icon: 'warn', danger: true,
|
|
121
124
|
label: `${f.issues.length} need${f.issues.length === 1 ? 's' : ''} attention`,
|
|
122
|
-
onClick: () => {
|
|
125
|
+
onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); },
|
|
123
126
|
})
|
|
124
127
|
: null,
|
|
125
128
|
f.running.length || f.uploading.length
|
|
126
129
|
? sheetRow({
|
|
127
130
|
icon: 'refresh',
|
|
128
131
|
label: `${f.running.length + f.uploading.length} running`,
|
|
129
|
-
onClick: () => {
|
|
132
|
+
onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); },
|
|
130
133
|
})
|
|
131
134
|
: null,
|
|
132
135
|
!f.off.online ? sheetRow({ icon: 'info', label: 'Offline', value: `${f.off.pins.length} pinned` }) : null,
|
|
@@ -146,26 +149,25 @@ function statusSheet(state, ui) {
|
|
|
146
149
|
? div({ className: 'sheet-row static usage' },
|
|
147
150
|
icon('download', { size: 18 }),
|
|
148
151
|
span({ className: 'sr-label' }, 'Free space'),
|
|
149
|
-
usageChip(
|
|
152
|
+
usageChip(f))
|
|
150
153
|
: null,
|
|
151
|
-
|
|
152
|
-
icon:
|
|
154
|
+
state.caps ? sheetRow({
|
|
155
|
+
icon: state.caps.storage?.presignDownload ? 'download' : 'files',
|
|
153
156
|
label: 'Transfers',
|
|
154
|
-
value:
|
|
157
|
+
value: state.caps.storage?.presignDownload ? 'Direct to storage' : 'Through the server',
|
|
155
158
|
}) : null,
|
|
156
|
-
|
|
159
|
+
state.caps?.features?.semanticSearch ? sheetRow({ icon: 'star', label: 'Search', value: 'Semantic + keyword' }) : null,
|
|
157
160
|
|
|
158
161
|
div({ className: 'sheet-actions' },
|
|
159
162
|
button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Scan for changes')).on({ click: go('workbench.scanCollection') }),
|
|
160
163
|
button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Activity'))
|
|
161
|
-
.on({ click: () => { ui.
|
|
164
|
+
.on({ click: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); } }),
|
|
162
165
|
),
|
|
163
166
|
);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
function moreSheet(state, ui) {
|
|
167
|
-
const
|
|
168
|
-
const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
|
|
170
|
+
const go = (cmd) => () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(cmd)); };
|
|
169
171
|
const me = state.so.me;
|
|
170
172
|
const unread = state.so.notifications.unread;
|
|
171
173
|
return div({ className: 'sheet-body' },
|
|
@@ -177,8 +179,8 @@ function moreSheet(state, ui) {
|
|
|
177
179
|
div({}, div({ className: 'sm-name' }, me.name || me.id), me.email ? div({ className: 'sm-sub' }, me.email) : null))
|
|
178
180
|
: null,
|
|
179
181
|
|
|
180
|
-
sheetRow({ icon: 'bell', label: unread ? `Notifications (${unread})` : 'Notifications', onClick: () => {
|
|
181
|
-
sheetRow({ icon: 'refresh', label: 'Activity & problems', onClick: () => {
|
|
182
|
+
sheetRow({ icon: 'bell', label: unread ? `Notifications (${unread})` : 'Notifications', onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleInboxAction(true)); } }),
|
|
183
|
+
sheetRow({ icon: 'refresh', label: 'Activity & problems', onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); } }),
|
|
182
184
|
sheetRow({ icon: 'info', label: 'Details & conversation', onClick: go('workbench.toggleInfoPanel') }),
|
|
183
185
|
sheetRow({ icon: 'trash', label: 'Trash', onClick: go('explorer.showTrash') }),
|
|
184
186
|
sheetRow({ icon: 'refresh', label: 'Refresh', onClick: go('explorer.refresh') }),
|