@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
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
// a context (a flat key→value map from the ContextKeyService) and returns a
|
|
4
4
|
// boolean.
|
|
5
5
|
//
|
|
6
|
-
// Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re
|
|
6
|
+
// Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re/flags, && , ||,
|
|
7
7
|
// parentheses, string/number/boolean literals. Expressions are parsed once into
|
|
8
8
|
// a predicate function and cached, so evaluation on every keydown is cheap.
|
|
9
9
|
//
|
|
10
|
+
// The compiled predicate carries `.keys`: every context key the expression names. That is
|
|
11
|
+
// what lets a clause be WATCHED rather than polled — see ContextRegistry#watch, which
|
|
12
|
+
// derives a cell over exactly those keys, so a clause about `view.active` stops recomputing
|
|
13
|
+
// every time the selection changes. Collected while parsing because that is the only place
|
|
14
|
+
// the distinction between a key and a literal is known.
|
|
15
|
+
//
|
|
10
16
|
// A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
|
|
11
17
|
// contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
|
|
12
18
|
// Registers are addressed by their full URI precisely because they're contributions
|
|
@@ -19,10 +25,16 @@ export function compileWhen(expr) {
|
|
|
19
25
|
if (cache.has(expr)) return cache.get(expr);
|
|
20
26
|
let fn;
|
|
21
27
|
try {
|
|
22
|
-
|
|
28
|
+
const parser = new Parser(expr);
|
|
29
|
+
fn = parser.parseExpression();
|
|
30
|
+
// The keys this clause reads. Frozen: it is shared by every caller through the cache.
|
|
31
|
+
fn.keys = Object.freeze([...parser.keys]);
|
|
23
32
|
} catch (err) {
|
|
24
33
|
console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
|
|
25
34
|
fn = () => false;
|
|
35
|
+
// A clause that could not be parsed depends on nothing, so watching it yields a cell
|
|
36
|
+
// that is constantly false rather than one that never settles.
|
|
37
|
+
fn.keys = Object.freeze([]);
|
|
26
38
|
}
|
|
27
39
|
cache.set(expr, fn);
|
|
28
40
|
return fn;
|
|
@@ -32,15 +44,21 @@ export function evaluateWhen(expr, ctx) {
|
|
|
32
44
|
return compileWhen(expr)(ctx || {});
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/** Every context key `expr` names — `[]` for a constant or unparseable clause. */
|
|
48
|
+
export function keysOf(expr) {
|
|
49
|
+
return compileWhen(expr).keys ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
// Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
|
|
36
53
|
// slashes would otherwise start a /…/ literal and swallow the rest of the expression.
|
|
37
|
-
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])
|
|
54
|
+
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/[a-z]*|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
|
|
38
55
|
|
|
39
56
|
class Parser {
|
|
40
57
|
constructor(src) {
|
|
41
58
|
this.src = src;
|
|
42
59
|
this.tokens = this.#lex(src);
|
|
43
60
|
this.pos = 0;
|
|
61
|
+
this.keys = new Set();
|
|
44
62
|
}
|
|
45
63
|
#lex(src) {
|
|
46
64
|
const out = [];
|
|
@@ -137,6 +155,7 @@ class Parser {
|
|
|
137
155
|
return () => s;
|
|
138
156
|
}
|
|
139
157
|
// A context key.
|
|
158
|
+
this.keys.add(t);
|
|
140
159
|
return (c) => c[t];
|
|
141
160
|
}
|
|
142
161
|
}
|
|
@@ -458,12 +458,23 @@ kbd {
|
|
|
458
458
|
.scrim { position: fixed; inset: 0; background: #0006; backdrop-filter: blur(1.5px); z-index: 40; }
|
|
459
459
|
|
|
460
460
|
.palette {
|
|
461
|
-
position: fixed; top: 12vh; left: 50%;
|
|
461
|
+
position: fixed; top: 12vh; left: 50%; translate: -50% 0;
|
|
462
462
|
width: min(640px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
|
|
463
463
|
border-radius: 12px; box-shadow: var(--shadow); z-index: 50; overflow: hidden;
|
|
464
464
|
animation: pop .13s var(--ease);
|
|
465
465
|
}
|
|
466
|
-
|
|
466
|
+
/* Transform-neutral on purpose.
|
|
467
|
+
|
|
468
|
+
This used to end at `translate(-50%, 0)` — the command palette's horizontal centring,
|
|
469
|
+
baked into a shared animation. `animation-fill-mode` is `none`, so when the animation
|
|
470
|
+
finished the transform reverted to the element's own and anything that did not happen to
|
|
471
|
+
want exactly that offset jumped by the difference: the context menu, plugin panel and
|
|
472
|
+
inbox each snapped right by half their width, and the dialog dropped half its height
|
|
473
|
+
because the keyframe kept the X centring and lost the Y.
|
|
474
|
+
|
|
475
|
+
Elements that need centring now do it with the standalone `translate` property, which
|
|
476
|
+
composes with `transform` instead of competing for the same slot. */
|
|
477
|
+
@keyframes pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
467
478
|
.palette .search { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
468
479
|
.palette .search input { flex: 1; background: transparent; border: 0; outline: none; font-size: 15px; color: var(--text); }
|
|
469
480
|
.palette .results { max-height: 52vh; overflow-y: auto; padding: 6px; }
|
|
@@ -501,7 +512,7 @@ kbd {
|
|
|
501
512
|
.palette .none .spinner { width: 13px; height: 13px; }
|
|
502
513
|
|
|
503
514
|
.dialog {
|
|
504
|
-
position: fixed; top: 50%; left: 50%;
|
|
515
|
+
position: fixed; top: 50%; left: 50%; translate: -50% -50%;
|
|
505
516
|
width: min(440px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
|
|
506
517
|
border-radius: 12px; box-shadow: var(--shadow); z-index: 50; padding: 20px; animation: pop .13s var(--ease);
|
|
507
518
|
}
|
|
@@ -535,7 +546,11 @@ kbd {
|
|
|
535
546
|
border: 1px solid var(--border-strong); border-radius: 9px; box-shadow: var(--shadow); padding: 5px;
|
|
536
547
|
animation: pop .1s var(--ease);
|
|
537
548
|
}
|
|
538
|
-
|
|
549
|
+
/* `width: 100%` because these are <button>s, and a form control computes `width: auto` as
|
|
550
|
+
fit-content rather than stretching the way a block does — so the row, and with it the
|
|
551
|
+
hover target, was only as wide as its label. `.kbd`'s `margin-left: auto` below is the
|
|
552
|
+
evidence of the intent: pushing a shortcut to the far right needs a full-width row. */
|
|
553
|
+
.menu .mi { display: flex; width: 100%; box-sizing: border-box; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 6px; cursor: default; }
|
|
539
554
|
.menu .mi:hover { background: var(--bg-active); }
|
|
540
555
|
.menu .mi.danger:hover { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
|
|
541
556
|
.menu .mi .kbd { margin-left: auto; color: var(--text-faint); }
|
|
@@ -562,6 +577,9 @@ kbd {
|
|
|
562
577
|
.tray .items { max-height: 300px; overflow-y: auto; }
|
|
563
578
|
.xfer { padding: 9px 12px; border-bottom: 1px solid var(--border); }
|
|
564
579
|
.xfer:last-child { border-bottom: 0; }
|
|
580
|
+
/* Only appears on a transfer that stopped short, so it never competes with the
|
|
581
|
+
progress bar for the eye. */
|
|
582
|
+
.xfer-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
565
583
|
.xfer .top { display: flex; align-items: center; gap: 8px; }
|
|
566
584
|
.xfer .top .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
|
567
585
|
.xfer .pct { color: var(--text-faint); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
@@ -661,6 +679,61 @@ kbd {
|
|
|
661
679
|
.review-meta { color: var(--text-faint); font-size: 11px; margin-top: 6px; }
|
|
662
680
|
.dialog.review .row-actions { padding: 14px 18px; border-top: 1px solid var(--border); margin: 0; }
|
|
663
681
|
|
|
682
|
+
/* The admin console's action rows: several small buttons side by side, which the settings
|
|
683
|
+
`.control` column is not otherwise shaped for. */
|
|
684
|
+
/* A row whose control is a GROUP of buttons rather than one input.
|
|
685
|
+
`.setting .control` is a fixed 200px column, which is right for a select or a toggle and
|
|
686
|
+
far too narrow for three buttons — they wrapped 2-then-1 and sat jammed against the
|
|
687
|
+
edge. Such a row wraps instead, giving the buttons their own full-width line under the
|
|
688
|
+
description, so the longest label stops squeezing the group. */
|
|
689
|
+
.setting.stacked { flex-wrap: wrap; }
|
|
690
|
+
.setting.stacked .info { flex: 1 1 100%; }
|
|
691
|
+
.setting.stacked .control { width: 100%; margin-top: 12px; }
|
|
692
|
+
.admin .control.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
693
|
+
.admin .control.admin-actions .btn { white-space: nowrap; }
|
|
694
|
+
.admin .control .mono.muted { color: var(--text-faint); }
|
|
695
|
+
|
|
696
|
+
/* The rotation bill, itemised. Only where a provider actually charges for the traffic. */
|
|
697
|
+
.rot-bill { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px;
|
|
698
|
+
background: color-mix(in srgb, var(--text) 4%, transparent); }
|
|
699
|
+
.rot-bill .rb-line { display: flex; justify-content: space-between; gap: 12px;
|
|
700
|
+
font-size: 12px; color: var(--text-dim); padding: 2px 0; }
|
|
701
|
+
.rot-bill .rb-line.total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 5px; color: var(--text); }
|
|
702
|
+
.rot-bill .rb-asof { font-size: 11px; color: var(--text-faint); margin-top: 5px; }
|
|
703
|
+
|
|
704
|
+
/* ---- Encryption, on the collection form --------------------------------- */
|
|
705
|
+
.enc-section { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 4px; }
|
|
706
|
+
.enc-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
|
|
707
|
+
.enc-hint { color: var(--text-faint); font-size: 11.5px; margin-top: 4px; }
|
|
708
|
+
.enc-body { margin-top: 10px; }
|
|
709
|
+
.enc-body .field { margin-bottom: 8px; }
|
|
710
|
+
.enc-problem { display: flex; align-items: center; gap: 6px; color: var(--warn); font-size: 12px; margin: 6px 0; }
|
|
711
|
+
/* What it does and does not do, next to the toggle rather than in a help page: the moment
|
|
712
|
+
someone turns this on is the moment the scope matters. */
|
|
713
|
+
.enc-scope { margin-top: 10px; padding: 10px 12px; border-radius: 8px;
|
|
714
|
+
background: color-mix(in srgb, var(--text) 4%, transparent); }
|
|
715
|
+
.enc-scope .es-title { font-size: 11px; font-weight: 650; text-transform: uppercase;
|
|
716
|
+
letter-spacing: 0.04em; color: var(--text-dim); margin-bottom: 3px; }
|
|
717
|
+
.enc-scope .es-title + .es-line { margin-bottom: 8px; }
|
|
718
|
+
.enc-scope .es-line { font-size: 12px; line-height: 1.5; color: var(--text-dim); }
|
|
719
|
+
.enc-scope .es-line.note { margin-top: 8px; color: var(--text-faint); }
|
|
720
|
+
|
|
721
|
+
/* Provenance warning. Sits between the review and the Install button rather than beside
|
|
722
|
+
the name, because a badge up in the header is something you read past. */
|
|
723
|
+
.review-warning { padding: 12px 18px; border-top: 1px solid var(--border);
|
|
724
|
+
background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
|
725
|
+
.review-warning .rw-head { display: flex; align-items: center; gap: 7px; color: var(--warn);
|
|
726
|
+
font-weight: 600; font-size: 13px; }
|
|
727
|
+
.review-warning .rw-detail { color: var(--text-dim); font-size: 12.5px; line-height: 1.5; margin-top: 5px; }
|
|
728
|
+
.review-warning .rw-ack { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px;
|
|
729
|
+
font-size: 12.5px; color: var(--text); cursor: pointer; }
|
|
730
|
+
.review-warning .rw-ack input { margin-top: 2px; }
|
|
731
|
+
/* An unsigned package is the one someone is most likely to wave through, so it is the
|
|
732
|
+
loudest; the others are informational. */
|
|
733
|
+
.review-warning.unverified { background: color-mix(in srgb, var(--danger) 10%, transparent); }
|
|
734
|
+
.review-warning.unverified .rw-head { color: var(--danger); }
|
|
735
|
+
.btn.primary.disabled, .btn.primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
736
|
+
|
|
664
737
|
/* ---- Media / audiobook opener ------------------------------------------- */
|
|
665
738
|
.viewer { height: 100%; display: flex; flex-direction: column; }
|
|
666
739
|
.viewer.text pre { margin: 0; padding: 18px 20px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; color: var(--text); }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Running a menu item or a list row.
|
|
2
|
+
//
|
|
3
|
+
// These used to carry a `run` closure — `run: () => ui.engine.dispatch(new ExecCommandAction('explorer.open', node))` — which
|
|
4
|
+
// made a menu item a piece of behaviour wearing a data shape. Nothing could inspect one,
|
|
5
|
+
// nothing could contribute one from outside the module that built it, and the engine saw
|
|
6
|
+
// only whatever the closure happened to do.
|
|
7
|
+
//
|
|
8
|
+
// They carry `actions` now: a list of Action instances, which is what an item IS — a label,
|
|
9
|
+
// an icon, and what happens if you pick it. Almost all of them turned out to be exactly one
|
|
10
|
+
// `ExecCommandAction`, which is the shape the statusItem view already used.
|
|
11
|
+
//
|
|
12
|
+
// A LIST rather than a single action because a few items genuinely do two things: opening a
|
|
13
|
+
// file from the search modal opens it and then closes the modal. Sequencing them here keeps
|
|
14
|
+
// that out of the item.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} ui
|
|
18
|
+
* @param {{actions?: object[]}} item
|
|
19
|
+
*/
|
|
20
|
+
export function activate(ui, item) {
|
|
21
|
+
for (const action of item?.actions || []) ui.engine.dispatch(action);
|
|
22
|
+
}
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
import { dd } from '../../runtime.js';
|
|
7
7
|
import { icon } from '../icon.js';
|
|
8
8
|
import { notificationBell, principalChip } from './social.js';
|
|
9
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
9
10
|
|
|
10
11
|
const { div, button, span, img } = dd;
|
|
11
12
|
|
|
12
13
|
const ITEMS = [
|
|
13
14
|
{ id: 'home', icon: 'search', title: 'Search & files', command: 'workbench.view.home' },
|
|
14
15
|
{ id: 'plugins', icon: 'plug', title: 'Plugins', command: 'workbench.view.plugins' },
|
|
16
|
+
// Where an administrator goes. A destination rather than a route because this app does
|
|
17
|
+
// not put its state in the address bar — see adminView.js.
|
|
18
|
+
{ id: 'admin', icon: 'book', title: 'Administration', command: 'workbench.view.admin' },
|
|
15
19
|
];
|
|
16
20
|
|
|
17
21
|
export default function activityBar(state, ui) {
|
|
@@ -19,17 +23,17 @@ export default function activityBar(state, ui) {
|
|
|
19
23
|
const pluginCount = state.plugins?.filter((p) => p.status === 'active').length || 0;
|
|
20
24
|
return div({ className: 'activitybar' },
|
|
21
25
|
button({ className: 'brand-mark', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
|
|
22
|
-
.on({ click: () => ui.
|
|
26
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }),
|
|
23
27
|
...ITEMS.map((it) =>
|
|
24
28
|
button({ className: `item ${active === it.id ? 'active' : ''}`, title: it.title },
|
|
25
29
|
icon(it.icon, { size: 22 }),
|
|
26
30
|
it.id === 'plugins' && pluginCount ? span({ className: 'badge' }, String(pluginCount)) : null,
|
|
27
|
-
).on({ click: () => ui.
|
|
31
|
+
).on({ click: () => ui.engine.dispatch(new ExecCommandAction(it.command)) }),
|
|
28
32
|
),
|
|
29
33
|
div({ className: 'spacer' }),
|
|
30
34
|
notificationBell(state, ui),
|
|
31
35
|
button({ className: `item ${active === 'settings' ? 'active' : ''}`, title: 'Settings' }, icon('gear', { size: 21 }))
|
|
32
|
-
.on({ click: () => ui.
|
|
36
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.openSettings')) }),
|
|
33
37
|
principalChip(state),
|
|
34
38
|
);
|
|
35
39
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { dd } from '../../runtime.js';
|
|
12
12
|
import { icon } from '../icon.js';
|
|
13
13
|
import { bytes } from '../format.js';
|
|
14
|
+
import { CancelTaskAction, CopyTextAction, DismissIssueAction, DismissTaskAction, ExecCommandAction, RetryIssueAction, ToggleActivityPanelAction } from '../../bl/actions.js';
|
|
14
15
|
|
|
15
16
|
const { div, button, span, h3, p, pre } = dd;
|
|
16
17
|
|
|
@@ -24,9 +25,7 @@ const STATUS_ICON = { running: 'refresh', done: 'check', failed: 'close', cancel
|
|
|
24
25
|
* of the app and into a terminal.
|
|
25
26
|
*/
|
|
26
27
|
function copyText(text, ui) {
|
|
27
|
-
|
|
28
|
-
.then(() => ui.platform.notifications.success('Copied'))
|
|
29
|
-
.catch(() => ui.platform.notifications.info(text, { sticky: true }));
|
|
28
|
+
ui.engine.dispatch(new CopyTextAction(text));
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
function amount(task) {
|
|
@@ -51,9 +50,9 @@ function taskRow(task, ui) {
|
|
|
51
50
|
task.error ? div({ className: 'act-error' }, task.error) : null,
|
|
52
51
|
),
|
|
53
52
|
running && task.cancellable
|
|
54
|
-
? button({ className: 'act-action', title: 'Cancel' }, icon('close', { size: 12 })).on({ click: () => ui.
|
|
53
|
+
? button({ className: 'act-action', title: 'Cancel' }, icon('close', { size: 12 })).on({ click: () => ui.engine.dispatch(new CancelTaskAction(task.id)) })
|
|
55
54
|
: !running
|
|
56
|
-
? button({ className: 'act-action', title: 'Dismiss' }, icon('close', { size: 12 })).on({ click: () => ui.
|
|
55
|
+
? button({ className: 'act-action', title: 'Dismiss' }, icon('close', { size: 12 })).on({ click: () => ui.engine.dispatch(new DismissTaskAction(task.id)) })
|
|
57
56
|
: null,
|
|
58
57
|
),
|
|
59
58
|
running
|
|
@@ -89,10 +88,10 @@ function issueRow(issue, ui) {
|
|
|
89
88
|
// Retry only when the server said pressing it will actually do something.
|
|
90
89
|
issue.retryable
|
|
91
90
|
? button({ className: 'btn small act-retry' }, icon('refresh', { size: 12 }), span('Retry'))
|
|
92
|
-
.on({ click: () => ui.
|
|
91
|
+
.on({ click: () => ui.engine.dispatch(new RetryIssueAction(issue.id)) })
|
|
93
92
|
: null,
|
|
94
93
|
button({ className: 'btn small ghost act-dismiss' }, 'Dismiss')
|
|
95
|
-
.on({ click: () => ui.
|
|
94
|
+
.on({ click: () => ui.engine.dispatch(new DismissIssueAction(issue.id)) }),
|
|
96
95
|
),
|
|
97
96
|
);
|
|
98
97
|
}
|
|
@@ -107,7 +106,7 @@ export default function activityPanel(state, ui) {
|
|
|
107
106
|
div({ className: 'act-head' },
|
|
108
107
|
h3('Activity'),
|
|
109
108
|
button({ className: 'act-action', title: 'Close' }, icon('close', { size: 13 }))
|
|
110
|
-
.on({ click: () => ui.
|
|
109
|
+
.on({ click: () => ui.engine.dispatch(new ToggleActivityPanelAction(false)) }),
|
|
111
110
|
),
|
|
112
111
|
|
|
113
112
|
// "Nothing is wrong" and "we couldn't ask" look identical unless one says so. Both
|
|
@@ -145,15 +144,15 @@ export default function activityPanel(state, ui) {
|
|
|
145
144
|
|
|
146
145
|
div({ className: 'act-foot act-actions' },
|
|
147
146
|
button({ className: 'btn small ghost act-rebuild' }, icon('refresh', { size: 12 }), span('Rebuild search index'))
|
|
148
|
-
.on({ click: () => ui.
|
|
147
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.rebuildIndex')) }),
|
|
149
148
|
button({ className: 'btn small ghost act-scan' }, icon('refresh', { size: 12 }), span('Scan for outside changes'))
|
|
150
|
-
.on({ click: () => ui.
|
|
149
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.scanCollection')) }),
|
|
151
150
|
// On demand as well as on a schedule, because the moment an admin wants to know
|
|
152
151
|
// whether they fixed the bucket is right after they changed it — not up to five
|
|
153
152
|
// minutes later. It also checks against THIS browser's origin, which the scheduled
|
|
154
153
|
// run can only do if the deployment configured one.
|
|
155
154
|
button({ className: 'btn small ghost act-storage' }, icon('plug', { size: 12 }), span('Check storage'))
|
|
156
|
-
.on({ click: () => ui.
|
|
155
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.checkStorage')) }),
|
|
157
156
|
),
|
|
158
157
|
);
|
|
159
158
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The admin console.
|
|
2
|
+
//
|
|
3
|
+
// Drive-level administration had accumulated in three places and no place: API keys in
|
|
4
|
+
// Settings, storage diagnostics behind a status-bar item, ACLs in a dialog, and the driver
|
|
5
|
+
// registry, the indexers and the identity provider only through the API. Each landed where
|
|
6
|
+
// it did for a local reason, and the result was that there was nowhere to send someone who
|
|
7
|
+
// had just been made an administrator.
|
|
8
|
+
//
|
|
9
|
+
// A DESTINATION, not a route. 006 introduced share links, and what it actually established
|
|
10
|
+
// is that this app does not reflect its state in the address bar — it consumes a share path
|
|
11
|
+
// at boot and immediately replaces it with `/`, because a URL that goes stale the moment
|
|
12
|
+
// you navigate is worse than one that says nothing. So there is no router to hang a route
|
|
13
|
+
// on, and an activity-bar destination beside Plugins and Settings is the consistent answer.
|
|
14
|
+
//
|
|
15
|
+
// Almost nothing here is new capability. It is gathering: every value below already comes
|
|
16
|
+
// from `/api/capabilities`, which the app fetches at startup, or from a query that already
|
|
17
|
+
// exists. What was missing was somewhere to look.
|
|
18
|
+
|
|
19
|
+
import { dd } from '../../runtime.js';
|
|
20
|
+
import { icon } from '../icon.js';
|
|
21
|
+
import { bytes } from '../format.js';
|
|
22
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
23
|
+
|
|
24
|
+
const { div, h2, h3, p, span, button } = dd;
|
|
25
|
+
|
|
26
|
+
export default function adminView(state, ui) {
|
|
27
|
+
const caps = state.caps;
|
|
28
|
+
return div({ className: 'editor' },
|
|
29
|
+
div({ className: 'stage' },
|
|
30
|
+
div({ className: 'settings admin' },
|
|
31
|
+
h2('Administration'),
|
|
32
|
+
p({ className: 'sub' }, 'How this drive is configured, and what it can do. Most of it is decided by the deployment rather than here.'),
|
|
33
|
+
accessSection(caps, state, ui),
|
|
34
|
+
storageSection(caps, state, ui),
|
|
35
|
+
extensionsSection(caps, state),
|
|
36
|
+
workSection(state, ui),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A label/value row, matching the settings screen so the two read as one thing. */
|
|
43
|
+
function row(title, detail, value) {
|
|
44
|
+
return div({ className: 'setting' },
|
|
45
|
+
div({ className: 'info' },
|
|
46
|
+
div({ className: 't' }, title),
|
|
47
|
+
detail ? div({ className: 'd' }, detail) : null,
|
|
48
|
+
),
|
|
49
|
+
div({ className: 'control' }, typeof value === 'string' ? span({ className: 'mono' }, value) : value),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Who this drive thinks you are, and who it lets in.
|
|
55
|
+
*
|
|
56
|
+
* The identity driver and whether authentication is required are deployment decisions —
|
|
57
|
+
* environment variables, not preferences — so they are reported rather than offered. Which
|
|
58
|
+
* is the point: an admin needs to know what is in force, and previously could only find out
|
|
59
|
+
* by reading the server's configuration or calling the API.
|
|
60
|
+
*/
|
|
61
|
+
function accessSection(caps, state, ui) {
|
|
62
|
+
const auth = caps?.auth || {};
|
|
63
|
+
const me = caps?.principal || state.so?.me || {};
|
|
64
|
+
const servers = auth.authorizationServers || [];
|
|
65
|
+
return div({ className: 'group' },
|
|
66
|
+
h3('Access'),
|
|
67
|
+
p({ className: 'sub' }, 'Who can reach this drive. Set by the deployment, not here.'),
|
|
68
|
+
row('Signed in as', me.anonymous ? 'This drive does not require a sign-in.' : (me.email || me.id),
|
|
69
|
+
me.anonymous ? 'anonymous' : (me.name || me.id || '—')),
|
|
70
|
+
row('Sign-in required', 'Whether a caller must present a token.',
|
|
71
|
+
caps?.mcp?.requiresAuth ? 'Yes' : 'No'),
|
|
72
|
+
row('Authorization server', 'Where clients are sent to sign in.',
|
|
73
|
+
servers.length ? servers.join(', ') : 'not set'),
|
|
74
|
+
// API keys stay in Settings for now and are LINKED rather than duplicated: two places
|
|
75
|
+
// to revoke a credential is worse than one place in the wrong screen.
|
|
76
|
+
row('API keys', 'Credentials for scripts and services, scoped per collection.',
|
|
77
|
+
button({ className: 'btn small' }, 'In Settings')
|
|
78
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.openSettings')) })),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What this deployment can actually do with bytes.
|
|
84
|
+
*
|
|
85
|
+
* The driver registry is the list a collection form offers, and it is deployment
|
|
86
|
+
* configuration: `TROVE_STORAGE_DRIVERS` narrows it, and a driver missing from here is the
|
|
87
|
+
* reason a collection cannot be made on it. The capability flags below explain behaviour
|
|
88
|
+
* that otherwise looks arbitrary — why a download is proxied rather than direct, why a
|
|
89
|
+
* usage figure is missing.
|
|
90
|
+
*/
|
|
91
|
+
function storageSection(caps, state, ui) {
|
|
92
|
+
const s = caps?.storage || {};
|
|
93
|
+
const drivers = caps?.storageDrivers || [];
|
|
94
|
+
const usage = state.ex?.usage;
|
|
95
|
+
const can = (on) => span({ className: `mono ${on ? '' : 'muted'}` }, on ? 'yes' : 'no');
|
|
96
|
+
return div({ className: 'group' },
|
|
97
|
+
h3('Storage'),
|
|
98
|
+
p({ className: 'sub' }, 'The backing stores this deployment offers, and what they support.'),
|
|
99
|
+
row('Drivers offered', 'What a new collection may be created on.',
|
|
100
|
+
drivers.length ? drivers.map((d) => d.label || d.key).join(', ') : 'none registered'),
|
|
101
|
+
row('Direct downloads', 'A presigned URL sends bytes from the store, not through the drive.', can(s.presignDownload)),
|
|
102
|
+
row('Direct uploads', 'A presigned PUT sends bytes to the store, not through the drive.', can(s.presignUpload)),
|
|
103
|
+
row('Multipart', 'Large files go up in parts, resumably.', can(s.multipart)),
|
|
104
|
+
row('Range requests', 'Seeking inside a file without fetching the whole thing.', can(s.range)),
|
|
105
|
+
usage?.total
|
|
106
|
+
? row('Space', 'Reported by the backing store.', `${bytes(usage.available)} free of ${bytes(usage.total)}`)
|
|
107
|
+
: row('Space', 'This store does not report a total, which object stores generally cannot.', 'not reported'),
|
|
108
|
+
// The check and its remedy already exist as a command — this is where someone would
|
|
109
|
+
// look for it, which is the whole complaint the ticket makes.
|
|
110
|
+
div({ className: 'setting' },
|
|
111
|
+
div({ className: 'info' },
|
|
112
|
+
div({ className: 't' }, 'Check the backing stores'),
|
|
113
|
+
div({ className: 'd' }, 'Asks whether each store can actually be reached from here — the failure that makes every file open to a spinner.'),
|
|
114
|
+
),
|
|
115
|
+
div({ className: 'control' },
|
|
116
|
+
button({ className: 'btn small' }, 'Run check')
|
|
117
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.checkStorage')) })),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What extends this drive, and what each extension is permitted.
|
|
124
|
+
*
|
|
125
|
+
* Indexers matter here for a reason that is easy to miss: an indexer reads file CONTENTS,
|
|
126
|
+
* including from an encrypted collection, because indexing happens on the drive's side of
|
|
127
|
+
* the encryption. Anyone weighing what encryption protects needs to know which ones are
|
|
128
|
+
* installed, so the same list appears on the collection form.
|
|
129
|
+
*/
|
|
130
|
+
function extensionsSection(caps, state) {
|
|
131
|
+
const indexers = caps?.indexers || [];
|
|
132
|
+
const plugins = state.plugins || [];
|
|
133
|
+
return div({ className: 'group' },
|
|
134
|
+
h3('Extensions'),
|
|
135
|
+
p({ className: 'sub' }, 'What reads and adds to this drive.'),
|
|
136
|
+
div({ className: 'setting' },
|
|
137
|
+
div({ className: 'info' },
|
|
138
|
+
div({ className: 't' }, 'Indexers'),
|
|
139
|
+
div({ className: 'd' }, 'They read file contents to make them searchable — including in an encrypted collection, since indexing happens on the drive’s side of the encryption.'),
|
|
140
|
+
),
|
|
141
|
+
div({ className: 'control' },
|
|
142
|
+
span({ className: 'mono' }, indexers.length ? indexers.map((i) => i.displayName || i.id).join(', ') : 'none')),
|
|
143
|
+
),
|
|
144
|
+
plugins.length
|
|
145
|
+
? div({}, ...plugins.map((pl) => div({ className: 'setting' },
|
|
146
|
+
div({ className: 'info' },
|
|
147
|
+
div({ className: 't' }, pl.manifest?.displayName || pl.id),
|
|
148
|
+
div({ className: 'd' }, (pl.grants || []).length
|
|
149
|
+
? `May: ${(pl.grants || []).join(', ')}`
|
|
150
|
+
: 'Granted nothing — it runs in its sandbox and can only draw.'),
|
|
151
|
+
),
|
|
152
|
+
div({ className: 'control' }, span({ className: `mono ${pl.status === 'active' ? '' : 'muted'}` }, pl.status || 'idle')),
|
|
153
|
+
)))
|
|
154
|
+
: row('Plugins', 'Installed by an administrator, from a signed package.', 'none installed'),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What the drive is doing, and what it has not finished.
|
|
160
|
+
*
|
|
161
|
+
* The panel that shows running work already exists and is reached from the status bar,
|
|
162
|
+
* which is fine when something is running and undiscoverable when nothing is. The counts
|
|
163
|
+
* here are the discoverable version; the panel is still where the detail lives.
|
|
164
|
+
*/
|
|
165
|
+
function workSection(state, ui) {
|
|
166
|
+
const act = state.act || { tasks: [], issues: [] };
|
|
167
|
+
const running = (act.tasks || []).filter((t) => t.status === 'running');
|
|
168
|
+
return div({ className: 'group' },
|
|
169
|
+
h3('Work'),
|
|
170
|
+
p({ className: 'sub' }, 'Background jobs and anything that needs attention.'),
|
|
171
|
+
row('Running now', 'Scans, reindexes and rotations happening on the server.',
|
|
172
|
+
running.length ? `${running.length}` : 'nothing running'),
|
|
173
|
+
row('Needs attention', 'Standing problems — they persist until fixed, unlike a toast.',
|
|
174
|
+
act.issues?.length ? `${act.issues.length}` : 'none'),
|
|
175
|
+
// `stacked`: three buttons do not fit the 200px control column — see styles.css.
|
|
176
|
+
div({ className: 'setting stacked' },
|
|
177
|
+
div({ className: 'info' },
|
|
178
|
+
div({ className: 't' }, 'Maintenance'),
|
|
179
|
+
div({ className: 'd' }, 'A scan picks up files changed in the bucket by something other than Trove. A reindex rebuilds search from scratch.'),
|
|
180
|
+
),
|
|
181
|
+
div({ className: 'control admin-actions' },
|
|
182
|
+
button({ className: 'btn small' }, 'Activity panel')
|
|
183
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.showActivity')) }),
|
|
184
|
+
button({ className: 'btn small' }, 'Scan')
|
|
185
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.scanCollection')) }),
|
|
186
|
+
button({ className: 'btn small' }, 'Reindex')
|
|
187
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.rebuildIndex')) }),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { dd } from '../../runtime.js';
|
|
15
15
|
import { icon } from '../icon.js';
|
|
16
|
+
import { ExecCommandAction } from '../../bl/actions.js';
|
|
16
17
|
|
|
17
18
|
const { div, h2, p, button, span } = dd;
|
|
18
19
|
|
|
@@ -44,7 +45,7 @@ function createPrompt(ui) {
|
|
|
44
45
|
+ 'there is somewhere to put it.'),
|
|
45
46
|
div({ className: 'gate-actions' },
|
|
46
47
|
button({ className: 'btn primary' }, icon('plus', { size: 14 }), 'New collection')
|
|
47
|
-
.on({ click: () => ui.
|
|
48
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.create')) }),
|
|
48
49
|
),
|
|
49
50
|
);
|
|
50
51
|
}
|
|
@@ -69,12 +70,12 @@ function choosePrompt(ex, ui) {
|
|
|
69
70
|
// What they may do, from the server's own answer — so a read-only collection says
|
|
70
71
|
// so before they open it and find the Upload button missing.
|
|
71
72
|
span({ className: 'gate-caps' }, (c.capabilities || []).join(' · ')),
|
|
72
|
-
).on({ click: () => ui.
|
|
73
|
+
).on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.switch', c.id)) })),
|
|
73
74
|
),
|
|
74
75
|
ex.canCreateCollection
|
|
75
76
|
? div({ className: 'gate-actions' },
|
|
76
77
|
button({ className: 'btn' }, icon('plus', { size: 14 }), 'New collection')
|
|
77
|
-
.on({ click: () => ui.
|
|
78
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('collections.create')) }),
|
|
78
79
|
)
|
|
79
80
|
: null,
|
|
80
81
|
);
|