@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
|
@@ -7,29 +7,45 @@
|
|
|
7
7
|
import { dd } from '../../runtime.js';
|
|
8
8
|
import { icon } from '../icon.js';
|
|
9
9
|
import { bytes as fmtBytes } from '../format.js';
|
|
10
|
+
import { draftFor } from '../../bl/viewState.js';
|
|
11
|
+
import { CloseDialogAction, SetViewStateAction } from '../../bl/actions.js';
|
|
12
|
+
import { describeTrust } from '../../bl/trust.js';
|
|
10
13
|
|
|
11
14
|
const { div, span, button, h3, p, label, input } = dd;
|
|
12
15
|
|
|
13
|
-
//
|
|
14
|
-
|
|
16
|
+
// The ticked capabilities, keyed to the dialog instance. Engine state — it decides which
|
|
17
|
+
// boxes render checked. See bl/viewState.js.
|
|
18
|
+
const KEY = 'pluginReview';
|
|
15
19
|
|
|
16
|
-
export function pluginReview(d, ui) {
|
|
17
|
-
const wb = ui.platform.workbench;
|
|
20
|
+
export function pluginReview(d, ui, view) {
|
|
18
21
|
const s = d.summary;
|
|
19
|
-
|
|
22
|
+
// Derived, not written: the default used to be installed during the render that noticed
|
|
23
|
+
// the dialog had changed, which made rendering a side-effecting operation.
|
|
24
|
+
const sel = draftFor(view, KEY, d, {
|
|
20
25
|
// Default: request everything the user is allowed to grant.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
grants: s.capabilities.filter((c) => !c.adminOnly || d.isAdmin).map((c) => c.id),
|
|
27
|
+
// Never defaulted true. An acknowledgement that arrives pre-ticked is not one.
|
|
28
|
+
acknowledged: false,
|
|
29
|
+
});
|
|
30
|
+
const policy = d.policy || { requiresAcknowledgement: false };
|
|
31
|
+
// Install stays out of reach until the risk has actually been acknowledged. A package
|
|
32
|
+
// this applies to is unsigned — see installPolicyFor.
|
|
33
|
+
const blocked = policy.requiresAcknowledgement && !sel.acknowledged;
|
|
34
|
+
// A new array rather than a mutated Set: the write has to be a different value for the
|
|
35
|
+
// cell to see it as a change at all.
|
|
36
|
+
const patch = (fields) => ui.engine.dispatch(new SetViewStateAction(KEY, { ...sel, ...fields }));
|
|
37
|
+
const toggle = (cap) => patch({
|
|
38
|
+
grants: sel.grants.includes(cap) ? sel.grants.filter((c) => c !== cap) : [...sel.grants, cap],
|
|
39
|
+
});
|
|
24
40
|
|
|
25
41
|
return div({},
|
|
26
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
42
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
27
43
|
div({ className: 'dialog review', $styling: { width: 'min(560px, 96vw)' } },
|
|
28
44
|
header(s),
|
|
29
45
|
div({ className: 'review-body' },
|
|
30
46
|
s.description ? p({ className: 'review-desc' }, s.description) : null,
|
|
31
47
|
section('Capabilities it requests', s.capabilities.length
|
|
32
|
-
? div({ className: 'cap-list' }, ...s.capabilities.map((c) => capRow(c, d.isAdmin, sel.grants.
|
|
48
|
+
? div({ className: 'cap-list' }, ...s.capabilities.map((c) => capRow(c, d.isAdmin, sel.grants.includes(c.id), () => toggle(c.id))))
|
|
33
49
|
: muted('None — this plugin only runs in its sandbox.')),
|
|
34
50
|
(s.network || []).length ? section('Network access', div({ className: 'contrib-list' }, ...s.network.map(endpointRow))) : null,
|
|
35
51
|
(s.commands || []).length
|
|
@@ -40,14 +56,38 @@ export function pluginReview(d, ui) {
|
|
|
40
56
|
s.settings.length ? section('Settings', div({ className: 'contrib-list' }, ...s.settings.map(settingRow))) : null,
|
|
41
57
|
div({ className: 'review-meta' }, `${s.fileCount} files · ${fmtBytes(s.sizeBytes)} · id ${s.id}`),
|
|
42
58
|
),
|
|
59
|
+
policy.headline ? trustWarning(policy, sel, patch) : null,
|
|
43
60
|
div({ className: 'row-actions' },
|
|
44
|
-
button({ className: 'btn' }, 'Cancel').on({ click: () =>
|
|
45
|
-
button({ className:
|
|
61
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
|
|
62
|
+
button({ className: `btn primary ${blocked ? 'disabled' : ''}`, disabled: blocked },
|
|
63
|
+
icon('plug', { size: 15 }), 'Install')
|
|
64
|
+
.on({ click: () => !blocked && d.onInstall([...sel.grants]) }),
|
|
46
65
|
),
|
|
47
66
|
),
|
|
48
67
|
);
|
|
49
68
|
}
|
|
50
69
|
|
|
70
|
+
/**
|
|
71
|
+
* What is wrong with this package's provenance, said before the Install button rather than
|
|
72
|
+
* as a badge beside the name.
|
|
73
|
+
*
|
|
74
|
+
* A badge is something you can read past. This sits between the review and the action, and
|
|
75
|
+
* where an acknowledgement is required it holds the button until the box is ticked — which
|
|
76
|
+
* is the difference between telling someone and asking them.
|
|
77
|
+
*/
|
|
78
|
+
function trustWarning(policy, sel, patch) {
|
|
79
|
+
return div({ className: `review-warning ${policy.status}` },
|
|
80
|
+
div({ className: 'rw-head' }, icon('warn', { size: 15 }), span(policy.headline)),
|
|
81
|
+
div({ className: 'rw-detail' }, policy.detail),
|
|
82
|
+
policy.requiresAcknowledgement
|
|
83
|
+
? label({ className: 'rw-ack' },
|
|
84
|
+
input({ type: 'checkbox', checked: !!sel.acknowledged })
|
|
85
|
+
.on({ change: (e) => patch({ acknowledged: !!e.target.checked }) }),
|
|
86
|
+
span('I know this package is unsigned and I trust where it came from'))
|
|
87
|
+
: null,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
51
91
|
function header(s) {
|
|
52
92
|
const t = s.trust || { status: 'unverified' };
|
|
53
93
|
return div({ className: 'review-head' },
|
|
@@ -60,17 +100,16 @@ function header(s) {
|
|
|
60
100
|
);
|
|
61
101
|
}
|
|
62
102
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return span({ className: 'trust unverified', title: t.reason || 'This plugin is not signed' }, icon('warn', { size: 13 }), 'Unverified');
|
|
103
|
+
// The full badge, for the dialog that asks whether to install at all. Same classification
|
|
104
|
+
// as the list's — see bl/trust.js — with room to spell it out.
|
|
105
|
+
function trustBadge(trust) {
|
|
106
|
+
const t = describeTrust(trust);
|
|
107
|
+
const label = t.status === 'verified' && t.domain ? `Verified · ${t.domain}`
|
|
108
|
+
: t.status === 'invalid' ? 'Invalid signature'
|
|
109
|
+
: t.status === 'signed' ? 'Signed'
|
|
110
|
+
: 'Unverified';
|
|
111
|
+
return span({ className: `trust ${t.tone}`, title: t.explanation },
|
|
112
|
+
icon(t.icon, { size: 13 }), label);
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
function capRow(cap, isAdmin, checked, onToggle) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { dd } from '../../runtime.js';
|
|
2
2
|
import { icon } from '../icon.js';
|
|
3
|
+
import { CloseDialogAction, ExecCommandAction, OpenPluginPanelAction, RefreshPluginAction, SetPluginSecretAction, SetSettingAction, ShowDialogAction, UninstallPluginAction } from '../../bl/actions.js';
|
|
4
|
+
import { describeTrust } from '../../bl/trust.js';
|
|
3
5
|
|
|
4
6
|
const { div, span, p, button, h2, input, label } = dd;
|
|
5
7
|
|
|
@@ -13,9 +15,9 @@ export default function pluginsView(state, ui) {
|
|
|
13
15
|
'Plugins are sandboxed packages you install from a file or URL. They run in an isolated iframe and reach Trove only through a message channel, using the capabilities you granted.'),
|
|
14
16
|
div({ className: 'plugin-install' },
|
|
15
17
|
button({ className: 'btn primary' }, icon('upload', { size: 15 }), 'Install from file…')
|
|
16
|
-
.on({ click: () => ui.
|
|
18
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('plugins.installFromFile')) }),
|
|
17
19
|
button({ className: 'btn' }, icon('plug', { size: 15 }), 'Install from URL…')
|
|
18
|
-
.on({ click: () => ui.
|
|
20
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('plugins.installFromUrl')) }),
|
|
19
21
|
),
|
|
20
22
|
plugins.length
|
|
21
23
|
? div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '12px', 'margin-top': '14px' } }, ...plugins.map((pl) => installedCard(pl, state, ui)))
|
|
@@ -52,60 +54,60 @@ function installedCard(pl, state, ui) {
|
|
|
52
54
|
: div({ className: 'muted', $styling: { 'font-size': '12px' } }, pl.responsive ? 'No contributions announced.' : 'No manifest received — the plugin may not be running.'),
|
|
53
55
|
)
|
|
54
56
|
: null,
|
|
55
|
-
(pl.settingsSchema || []).length ? settingsSection(pl, ui) : null,
|
|
57
|
+
(pl.settingsSchema || []).length ? settingsSection(pl, ui, state) : null,
|
|
56
58
|
div({ className: 'actions' },
|
|
57
|
-
pl.hasUi ? button({ className: 'btn' }, icon('command', { size: 14 }), 'Open panel').on({ click: () => ui.
|
|
58
|
-
button({ className: 'btn' }, icon('refresh', { size: 14 }), 'Refresh').on({ click: () => ui.
|
|
59
|
+
pl.hasUi ? button({ className: 'btn' }, icon('command', { size: 14 }), 'Open panel').on({ click: () => ui.engine.dispatch(new OpenPluginPanelAction(pl.id)) }) : null,
|
|
60
|
+
button({ className: 'btn' }, icon('refresh', { size: 14 }), 'Refresh').on({ click: () => ui.engine.dispatch(new RefreshPluginAction(pl.id)) }),
|
|
59
61
|
button({ className: 'btn danger' }, 'Uninstall').on({
|
|
60
|
-
click: () => ui.
|
|
62
|
+
click: () => ui.engine.dispatch(new ShowDialogAction({
|
|
61
63
|
kind: 'confirm', title: `Uninstall ${pl.name}?`, danger: true, confirmLabel: 'Uninstall',
|
|
62
64
|
body: 'The plugin and all data it stored will be removed.',
|
|
63
|
-
|
|
64
|
-
}),
|
|
65
|
+
confirmActions: [new UninstallPluginAction(pl.id)],
|
|
66
|
+
})),
|
|
65
67
|
}),
|
|
66
68
|
),
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
function settingsSection(pl, ui) {
|
|
72
|
+
function settingsSection(pl, ui, state) {
|
|
71
73
|
return div({ className: 'plugin-features' },
|
|
72
74
|
div({ className: 'pf-head' }, span('Settings')),
|
|
73
75
|
div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '8px' } },
|
|
74
|
-
...pl.settingsSchema.map((s) => settingRow(pl, s, ui)),
|
|
76
|
+
...pl.settingsSchema.map((s) => settingRow(pl, s, ui, state)),
|
|
75
77
|
),
|
|
76
78
|
);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
function settingRow(pl, s, ui) {
|
|
81
|
+
function settingRow(pl, s, ui, state) {
|
|
80
82
|
const key = `${pl.id}.${s.key}`;
|
|
81
|
-
const value = s.secret ? '' : (
|
|
83
|
+
const value = s.secret ? '' : (state.settings?.[key] ?? s.default ?? '');
|
|
82
84
|
return div({ className: 'setting', $styling: { padding: '6px 0' } },
|
|
83
85
|
div({ className: 'info' }, div({ className: 't' }, s.title || s.key), s.description ? div({ className: 'd' }, s.description) : null),
|
|
84
86
|
div({ className: 'control' },
|
|
85
87
|
s.secret
|
|
86
88
|
? input({ className: 'input', type: 'password', placeholder: 'Set secret…' }).on({
|
|
87
|
-
change: (e) => { if (e.target.value) { ui.
|
|
89
|
+
change: (e) => { if (e.target.value) { ui.engine.dispatch(new SetPluginSecretAction(pl.id, s.key, e.target.value)); e.target.value = ''; e.target.placeholder = 'Saved ✓'; } },
|
|
88
90
|
})
|
|
89
91
|
: s.type === 'boolean'
|
|
90
|
-
? label({ className: 'switch' }, input({ type: 'checkbox', checked: !!value }).on({ change: (e) => ui.
|
|
91
|
-
: input({ className: 'input', value }).on({ change: (e) => ui.
|
|
92
|
+
? label({ className: 'switch' }, input({ type: 'checkbox', checked: !!value }).on({ change: (e) => ui.engine.dispatch(new SetSettingAction(key, e.target.checked)) }), span({ className: 'track' }))
|
|
93
|
+
: input({ className: 'input', value }).on({ change: (e) => ui.engine.dispatch(new SetSettingAction(key, e.target.value)) }),
|
|
92
94
|
),
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
98
|
+
// The compact badge. WHAT the signature means is decided in bl/trust.js — this only
|
|
99
|
+
// chooses how few characters to say it in, which is the part that is genuinely a question
|
|
100
|
+
// about a crowded row. The two badges used to be two `if` ladders maintained separately,
|
|
101
|
+
// and this one was missing the branch that distinguishes a tampered package from an
|
|
102
|
+
// unsigned one.
|
|
103
|
+
function trustBadge(trust) {
|
|
104
|
+
if (!trust) return null;
|
|
105
|
+
const t = describeTrust(trust);
|
|
106
|
+
const label = t.status === 'verified' ? t.domain
|
|
107
|
+
: t.status === 'invalid' ? 'invalid signature'
|
|
108
|
+
: t.status;
|
|
109
|
+
return span({ className: `trust ${t.tone}`, title: t.explanation },
|
|
110
|
+
icon(t.icon, { size: 12 }), label);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
const KIND_ICON = { command: 'command', opener: 'file', indexer: 'search', statusItem: 'info' };
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { dd } from '../../runtime.js';
|
|
2
|
-
import {
|
|
2
|
+
import { eventToKey } from '../../platform/keybindings.js';
|
|
3
3
|
import { icon } from '../icon.js';
|
|
4
|
-
import {
|
|
4
|
+
import { region } from '../region.js';
|
|
5
|
+
import * as q from '../../bl/queries.js';
|
|
6
|
+
import { rotationFor } from '../../bl/queries.js';
|
|
7
|
+
import { watchQuery } from '../../bl/watchQuery.js';
|
|
8
|
+
import { BeginRotationAction, CancelRotationAction, CopyTextAction, ExecCommandAction, PatchApiKeyDraftAction, RebindKeyAction, RememberOpenerAction, SetSettingAction, SetViewStateAction, ToggleApiKeyCapAction } from '../../bl/actions.js';
|
|
5
9
|
|
|
6
10
|
const { div, h2, h3, p, span, select, option, input, label, button, ul, li, code } = dd;
|
|
7
11
|
|
|
8
12
|
export default function settingsView(state, ui) {
|
|
9
|
-
const groups =
|
|
13
|
+
const groups = state.settingsGroups || new Map();
|
|
10
14
|
return div({ className: 'editor' },
|
|
11
15
|
div({ className: 'stage' },
|
|
12
16
|
div({ className: 'settings' },
|
|
13
17
|
h2('Settings'),
|
|
14
18
|
p({ className: 'sub' }, 'Preferences are stored in this browser. Plugins contribute their own settings here too.'),
|
|
15
19
|
...groups.map((g) => group(g, ui)),
|
|
16
|
-
mcpSection(ui),
|
|
20
|
+
mcpSection(ui, state.caps),
|
|
21
|
+
encryptionSection(state, ui),
|
|
17
22
|
apiKeysSection(state, ui),
|
|
18
|
-
openersSection(ui),
|
|
23
|
+
openersSection(state.assoc, ui),
|
|
19
24
|
keybindingsSection(ui),
|
|
20
25
|
),
|
|
21
26
|
),
|
|
@@ -40,7 +45,7 @@ function setting(s, ui) {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function control(s, ui) {
|
|
43
|
-
const set = (v) => ui.
|
|
48
|
+
const set = (v) => ui.engine.dispatch(new SetSettingAction(s.key, v));
|
|
44
49
|
if (s.type === 'boolean') {
|
|
45
50
|
return label({ className: 'switch' },
|
|
46
51
|
input({ type: 'checkbox', checked: !!s.value }).on({ change: (e) => set(e.target.checked) }),
|
|
@@ -87,8 +92,7 @@ function control(s, ui) {
|
|
|
87
92
|
//
|
|
88
93
|
// It reads straight off `capabilities`, which the app already fetched at startup, so
|
|
89
94
|
// there is no loading state and nothing to go stale.
|
|
90
|
-
function mcpSection(ui) {
|
|
91
|
-
const caps = ui.platform.capabilities;
|
|
95
|
+
function mcpSection(ui, caps) {
|
|
92
96
|
if (!caps) return null; // capabilities never arrived; the shell has bigger problems
|
|
93
97
|
const mcp = caps.mcp;
|
|
94
98
|
const auth = caps.auth || { authorizationServers: [], source: 'none' };
|
|
@@ -97,13 +101,9 @@ function mcpSection(ui) {
|
|
|
97
101
|
p({ className: 'sub' }, 'MCP is switched off on this server. Set TROVE_MCP=on to enable it.'));
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Clipboard access is denied often enough that failing silently would look like a
|
|
104
|
-
// broken button; showing the value still gets it into the user's hands.
|
|
105
|
-
.catch(() => ui.platform.notifications.info(text, { sticky: true }));
|
|
106
|
-
};
|
|
104
|
+
// The "denied clipboard still has to hand the value over" reasoning moved into
|
|
105
|
+
// CopyTextAction, which is where the other three copies of it went too.
|
|
106
|
+
const copy = (text) => () => ui.engine.dispatch(new CopyTextAction(text));
|
|
107
107
|
|
|
108
108
|
const servers = auth.authorizationServers || [];
|
|
109
109
|
// Where the value came from matters when it is wrong: "we took this from your JWT
|
|
@@ -192,7 +192,7 @@ function apiKeysSection(state, ui) {
|
|
|
192
192
|
// sessions never need it, so it is not worth a request at boot.
|
|
193
193
|
if (!keysRequested && !keys.loaded && !keys.loading) {
|
|
194
194
|
keysRequested = true;
|
|
195
|
-
ui.
|
|
195
|
+
ui.engine.dispatch(new ExecCommandAction('keys.load'));
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
// A non-admin gets a 403 here, which is the correct answer rather than an error worth
|
|
@@ -215,7 +215,7 @@ function apiKeysSection(state, ui) {
|
|
|
215
215
|
!keys.draft && !keys.minted
|
|
216
216
|
? div({ className: 'keys-actions' },
|
|
217
217
|
button({ className: 'btn' }, icon('plus', { size: 14 }), 'New key')
|
|
218
|
-
.on({ click: () => ui.
|
|
218
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.new')) }))
|
|
219
219
|
: null,
|
|
220
220
|
|
|
221
221
|
keys.keys?.length
|
|
@@ -232,11 +232,7 @@ function apiKeysSection(state, ui) {
|
|
|
232
232
|
* table would be dismissed without being copied.
|
|
233
233
|
*/
|
|
234
234
|
function mintedBanner(minted, ui) {
|
|
235
|
-
const copy = () =>
|
|
236
|
-
navigator.clipboard?.writeText(minted.secret)
|
|
237
|
-
.then(() => ui.platform.notifications.success('Key copied'))
|
|
238
|
-
.catch(() => ui.platform.notifications.info(minted.secret, { sticky: true }));
|
|
239
|
-
};
|
|
235
|
+
const copy = () => ui.engine.dispatch(new CopyTextAction(minted.secret, 'Key copied'));
|
|
240
236
|
return div({ className: 'key-minted' },
|
|
241
237
|
div({ className: 'key-minted-head' },
|
|
242
238
|
icon('warn', { size: 15 }),
|
|
@@ -250,14 +246,16 @@ function mintedBanner(minted, ui) {
|
|
|
250
246
|
button({ className: 'btn small', title: 'Copy' }, icon('link', { size: 13 })).on({ click: copy }),
|
|
251
247
|
),
|
|
252
248
|
div({ className: 'keys-actions' },
|
|
253
|
-
button({ className: 'btn' }, 'Done').on({ click: () => ui.
|
|
249
|
+
button({ className: 'btn' }, 'Done').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.dismissMinted')) }),
|
|
254
250
|
),
|
|
255
251
|
);
|
|
256
252
|
}
|
|
257
253
|
|
|
258
254
|
function draftForm(keys, collections, ui) {
|
|
259
255
|
const draft = keys.draft;
|
|
260
|
-
|
|
256
|
+
// Resolved by the apiKeys view: what this draft would actually grant, or null when it
|
|
257
|
+
// would grant nothing.
|
|
258
|
+
const scopes = keys.draftScopes;
|
|
261
259
|
const ready = !!draft.name.trim() && !!scopes;
|
|
262
260
|
|
|
263
261
|
return div({ className: 'key-form' },
|
|
@@ -268,7 +266,7 @@ function draftForm(keys, collections, ui) {
|
|
|
268
266
|
),
|
|
269
267
|
div({ className: 'control' },
|
|
270
268
|
input({ className: 'input', value: draft.name, $attrs: { placeholder: 'CI uploader' } })
|
|
271
|
-
.on({ input: (e) => ui.
|
|
269
|
+
.on({ input: (e) => ui.engine.dispatch(new PatchApiKeyDraftAction({ name: e.target.value })) }),
|
|
272
270
|
),
|
|
273
271
|
),
|
|
274
272
|
|
|
@@ -281,7 +279,7 @@ function draftForm(keys, collections, ui) {
|
|
|
281
279
|
input({
|
|
282
280
|
className: 'input', type: 'number', value: draft.expiresInDays,
|
|
283
281
|
$attrs: { min: '1', max: '3650', placeholder: 'never' },
|
|
284
|
-
}).on({ input: (e) => ui.
|
|
282
|
+
}).on({ input: (e) => ui.engine.dispatch(new PatchApiKeyDraftAction({ expiresInDays: e.target.value })) }),
|
|
285
283
|
),
|
|
286
284
|
),
|
|
287
285
|
|
|
@@ -298,8 +296,8 @@ function draftForm(keys, collections, ui) {
|
|
|
298
296
|
div({ className: 'keys-actions' },
|
|
299
297
|
button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } },
|
|
300
298
|
keys.busy === 'mint' ? 'Creating\u2026' : 'Create key')
|
|
301
|
-
.on({ click: () => ready && ui.
|
|
302
|
-
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.
|
|
299
|
+
.on({ click: () => ready && ui.engine.dispatch(new ExecCommandAction('keys.mint')) }),
|
|
300
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.cancel')) }),
|
|
303
301
|
),
|
|
304
302
|
);
|
|
305
303
|
}
|
|
@@ -314,7 +312,7 @@ function scopeRow(collection, draft, ui) {
|
|
|
314
312
|
div({ className: 'key-scope-caps' },
|
|
315
313
|
...CAPS.map((c) => label({ className: `cap ${held.has(c.id) ? 'on' : ''}`, title: c.hint },
|
|
316
314
|
input({ type: 'checkbox', checked: held.has(c.id) })
|
|
317
|
-
.on({ change: () => ui.
|
|
315
|
+
.on({ change: () => ui.engine.dispatch(new ToggleApiKeyCapAction(collection.id, c.id)) }),
|
|
318
316
|
span(c.label),
|
|
319
317
|
)),
|
|
320
318
|
),
|
|
@@ -339,7 +337,7 @@ function keyRow(k, keys, collections, ui) {
|
|
|
339
337
|
? button({
|
|
340
338
|
className: 'btn small danger',
|
|
341
339
|
$attrs: keys.busy === k.id ? { disabled: 'true' } : {},
|
|
342
|
-
}, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.
|
|
340
|
+
}, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.revoke', k.id)) })
|
|
343
341
|
: null,
|
|
344
342
|
);
|
|
345
343
|
}
|
|
@@ -366,8 +364,96 @@ function metaLine(k) {
|
|
|
366
364
|
return bits.join(' \u00b7 ');
|
|
367
365
|
}
|
|
368
366
|
|
|
369
|
-
|
|
370
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The open collection's key: which one it is, and moving it to a new one.
|
|
369
|
+
*
|
|
370
|
+
* Only for an encrypted collection, and only for an admin — a non-admin gets a 403 reading
|
|
371
|
+
* the rotation state, which is the correct answer rather than an error, so the section
|
|
372
|
+
* simply is not drawn.
|
|
373
|
+
*/
|
|
374
|
+
function encryptionSection(state, ui) {
|
|
375
|
+
const collectionId = state.ex?.collectionId;
|
|
376
|
+
const enc = state.ex?.collections?.find((c) => c.id === collectionId)?.encryption;
|
|
377
|
+
if (!enc?.enabled) return null;
|
|
378
|
+
// Watching the query is what LOADS it — its bootAction reads the rotation state and its
|
|
379
|
+
// realization polls while one is running. Nothing fetches while this screen is closed.
|
|
380
|
+
return ui.watch(watchQuery(ui.engine, rotationFor(collectionId)), (rot) => encryptionBody(enc, rot || {}, ui));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function encryptionBody(enc, rot, ui) {
|
|
384
|
+
const running = rot.rotation?.status === 'running';
|
|
385
|
+
const est = rot.estimate;
|
|
386
|
+
|
|
387
|
+
return div({ className: 'group' },
|
|
388
|
+
h3('Encryption'),
|
|
389
|
+
p({ className: 'sub' }, 'Files in this collection are sealed before they leave your browser. The drive holds the key.'),
|
|
390
|
+
|
|
391
|
+
// The only thing that identifies which key the collection is on, and what a sideloaded
|
|
392
|
+
// object is matched against — so it has to be somewhere findable and copyable.
|
|
393
|
+
div({ className: 'setting' },
|
|
394
|
+
div({ className: 'info' },
|
|
395
|
+
div({ className: 't' }, 'Key fingerprint'),
|
|
396
|
+
div({ className: 'd' }, 'Identifies the key this collection is on.'),
|
|
397
|
+
),
|
|
398
|
+
div({ className: 'control' },
|
|
399
|
+
button({ className: 'btn small', title: 'Copy' }, dd.h('code', enc.fingerprint))
|
|
400
|
+
.on({ click: () => ui.engine.dispatch(new CopyTextAction(enc.fingerprint, 'Fingerprint copied')) }),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
|
|
404
|
+
div({ className: 'setting' },
|
|
405
|
+
div({ className: 'info' },
|
|
406
|
+
div({ className: 't' }, running ? 'Rotating the key' : 'Rotate the key'),
|
|
407
|
+
div({ className: 'd' }, running
|
|
408
|
+
? `${rot.rotation.moved} moved${rot.rotation.failed ? `, ${rot.rotation.failed} failed` : ''} — this continues in the background.`
|
|
409
|
+
: rotationCost(est)),
|
|
410
|
+
),
|
|
411
|
+
div({ className: 'control' },
|
|
412
|
+
running
|
|
413
|
+
? button({ className: 'btn small', disabled: !!rot.busy }, 'Stop')
|
|
414
|
+
.on({ click: () => ui.engine.dispatch(new CancelRotationAction()) })
|
|
415
|
+
: button({ className: 'btn small', disabled: !!rot.busy }, 'Rotate')
|
|
416
|
+
.on({ click: () => ui.engine.dispatch(new BeginRotationAction()) }),
|
|
417
|
+
),
|
|
418
|
+
),
|
|
419
|
+
running
|
|
420
|
+
? null
|
|
421
|
+
// Said before it is started rather than after: what has moved stays moved, so this
|
|
422
|
+
// is not a job that can be undone by regretting it.
|
|
423
|
+
: div({},
|
|
424
|
+
rotationBill(est),
|
|
425
|
+
p({ className: 'sub' }, 'Every file is re-sealed under a new key and the old one is retired once nothing is left on it. Files stay readable throughout.'),
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* What a rotation would cost, before anyone starts one.
|
|
432
|
+
*
|
|
433
|
+
* The server already composes this in words — it knows the provider and its pricing, and
|
|
434
|
+
* whether anyone is billed at all — so this shows what it said rather than recomputing a
|
|
435
|
+
* worse version from a file count. `applicable` is false for a store nobody charges for,
|
|
436
|
+
* where the only cost is time.
|
|
437
|
+
*/
|
|
438
|
+
function rotationCost(est) {
|
|
439
|
+
if (!est) return 'Re-seals every file under a fresh key.';
|
|
440
|
+
return est.summary || 'Re-seals every file under a fresh key.';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The itemised estimate, when the provider charges for the traffic. */
|
|
444
|
+
function rotationBill(est) {
|
|
445
|
+
if (!est?.applicable || !(est.lines || []).length) return null;
|
|
446
|
+
return div({ className: 'rot-bill' },
|
|
447
|
+
...est.lines.map((l) => div({ className: 'rb-line' },
|
|
448
|
+
span(l.label || l.name || ''), span({ className: 'mono' }, l.amount || l.value || ''))),
|
|
449
|
+
est.total ? div({ className: 'rb-line total' }, span('Estimated total'), span({ className: 'mono' }, est.total)) : null,
|
|
450
|
+
// Prices move and this is a guess against a snapshot of them; saying when stops it
|
|
451
|
+
// reading as a quote.
|
|
452
|
+
est.asOf ? div({ className: 'rb-asof' }, `Prices as of ${est.asOf}.`) : null,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function openersSection(rows, ui) {
|
|
371
457
|
return div({ className: 'group' },
|
|
372
458
|
h3('Default Openers'),
|
|
373
459
|
rows.length
|
|
@@ -380,7 +466,7 @@ function openersSection(ui) {
|
|
|
380
466
|
),
|
|
381
467
|
div({ className: 'control' },
|
|
382
468
|
button({ className: 'iconbtn', title: 'Forget this default' }, icon('close', { size: 14 }))
|
|
383
|
-
.on({ click: () =>
|
|
469
|
+
.on({ click: () => ui.engine.dispatch(new RememberOpenerAction(r.typeKey, null)) }),
|
|
384
470
|
),
|
|
385
471
|
),
|
|
386
472
|
),
|
|
@@ -389,21 +475,31 @@ function openersSection(ui) {
|
|
|
389
475
|
);
|
|
390
476
|
}
|
|
391
477
|
|
|
392
|
-
// Which shortcut is
|
|
393
|
-
//
|
|
394
|
-
|
|
478
|
+
// Which shortcut is listening for its new chord. Transient, and still engine state: it
|
|
479
|
+
// decides whether a row renders a `<kbd>` or "Press keys…". See bl/viewState.js.
|
|
480
|
+
const CAPTURE = 'keybindingCapture';
|
|
395
481
|
|
|
482
|
+
// Its own region, over the keybindings view and the capture state.
|
|
483
|
+
//
|
|
484
|
+
// Everything a row needs is decided by the query — the command's title, whether the user
|
|
485
|
+
// changed it, and whether another command answers to the same chord. That last one is not
|
|
486
|
+
// something a row could work out for itself: it needs the whole list at once.
|
|
487
|
+
//
|
|
488
|
+
// Rebinding dispatches with the binding ID. The component has never held a binding object,
|
|
489
|
+
// only a description of one, which is the point.
|
|
490
|
+
let kbRegion = null;
|
|
396
491
|
function keybindingsSection(ui) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
492
|
+
// Built once. A fresh region per render would rebuild its alias every pass, and dodo
|
|
493
|
+
// identifies an alias by the function — see ui/region.js.
|
|
494
|
+
kbRegion ??= region(ui.engine, { bindings: q.keybindings, view: q.viewState },
|
|
495
|
+
(st) => keybindingRows(st.bindings || [], ui, st.view?.[CAPTURE] ?? null));
|
|
496
|
+
return kbRegion();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function keybindingRows(bindings, ui, capturing) {
|
|
500
|
+
const setCapturing = (id) => ui.engine.dispatch(new SetViewStateAction(CAPTURE, id));
|
|
501
|
+
const stop = () => setCapturing(null);
|
|
502
|
+
const rebind = (bindingId, key) => ui.engine.dispatch(new RebindKeyAction(bindingId, key));
|
|
407
503
|
return div({ className: 'group' },
|
|
408
504
|
h3('Keyboard Shortcuts'),
|
|
409
505
|
p({ className: 'sub' }, 'Click a shortcut to record a new one. Esc cancels; Backspace clears it.'),
|
|
@@ -411,41 +507,38 @@ function keybindingsSection(ui) {
|
|
|
411
507
|
// more" anywhere, the shortcuts past it simply did not exist as far as anyone could
|
|
412
508
|
// tell, and one plugin keymap was enough to push real ones off the end.
|
|
413
509
|
...bindings.map((b) => {
|
|
414
|
-
const cmd = cmds.get(b.command);
|
|
415
510
|
const listening = capturing === b.bindingId;
|
|
416
|
-
const custom = !!overrides[b.bindingId];
|
|
417
|
-
const clash = byKey.get(b.key) > 1;
|
|
418
511
|
return div({ className: 'setting' },
|
|
419
512
|
div({ className: 'info' },
|
|
420
|
-
div({ className: 't' },
|
|
513
|
+
div({ className: 't' }, b.title),
|
|
421
514
|
div({ className: 'd' }, b.command),
|
|
422
515
|
),
|
|
423
516
|
div({ className: 'control' },
|
|
424
|
-
clash && !listening
|
|
517
|
+
b.clash && !listening
|
|
425
518
|
? span({ className: 'kbd-clash', title: 'Another command answers to this shortcut too — the one registered last wins' },
|
|
426
519
|
icon('warn', { size: 12 }))
|
|
427
520
|
: null,
|
|
428
|
-
button({ className: `kbd-edit ${listening ? 'listening' : ''} ${clash ? 'clash' : ''}`, title: listening ? 'Press the new shortcut' : 'Click to rebind' },
|
|
429
|
-
listening ? span('Press keys…') : dd.h('kbd',
|
|
521
|
+
button({ className: `kbd-edit ${listening ? 'listening' : ''} ${b.clash ? 'clash' : ''}`, title: listening ? 'Press the new shortcut' : 'Click to rebind' },
|
|
522
|
+
listening ? span('Press keys…') : dd.h('kbd', b.label))
|
|
430
523
|
.on({
|
|
431
|
-
click: () =>
|
|
524
|
+
click: () => setCapturing(listening ? null : b.bindingId),
|
|
432
525
|
blur: () => { if (listening) stop(); },
|
|
433
526
|
keydown: (e) => {
|
|
434
527
|
if (!listening) return;
|
|
435
528
|
e.preventDefault();
|
|
436
529
|
e.stopPropagation();
|
|
437
530
|
if (e.key === 'Escape') return stop();
|
|
438
|
-
if (e.key === 'Backspace') {
|
|
531
|
+
if (e.key === 'Backspace') { rebind(b.bindingId, null); return stop(); }
|
|
439
532
|
// A bare modifier isn't a chord yet — wait for the key it modifies.
|
|
440
533
|
if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) return;
|
|
441
|
-
|
|
534
|
+
rebind(b.bindingId, eventToKey(e));
|
|
442
535
|
stop();
|
|
443
536
|
},
|
|
444
537
|
$attach: (el) => { if (listening) queueMicrotask(() => el.focus()); },
|
|
445
538
|
}),
|
|
446
|
-
custom
|
|
539
|
+
b.custom
|
|
447
540
|
? button({ className: 'c-link', title: 'Back to the default' }, 'reset')
|
|
448
|
-
.on({ click: () => {
|
|
541
|
+
.on({ click: () => { rebind(b.bindingId, null); setCapturing(null); } })
|
|
449
542
|
: null,
|
|
450
543
|
),
|
|
451
544
|
);
|