@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
|
@@ -4,31 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
import { dd } from '../../runtime.js';
|
|
6
6
|
import { icon, iconForNode } from '../icon.js';
|
|
7
|
-
import {
|
|
8
|
-
import { OpenFileAction, QuickOpenAction } from '../../bl/actions.js';
|
|
7
|
+
import { ClosePaletteAction, ExecCommandAction, MovePaletteAction, OpenFileAction, QuickOpenAction, SetPaletteIndexAction, SetPaletteQueryAction } from '../../bl/actions.js';
|
|
9
8
|
|
|
10
9
|
const { div, input, span } = dd;
|
|
11
10
|
|
|
12
11
|
export default function commandPalette(state, ui) {
|
|
13
12
|
const pal = state.overlay.palette;
|
|
14
13
|
if (!pal) return null;
|
|
15
|
-
const wb = ui.platform.workbench;
|
|
16
14
|
// With no query there is nothing to show — and showing the PREVIOUS session's hits
|
|
17
15
|
// would be worse than nothing, since they don't match what the field now says.
|
|
16
|
+
// Already ranked — see the `paletteMatches` query. The palette used to score and sort
|
|
17
|
+
// here, with its own fuzzy algorithm that disagreed with the launcher's.
|
|
18
18
|
const items = pal.mode === 'files'
|
|
19
19
|
? (pal.query.trim() ? state.se.paletteFiles || [] : [])
|
|
20
|
-
:
|
|
20
|
+
: state.matches || [];
|
|
21
21
|
const index = Math.min(pal.index, Math.max(0, items.length - 1));
|
|
22
22
|
|
|
23
23
|
const run = (item) => {
|
|
24
24
|
if (!item) return;
|
|
25
|
-
|
|
26
|
-
if (pal.mode === 'files') ui.
|
|
27
|
-
else ui.
|
|
25
|
+
ui.engine.dispatch(new ClosePaletteAction());
|
|
26
|
+
if (pal.mode === 'files') ui.engine.dispatch(new OpenFileAction(item.node));
|
|
27
|
+
else ui.engine.dispatch(new ExecCommandAction(item.id));
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
return div({},
|
|
31
|
-
div({ className: 'scrim' }).on({ click: () =>
|
|
31
|
+
div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new ClosePaletteAction()) }),
|
|
32
32
|
div({ className: 'palette' },
|
|
33
33
|
div({ className: 'search' },
|
|
34
34
|
icon(pal.mode === 'files' ? 'search' : 'command', { size: 18 }),
|
|
@@ -54,7 +54,7 @@ export default function commandPalette(state, ui) {
|
|
|
54
54
|
div({ className: 'results' },
|
|
55
55
|
items.length
|
|
56
56
|
? items.map((item, i) => {
|
|
57
|
-
const hover = () =>
|
|
57
|
+
const hover = () => ui.engine.dispatch(new SetPaletteIndexAction(i));
|
|
58
58
|
return pal.mode === 'files' ? fileOpt(item, i === index, run, hover) : cmdOpt(item, i === index, ui, run, hover);
|
|
59
59
|
})
|
|
60
60
|
: emptyResults(pal, state),
|
|
@@ -72,23 +72,19 @@ function emptyResults(pal, state) {
|
|
|
72
72
|
return div({ className: 'none' }, pal.query.trim() ? 'No files found' : 'Type to search files by name');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Everything here arrives decided — see the paletteCommands view query. This used to ask
|
|
76
|
+
// the keybinding service for a label, then throw the label away and re-resolve the raw key
|
|
77
|
+
// to prettify it again, which was two lookups to render one `<kbd>`.
|
|
75
78
|
function cmdOpt(cmd, active, ui, run, hover) {
|
|
76
|
-
|
|
77
|
-
const available = ui.platform.commands.isAvailable(cmd);
|
|
78
|
-
return div({ className: `opt ${active ? 'active' : ''} ${available ? '' : 'unavailable'}` },
|
|
79
|
+
return div({ className: `opt ${active ? 'active' : ''} ${cmd.available ? '' : 'unavailable'}` },
|
|
79
80
|
span({ className: 'ico' }, icon(cmd.icon || 'command', { size: 16 })),
|
|
80
81
|
cmd.category ? span({ className: 'cat' }, cmd.category + ' ›') : null,
|
|
81
82
|
span({ className: 'title' }, cmd.title),
|
|
82
|
-
!available ? span({ className: 'offline-tag' }, 'offline') : null,
|
|
83
|
-
|
|
83
|
+
!cmd.available ? span({ className: 'offline-tag' }, 'offline') : null,
|
|
84
|
+
cmd.keybinding ? span({ className: 'kbd' }, dd.h('kbd', cmd.keybinding)) : null,
|
|
84
85
|
).on({ click: () => run(cmd), mouseenter: hover });
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function keyRaw(ui, id) {
|
|
88
|
-
const b = ui.platform.keybindings.resolved().find((x) => x.command === id);
|
|
89
|
-
return b ? b.key : '';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
88
|
function fileOpt(item, active, run, hover) {
|
|
93
89
|
return div({ className: `opt ${active ? 'active' : ''}` },
|
|
94
90
|
span({ className: 'ico' }, icon(iconForNode(item.node), { size: 16 })),
|
|
@@ -97,58 +93,31 @@ function fileOpt(item, active, run, hover) {
|
|
|
97
93
|
).on({ click: () => run(item), mouseenter: hover });
|
|
98
94
|
}
|
|
99
95
|
|
|
100
|
-
function filterCommands(state, ui, query) {
|
|
101
|
-
const all = ui.platform.commands.paletteCommands();
|
|
102
|
-
const q = query.trim().toLowerCase();
|
|
103
|
-
if (!q) return all.slice(0, 60);
|
|
104
|
-
const scored = [];
|
|
105
|
-
for (const c of all) {
|
|
106
|
-
const hay = `${c.category || ''} ${c.title}`.toLowerCase();
|
|
107
|
-
const s = fuzzyScore(hay, q);
|
|
108
|
-
if (s > 0) scored.push([s, c]);
|
|
109
|
-
}
|
|
110
|
-
return scored.sort((a, b) => b[0] - a[0]).slice(0, 60).map((x) => x[1]);
|
|
111
|
-
}
|
|
112
96
|
|
|
113
|
-
function fuzzyScore(hay, q) {
|
|
114
|
-
let score = 0;
|
|
115
|
-
let hi = 0;
|
|
116
|
-
for (const ch of q) {
|
|
117
|
-
const idx = hay.indexOf(ch, hi);
|
|
118
|
-
if (idx < 0) return 0;
|
|
119
|
-
score += idx === hi ? 3 : 1;
|
|
120
|
-
hi = idx + 1;
|
|
121
|
-
}
|
|
122
|
-
if (hay.includes(q)) score += 10;
|
|
123
|
-
return score;
|
|
124
|
-
}
|
|
125
97
|
|
|
126
98
|
function onKey(e, ui, items, index, run) {
|
|
127
|
-
const wb = ui.platform.workbench;
|
|
128
99
|
if (e.key === 'ArrowDown') {
|
|
129
100
|
e.preventDefault();
|
|
130
|
-
|
|
101
|
+
ui.engine.dispatch(new MovePaletteAction(1, items.length));
|
|
131
102
|
} else if (e.key === 'ArrowUp') {
|
|
132
103
|
e.preventDefault();
|
|
133
|
-
|
|
104
|
+
ui.engine.dispatch(new MovePaletteAction(-1, items.length));
|
|
134
105
|
} else if (e.key === 'Enter') {
|
|
135
106
|
e.preventDefault();
|
|
136
107
|
run(items[index]);
|
|
137
108
|
} else if (e.key === 'Escape') {
|
|
138
109
|
e.preventDefault();
|
|
139
|
-
|
|
110
|
+
ui.engine.dispatch(new ClosePaletteAction());
|
|
140
111
|
}
|
|
141
112
|
}
|
|
142
113
|
|
|
143
114
|
let fileTimer = null;
|
|
144
115
|
function onQuery(ui, value) {
|
|
145
|
-
|
|
146
|
-
wb.setPaletteQuery(value);
|
|
147
|
-
// The palette's CURRENT mode, not whichever one was on screen when this listener
|
|
148
|
-
// was created — see the note at the input.
|
|
116
|
+
ui.engine.dispatch(new SetPaletteQueryAction(value));
|
|
149
117
|
clearTimeout(fileTimer);
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
// service (state.se.paletteFiles), so the palette re-renders
|
|
153
|
-
|
|
118
|
+
// The mode guard moved into QuickOpenAction, where "current" means current: the timer
|
|
119
|
+
// below outlives the render that set it, so checking here would test a stale mode.
|
|
120
|
+
// Results land in the search service (state.se.paletteFiles), so the palette re-renders
|
|
121
|
+
// without ad-hoc state.
|
|
122
|
+
fileTimer = setTimeout(() => ui.engine.dispatch(new QuickOpenAction(value)), 200);
|
|
154
123
|
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { dd } from '../../runtime.js';
|
|
9
9
|
import { icon, iconForNode } from '../icon.js';
|
|
10
10
|
import { renderOpener } from './openers/index.js';
|
|
11
|
-
import {
|
|
11
|
+
import { openersFor } from '../../bl/openers.js';
|
|
12
|
+
import { NavigateBackAction, OpenInPanelAction, ShowDialogAction, ShowHomeAction, ToggleInfoPanelAction } from '../../bl/actions.js';
|
|
12
13
|
|
|
13
14
|
const { div, span, button } = dd;
|
|
14
15
|
|
|
@@ -21,30 +22,29 @@ export default function editorArea(state, ui) {
|
|
|
21
22
|
const active = files[files.length - 1];
|
|
22
23
|
if (!active) return div({ className: 'editor' });
|
|
23
24
|
return div({ className: 'editor' },
|
|
24
|
-
navBar(files, active, ui),
|
|
25
|
+
navBar(files, active, state, ui),
|
|
25
26
|
div({ className: 'stage' }, openerHost(active, ui)),
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function navBar(files, active, ui) {
|
|
30
|
-
const w = ui.platform.workbench;
|
|
30
|
+
function navBar(files, active, state, ui) {
|
|
31
31
|
return div({ className: 'viewer-nav' },
|
|
32
32
|
button({ className: 'vn-back', title: 'Back (Esc)' }, icon('chevron-left', { size: 16 }))
|
|
33
|
-
.on({ click: () =>
|
|
33
|
+
.on({ click: () => ui.engine.dispatch(new NavigateBackAction()) }),
|
|
34
34
|
div({ className: 'vn-trail' },
|
|
35
35
|
button({ className: 'vn-crumb' }, icon('search', { size: 13 }), span('Search'))
|
|
36
|
-
.on({ click: () =>
|
|
36
|
+
.on({ click: () => ui.engine.dispatch(new ShowHomeAction()) }),
|
|
37
37
|
...files.map((p) =>
|
|
38
38
|
button({ className: `vn-crumb ${p.id === active.id ? 'active' : ''}`, title: p.node.name },
|
|
39
39
|
icon(iconForNode(p.node), { size: 13 }), span({ className: 'label' }, p.node.name))
|
|
40
|
-
.on({ click: () =>
|
|
40
|
+
.on({ click: () => ui.engine.dispatch(new OpenInPanelAction(p.node, p.openerId)) })),
|
|
41
41
|
),
|
|
42
42
|
div({ className: 'vn-actions' },
|
|
43
|
-
openerSwitch(active, ui),
|
|
43
|
+
openerSwitch(active, state, ui),
|
|
44
44
|
button({ className: 'iconbtn', title: 'Details & comments' }, icon('info', { size: 15 }))
|
|
45
|
-
.on({ click: () =>
|
|
45
|
+
.on({ click: () => ui.engine.dispatch(new ToggleInfoPanelAction()) }),
|
|
46
46
|
button({ className: 'iconbtn', title: 'Close (Esc)' }, icon('close', { size: 15 }))
|
|
47
|
-
.on({ click: () =>
|
|
47
|
+
.on({ click: () => ui.engine.dispatch(new ShowHomeAction()) }),
|
|
48
48
|
),
|
|
49
49
|
);
|
|
50
50
|
}
|
|
@@ -52,11 +52,14 @@ function navBar(files, active, ui) {
|
|
|
52
52
|
// "Open with…" — shown only when more than one opener can handle this file. Opens
|
|
53
53
|
// the chooser (pre-selected to the current opener) so the user can switch viewers,
|
|
54
54
|
// and optionally make the choice their default for this file type.
|
|
55
|
-
function openerSwitch(active, ui) {
|
|
56
|
-
|
|
55
|
+
function openerSwitch(active, state, ui) {
|
|
56
|
+
// `state.openers` has already had its when-clauses and plugin health resolved by the
|
|
57
|
+
// query; matching them against THIS file is pure. That split is why this no longer
|
|
58
|
+
// reaches for the contribution registry mid-render.
|
|
59
|
+
const openers = openersFor(state.openers, active.node);
|
|
57
60
|
if (openers.length <= 1) return null;
|
|
58
61
|
return button({ className: 'iconbtn', title: 'Open with…' }, icon('dots', { size: 15 }))
|
|
59
|
-
.on({ click: () => ui.
|
|
62
|
+
.on({ click: () => ui.engine.dispatch(new ShowDialogAction({ kind: 'opener-chooser', node: active.node, openers, current: active.openerId })) });
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function openerHost(panel, ui) {
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { dd } from '../../runtime.js';
|
|
8
8
|
import { icon } from '../icon.js';
|
|
9
|
-
import {
|
|
9
|
+
import { ExecCommandAction, FilterAction, MoveLaunchAction, SearchAction, SelectLaunchAction, SetLaunchIndexAction, SetLaunchQueryAction } from '../../bl/actions.js';
|
|
10
10
|
import { parseTagQuery, filterLabel } from '../../bl/tagQuery.js';
|
|
11
|
-
import {
|
|
11
|
+
import { renderView, viewSwitcher, viewMove } from './views/index.js';
|
|
12
12
|
import { openRowMenu } from './views/parts.js';
|
|
13
|
+
import { activate } from '../activate.js';
|
|
13
14
|
|
|
14
15
|
const { div, span, input, button } = dd;
|
|
15
16
|
|
|
@@ -23,8 +24,8 @@ const ARROWS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
|
|
|
23
24
|
// stops being the right advice. The fallback covers the moment before capabilities
|
|
24
25
|
// arrive and any server too old to say; `!` is ours either way, since running a command
|
|
25
26
|
// from here is a client convention the server knows nothing about.
|
|
26
|
-
function promptFor(
|
|
27
|
-
const p =
|
|
27
|
+
function promptFor(caps, { compact = false, modal = false } = {}) {
|
|
28
|
+
const p = caps?.searchPrompt;
|
|
28
29
|
const base = (compact ? p?.short || p?.placeholder : p?.placeholder)
|
|
29
30
|
|| (compact ? 'Search files' : 'Search files · # filter by tag');
|
|
30
31
|
// On a phone the box is ~300px wide; adding a second clause guarantees an ellipsis
|
|
@@ -36,30 +37,32 @@ let searchTimer = null;
|
|
|
36
37
|
function runSearch(ui, query) {
|
|
37
38
|
// SearchAction owns search state; the launcher only dispatches (no direct .set).
|
|
38
39
|
clearTimeout(searchTimer);
|
|
39
|
-
searchTimer = setTimeout(() => ui.
|
|
40
|
+
searchTimer = setTimeout(() => ui.engine.dispatch(new SearchAction(query)), 240);
|
|
40
41
|
}
|
|
41
42
|
function clearSearch(ui) {
|
|
42
|
-
ui.
|
|
43
|
-
ui.
|
|
43
|
+
ui.engine.dispatch(new SetLaunchQueryAction(''));
|
|
44
|
+
ui.engine.dispatch(new SearchAction('')); // empty query resets results/ran/error in the service
|
|
44
45
|
}
|
|
45
46
|
function runFilter(ui, filters, text) {
|
|
46
47
|
clearTimeout(searchTimer);
|
|
47
|
-
searchTimer = setTimeout(() => ui.
|
|
48
|
+
searchTimer = setTimeout(() => ui.engine.dispatch(new FilterAction(filters, text)), 200);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export default function launcher(state, ui, opts = {}) {
|
|
51
|
-
const wb = ui.platform.workbench;
|
|
52
52
|
const modal = !!opts.modal; // rendered as the double-shift overlay → picks reset the stack
|
|
53
53
|
const q = state.wb.launch.query;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// What is on screen, which view draws it, and whether a search-help offer applies — all
|
|
55
|
+
// decided by the `launcherContent` query. This used to be ~100 lines of derivation right
|
|
56
|
+
// here: which nodes, what each heading says, whether "All items" is a claim the drive
|
|
57
|
+
// cannot support. See bl/launcher.js.
|
|
58
|
+
const content = modal ? state.modalContent : state.content;
|
|
59
|
+
const { groups, mode, view } = content;
|
|
57
60
|
const flat = groups.flatMap((g) => g.items);
|
|
58
61
|
const idx = flat.length ? Math.min(state.wb.launch.index, flat.length - 1) : 0;
|
|
59
62
|
|
|
60
63
|
const onInput = (e) => {
|
|
61
64
|
const v = e.target.value;
|
|
62
|
-
|
|
65
|
+
ui.engine.dispatch(new SetLaunchQueryAction(v));
|
|
63
66
|
if (v.startsWith('!')) return; // command mode: no query dispatch
|
|
64
67
|
const { text, filters } = parseTagQuery(v);
|
|
65
68
|
if (filters.length) runFilter(ui, filters, text); // drive-wide tag/property query
|
|
@@ -75,20 +78,15 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
75
78
|
// move re-renders the list under the pointer, and a row replaced between mousedown and
|
|
76
79
|
// mouseup never receives its click. A pointer user acts through the row's own menu,
|
|
77
80
|
// which selects explicitly before it opens.
|
|
78
|
-
const hoverAt = (at) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// thing whichever view is showing.
|
|
88
|
-
//
|
|
89
|
-
// The search transformer may have suggested one. Only for an actual search: on the
|
|
90
|
-
// home list there is no sentence to have read.
|
|
91
|
-
const view = activeView(ui.platform, flat, mode === 'search' ? state.se.resolved?.view : null);
|
|
81
|
+
const hoverAt = (at) => ui.engine.dispatch(new SetLaunchIndexAction(at));
|
|
82
|
+
// Moving the highlight and selecting what it lands on are one action, not two. The index
|
|
83
|
+
// wraps, so only the store can say where a move ends up — and reading that back out here
|
|
84
|
+
// would read it before the dispatch had applied. See MoveLaunchAction.
|
|
85
|
+
const selectAt = (at) => ui.engine.dispatch(new SelectLaunchAction(at, flat[at]?.node));
|
|
86
|
+
const move = (delta) => ui.engine.dispatch(new MoveLaunchAction(delta, flat.map((i) => i.node)));
|
|
87
|
+
// The view gets a say in what an arrow key means — one row down is one tile down in a
|
|
88
|
+
// grid, not one tile across — but the index, the selection and the wrapping stay here, so
|
|
89
|
+
// up and down mean the same thing whichever view is showing.
|
|
92
90
|
const onKey = (e) => {
|
|
93
91
|
// `textual`: there is text in the box, so left/right are the caret's and no view
|
|
94
92
|
// may claim them. Fixing a typo must not move the highlight.
|
|
@@ -98,7 +96,7 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
98
96
|
if (claimed !== null) { e.preventDefault(); move(claimed); }
|
|
99
97
|
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
|
|
100
98
|
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
|
|
101
|
-
else if (e.key === 'Enter') { e.preventDefault(); flat[idx]
|
|
99
|
+
else if (e.key === 'Enter') { e.preventDefault(); activate(ui, flat[idx]); }
|
|
102
100
|
else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui); }
|
|
103
101
|
// The row under the highlight is the subject of the row menu, so the key that opens
|
|
104
102
|
// one on every other list opens this one too — without making the user leave the
|
|
@@ -123,23 +121,23 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
123
121
|
// `false`, not `'false'`: this is set as a property, and the string is truthy —
|
|
124
122
|
// so the search box had spellcheck ON, red-underlining every filename typed into it.
|
|
125
123
|
input({ className: 'launch-input', value: q, autofocus: true, spellcheck: false,
|
|
126
|
-
placeholder: promptFor(
|
|
124
|
+
placeholder: promptFor(state.caps, { compact: state.vp?.mode === 'phone', modal }) })
|
|
127
125
|
.on({ input: onInput, keydown: onKey }),
|
|
128
126
|
q ? button({ className: 'launch-clear', title: 'Clear' }, icon('close', { size: 14 }))
|
|
129
127
|
.on({ click: () => clearSearch(ui) }) : null,
|
|
130
128
|
// Only where the browser can transcribe WITHOUT sending audio anywhere. A remote's
|
|
131
129
|
// own mic needs no button from us — it dictates into this field once it is focused,
|
|
132
130
|
// which is what `search.voice` is really for.
|
|
133
|
-
|
|
131
|
+
state.voice?.canListen
|
|
134
132
|
? button({
|
|
135
133
|
className: `launch-mic ${state.voice?.listening ? 'on' : ''}`,
|
|
136
134
|
title: state.voice?.listening ? 'Stop listening' : 'Search by voice',
|
|
137
135
|
'aria-pressed': state.voice?.listening ? 'true' : 'false',
|
|
138
|
-
}, icon('mic', { size: 15 })).on({ click: () => ui.
|
|
136
|
+
}, icon('mic', { size: 15 })).on({ click: () => ui.engine.dispatch(new ExecCommandAction('search.voice')) })
|
|
139
137
|
: null,
|
|
140
138
|
// Which way to look at the drive. Not in the modal search, where the answer is
|
|
141
139
|
// always "the one thing I am about to press Enter on".
|
|
142
|
-
modal ? null : viewSwitcher(
|
|
140
|
+
modal ? null : viewSwitcher(state.views, view, ui),
|
|
143
141
|
),
|
|
144
142
|
showResolved ? resolvedBar(resolved) : null,
|
|
145
143
|
// The results belong to the active view — see ui/components/views. The launcher says
|
|
@@ -147,7 +145,7 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
147
145
|
// split is why a gallery is a contribution rather than another branch in here.
|
|
148
146
|
div({ className: 'launch-body' },
|
|
149
147
|
renderView(view, { groups, index: idx, handlers: { hover: hoverAt, select: selectAt }, state, ui }),
|
|
150
|
-
searchHelp(
|
|
148
|
+
searchHelp(content.help, ui),
|
|
151
149
|
),
|
|
152
150
|
);
|
|
153
151
|
return modal ? inner : div({ className: 'editor' }, inner);
|
|
@@ -169,10 +167,6 @@ function resolvedBar(r) {
|
|
|
169
167
|
// The NODE goes along with the id. A search result or a recent can be from a collection
|
|
170
168
|
// that isn't loaded — or a page that isn't — so an id alone is something the explorer
|
|
171
169
|
// cannot resolve, and the commands that act on "the selection" then do nothing at all.
|
|
172
|
-
function syncSelection(ui, item) {
|
|
173
|
-
const node = item?.node;
|
|
174
|
-
ui.app.explorer.select(node?.id ? [node.id] : [], { nodes: node ? [node] : null });
|
|
175
|
-
}
|
|
176
170
|
|
|
177
171
|
/**
|
|
178
172
|
* How this search box works — shown when, and only when, a search came back empty.
|
|
@@ -185,208 +179,28 @@ function syncSelection(ui, item) {
|
|
|
185
179
|
* The text comes from the server's transformer, so it describes the grammar this
|
|
186
180
|
* deployment actually runs rather than the one this file was written against.
|
|
187
181
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!
|
|
192
|
-
const p = ui.platform.capabilities?.searchPrompt;
|
|
193
|
-
if (!p?.hint && !p?.examples?.length) return null;
|
|
194
|
-
const wb = ui.platform.workbench;
|
|
195
|
-
const tryIt = (query) => () => {
|
|
196
|
-
wb.setLaunchQuery(query);
|
|
197
|
-
const { text, filters } = parseTagQuery(query);
|
|
198
|
-
if (filters.length) ui.go(new FilterAction(filters, text));
|
|
199
|
-
else ui.go(new SearchAction(text));
|
|
200
|
-
};
|
|
182
|
+
// Decided by the `launcherContent` query — whether an offer applies, and what the server
|
|
183
|
+
// suggests. This only draws it.
|
|
184
|
+
function searchHelp(help, ui) {
|
|
185
|
+
if (!help) return null;
|
|
201
186
|
return div({ className: 'launch-help' },
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
? div({ className: 'lh-examples' }, ...
|
|
187
|
+
help.hint ? div({ className: 'lh-hint' }, icon('info', { size: 13 }), span(help.hint)) : null,
|
|
188
|
+
help.examples.length
|
|
189
|
+
? div({ className: 'lh-examples' }, ...help.examples.map((ex) =>
|
|
205
190
|
button({ className: 'lh-example', title: ex.label || 'Try this search' },
|
|
206
191
|
span({ className: 'lh-q' }, ex.query),
|
|
207
192
|
ex.label ? span({ className: 'lh-label' }, ex.label) : null,
|
|
208
|
-
).on({ click:
|
|
193
|
+
).on({ click: () => activate(ui, ex) })))
|
|
209
194
|
: null,
|
|
210
195
|
);
|
|
211
196
|
}
|
|
212
197
|
|
|
213
|
-
function buildContent(state, ui, q, mode, modal) {
|
|
214
|
-
const done = () => { if (modal) ui.platform.workbench.closeSearchModal(); };
|
|
215
|
-
if (mode === 'command') {
|
|
216
|
-
const term = q.slice(1).trim().toLowerCase();
|
|
217
|
-
const items = ui.platform.commands.paletteCommands()
|
|
218
|
-
.map((c) => ({ hay: `${c.category || ''} ${c.title}`.toLowerCase(), c }))
|
|
219
|
-
.map(({ hay, c }) => ({ s: score(hay, term), c }))
|
|
220
|
-
.filter((x) => term === '' || x.s > 0)
|
|
221
|
-
.sort((a, b) => b.s - a.s)
|
|
222
|
-
.slice(0, 40)
|
|
223
|
-
.map(({ c }) => ({ icon: 'command', title: c.title, detail: c.category, badge: 'command', run: () => { ui.exec(c.id); done(); } }));
|
|
224
|
-
return [{ title: 'Commands', items, empty: 'No matching commands.' }];
|
|
225
|
-
}
|
|
226
198
|
|
|
227
|
-
const { text, filters } = parseTagQuery(q);
|
|
228
|
-
const nodes = (state.se.results || []).map((r) => r.node);
|
|
229
|
-
const err = state.se.error;
|
|
230
199
|
|
|
231
|
-
// A one-click retry so a transient search/filter failure isn't a dead end.
|
|
232
|
-
const retryBtn = (action) => button({ className: 'launch-up', title: 'Retry' }, icon('refresh', { size: 13 }), 'Retry').on({ click: () => ui.go(action) });
|
|
233
|
-
|
|
234
|
-
// Drive-wide tag/property filter (server-side), optionally narrowed by free text.
|
|
235
|
-
if (filters.length) {
|
|
236
|
-
const label = filters.map(filterLabel).join(' ') + (text.trim() ? ` · "${text.trim()}"` : '');
|
|
237
|
-
return [{
|
|
238
|
-
title: state.se.loading ? 'Filtering…' : err ? 'Filter failed' : 'Filtered',
|
|
239
|
-
// Shown as typed: a tag is a value, not a heading.
|
|
240
|
-
verbatim: state.se.loading || err ? null : label,
|
|
241
|
-
action: err ? retryBtn(new FilterAction(filters, text)) : null,
|
|
242
|
-
items: nodes.map((n) => fileItem(n, ui, modal)),
|
|
243
|
-
empty: state.se.loading ? 'Filtering…' : err ? `Couldn’t filter: ${err}` : 'No files match those filters.',
|
|
244
|
-
}];
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Free-text search.
|
|
248
|
-
if (text.trim()) {
|
|
249
|
-
return [{
|
|
250
|
-
title: state.se.loading ? 'Searching…' : err ? 'Search failed' : 'Results',
|
|
251
|
-
action: err ? retryBtn(new SearchAction(q)) : null,
|
|
252
|
-
items: nodes.map((n) => fileItem(n, ui, modal)),
|
|
253
|
-
empty: state.se.loading ? 'Searching…' : err ? `Couldn’t search: ${err}` : 'No files match.',
|
|
254
|
-
}];
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Home: recents, then everything in the collection. There is nothing to descend
|
|
258
|
-
// into — this is the "show me everything" fallback for when search isn't the answer.
|
|
259
|
-
const groups = [];
|
|
260
|
-
const recents = (state.nav.recents || []).map((r) => fileItem(r, ui, modal));
|
|
261
|
-
if (recents.length) groups.push({ title: 'Recent', items: recents });
|
|
262
|
-
|
|
263
|
-
const ex = state.ex;
|
|
264
|
-
const shown = (ex.items || []).length;
|
|
265
|
-
// `stats` is the collection; `items` is the page. When the server didn't report stats
|
|
266
|
-
// we only know the page — and saying "500" while a next page exists is a claim about
|
|
267
|
-
// the drive that is false, so say "500+" instead.
|
|
268
|
-
const knownTotal = ex.stats?.items ?? null;
|
|
269
|
-
const total = knownTotal ?? shown;
|
|
270
|
-
const totalLabel = knownTotal != null ? knownTotal.toLocaleString() : `${shown.toLocaleString()}+`;
|
|
271
|
-
const items = (ex.items || []).map((n) => fileItem(n, ui, modal));
|
|
272
|
-
// A partial list must not be titled "All items" — that is a claim about the drive,
|
|
273
|
-
// and on a collection bigger than one page it is false. Say what is on screen, and
|
|
274
|
-
// offer the rest rather than leaving it unreachable.
|
|
275
|
-
if (ex.nextCursor) {
|
|
276
|
-
items.push({
|
|
277
|
-
icon: 'refresh',
|
|
278
|
-
title: ex.loadingMore ? 'Loading…'
|
|
279
|
-
: knownTotal != null ? `Show more (${(knownTotal - shown).toLocaleString()} more)` : 'Show more',
|
|
280
|
-
detail: 'or search to jump straight to something',
|
|
281
|
-
run: () => ui.exec('explorer.loadMore'),
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
// The trash, when it has been opened. Not shown by default: it is a place you go to
|
|
285
|
-
// recover a mistake, not part of browsing the drive.
|
|
286
|
-
if (ex.trash) {
|
|
287
|
-
groups.push({
|
|
288
|
-
// One line, not two: a header reading "0 items" above a body reading "the trash is
|
|
289
|
-
// empty" is the same sentence twice, permanently parked above the drive.
|
|
290
|
-
title: ex.trash.length
|
|
291
|
-
? `Trash · ${ex.trash.length} item${ex.trash.length === 1 ? '' : 's'}`
|
|
292
|
-
: 'Trash',
|
|
293
|
-
action: div({ className: 'lh-actions' },
|
|
294
|
-
ex.trash.length
|
|
295
|
-
? button({ className: 'launch-up', title: 'Destroy everything in the trash' },
|
|
296
|
-
icon('trash', { size: 13 }), 'Empty trash').on({ click: () => ui.exec('explorer.emptyTrash') })
|
|
297
|
-
: null,
|
|
298
|
-
// The way back out. Opening the trash used to be one-way until a page reload.
|
|
299
|
-
button({ className: 'launch-up', title: 'Hide the trash' },
|
|
300
|
-
icon('close', { size: 13 }), 'Close').on({ click: () => ui.exec('explorer.hideTrash') }),
|
|
301
|
-
),
|
|
302
|
-
items: ex.trash.length
|
|
303
|
-
? ex.trash.map((n) => ({
|
|
304
|
-
icon: 'trash',
|
|
305
|
-
title: n.name,
|
|
306
|
-
detail: `deleted ${new Date(n.deletedAt).toLocaleString()} — restore`,
|
|
307
|
-
run: () => ui.exec('explorer.restore', n.id),
|
|
308
|
-
menu: () => trashMenu(n, ui),
|
|
309
|
-
}))
|
|
310
|
-
: [],
|
|
311
|
-
empty: 'The trash is empty.',
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
groups.push({
|
|
316
|
-
title: ex.nextCursor ? `All items · showing ${shown.toLocaleString()} of ${totalLabel}` : 'All items',
|
|
317
|
-
// The empty state told people to upload a file, and on desktop and TV there was
|
|
318
|
-
// nothing anywhere that would let them. (Phone has the + in its bottom bar.)
|
|
319
|
-
action: modal ? null : button({ className: 'launch-up', title: 'Upload files to this collection' },
|
|
320
|
-
icon('upload', { size: 13 }), 'Upload').on({ click: () => ui.exec('explorer.upload') }),
|
|
321
|
-
items,
|
|
322
|
-
// Don't show a false "empty" when the load actually FAILED (e.g. server
|
|
323
|
-
// unreachable) — say so, so the user knows to retry rather than believing the
|
|
324
|
-
// collection is empty. (A toast also fires, but the persistent state must be honest.)
|
|
325
|
-
empty: ex.loading ? 'Loading…' : ex.error ? `Couldn’t load this collection: ${ex.error}` : 'Nothing here yet — upload a file to get started.',
|
|
326
|
-
});
|
|
327
|
-
return groups;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function fileItem(node, ui, modal) {
|
|
331
|
-
return {
|
|
332
|
-
icon: fileIcon(node),
|
|
333
|
-
title: node.name,
|
|
334
|
-
detail: node.contentType || '',
|
|
335
|
-
node,
|
|
336
|
-
// From the modal search, `reset` starts a fresh viewer stack; then close it.
|
|
337
|
-
run: () => {
|
|
338
|
-
ui.go(new OpenFileAction(node, { reset: !!modal }));
|
|
339
|
-
if (modal) ui.platform.workbench.closeSearchModal();
|
|
340
|
-
},
|
|
341
|
-
menu: () => fileMenu(node, ui),
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function fileMenu(node, ui) {
|
|
346
|
-
const pinned = (ui.app.offline?.state?.pins || []).some((p) => p.id === node.id);
|
|
347
|
-
// Ask what the shortcut actually IS. Hardcoding "⌘⇧L" told a Windows or Linux user
|
|
348
|
-
// about a key their machine does not have, and told everyone the default even after
|
|
349
|
-
// they had rebound it.
|
|
350
|
-
const kbd = (id) => ui.platform.keybindings.labelFor(id) || undefined;
|
|
351
|
-
return [
|
|
352
|
-
{ label: 'Open', icon: 'file-text', run: () => ui.exec('explorer.open', node) },
|
|
353
|
-
{ label: 'Download', icon: 'download', run: () => ui.exec('explorer.download', node) },
|
|
354
|
-
{ label: 'Copy link', icon: 'link', kbd: kbd('explorer.copyLink'), run: () => ui.exec('explorer.copyLink') },
|
|
355
|
-
{ sep: true },
|
|
356
|
-
{ label: 'Rename…', run: () => ui.exec('explorer.rename') },
|
|
357
|
-
pinned
|
|
358
|
-
? { label: 'Remove from offline', icon: 'close', run: () => ui.exec('offline.unpin', node) }
|
|
359
|
-
: { label: 'Make available offline', icon: 'download', run: () => ui.exec('offline.pin', node) },
|
|
360
|
-
{ sep: true },
|
|
361
|
-
{ label: 'Move to trash', icon: 'trash', danger: true, kbd: kbd('explorer.delete'), run: () => ui.exec('explorer.delete') },
|
|
362
|
-
];
|
|
363
|
-
}
|
|
364
200
|
|
|
365
201
|
// The trash is the one place "delete" means destroy, so its two verbs live together:
|
|
366
202
|
// put it back, or finish the job on this one item. Emptying the whole trash was
|
|
367
203
|
// previously the only way to purge anything.
|
|
368
|
-
function trashMenu(node, ui) {
|
|
369
|
-
return [
|
|
370
|
-
{ label: 'Restore', icon: 'refresh', run: () => ui.exec('explorer.restore', node.id) },
|
|
371
|
-
{ sep: true },
|
|
372
|
-
{ label: 'Delete forever', icon: 'trash', danger: true, run: () => ui.exec('explorer.purgeOne', node.id) },
|
|
373
|
-
];
|
|
374
|
-
}
|
|
375
204
|
|
|
376
|
-
function fileIcon(node) {
|
|
377
|
-
const t = node.contentType || '';
|
|
378
|
-
if (t.startsWith('image/')) return 'file-image';
|
|
379
|
-
if (t.startsWith('audio/')) return 'file-audio';
|
|
380
|
-
if (t.startsWith('video/')) return 'file-video';
|
|
381
|
-
return 'file-text';
|
|
382
|
-
}
|
|
383
205
|
|
|
384
206
|
// Substring-first, then subsequence fuzzy score (0 = no match).
|
|
385
|
-
function score(hay, term) {
|
|
386
|
-
if (!term) return 1;
|
|
387
|
-
const i = hay.indexOf(term);
|
|
388
|
-
if (i >= 0) return 1000 - i;
|
|
389
|
-
let ti = 0;
|
|
390
|
-
for (let hi = 0; hi < hay.length && ti < term.length; hi++) if (hay[hi] === term[ti]) ti++;
|
|
391
|
-
return ti === term.length ? 1 : 0;
|
|
392
|
-
}
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// Each declares a selector (extensions / mime) and a component(node, ui) → vnode.
|
|
3
3
|
// The editor area picks the highest-priority opener whose selector matches.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { cell, dd, watch } from '../../../runtime.js';
|
|
6
6
|
import { icon } from '../../icon.js';
|
|
7
7
|
import { bytes } from '../../format.js';
|
|
8
8
|
import { markdownOpener } from './markdown.js';
|
|
9
9
|
import { attachMedia } from '../../media.js';
|
|
10
|
+
import { FileText } from '../../../bl/queries.js';
|
|
11
|
+
import { watchQuery } from '../../../bl/watchQuery.js';
|
|
12
|
+
import { ExecCommandAction } from '../../../bl/actions.js';
|
|
10
13
|
|
|
11
14
|
const { div, pre, img, span, button, video, audio } = dd;
|
|
12
15
|
|
|
@@ -77,9 +80,9 @@ export function renderOpener(node, openerId, ui) {
|
|
|
77
80
|
const TEXT_VIEW_BYTES = 512 * 1024;
|
|
78
81
|
|
|
79
82
|
function textOpener(node, ui) {
|
|
80
|
-
const src =
|
|
83
|
+
const src = watchQuery(ui.engine, FileText.of(node.id, TEXT_VIEW_BYTES, node.size));
|
|
81
84
|
return dd.alias(() =>
|
|
82
|
-
ui.
|
|
85
|
+
ui.watch(
|
|
83
86
|
src,
|
|
84
87
|
({ text, truncated, total }) => div({ className: 'viewer text' },
|
|
85
88
|
pre(text),
|
|
@@ -162,7 +165,7 @@ function fallbackOpener(node, ui, reason) {
|
|
|
162
165
|
span({ $styling: { color: 'var(--text-faint)', 'max-width': '340px' } },
|
|
163
166
|
reason || 'No preview available for this file type. Install a plugin that handles it, or download the file.'),
|
|
164
167
|
button({ className: 'btn primary' }, icon('download', { size: 15 }), 'Download')
|
|
165
|
-
.on({ click: () => ui.
|
|
168
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('explorer.download', node)) }),
|
|
166
169
|
),
|
|
167
170
|
);
|
|
168
171
|
}
|
|
@@ -18,19 +18,21 @@
|
|
|
18
18
|
// document length and the node count are capped — a truncated document with a visible
|
|
19
19
|
// notice beats a hung one.
|
|
20
20
|
|
|
21
|
-
import { dd,
|
|
21
|
+
import { dd, mapCell } from '../../../runtime.js';
|
|
22
22
|
import { icon } from '../../icon.js';
|
|
23
23
|
import { parseTroveUri } from '@3sln/trove/core/links.js';
|
|
24
24
|
import { openTroveLink } from '../../../bl/links.js';
|
|
25
|
+
import { FileText } from '../../../bl/queries.js';
|
|
26
|
+
import { watchQuery } from '../../../bl/watchQuery.js';
|
|
25
27
|
|
|
26
28
|
const { div, span, h1, h2, h3, p, ul, ol, li, pre, code, a, em, strong, blockquote, hr, br } = dd;
|
|
27
29
|
|
|
28
30
|
export function markdownOpener(node, ui) {
|
|
29
31
|
// Bounded at the TRANSFER, not just the render: MAX_CHARS below stops us laying out a
|
|
30
32
|
// huge document, but without this the whole file is still pulled into the tab first.
|
|
31
|
-
const src =
|
|
33
|
+
const src = mapCell(watchQuery(ui.engine, FileText.of(node.id, MAX_CHARS, node.size)), (r) => r.text);
|
|
32
34
|
return dd.alias(() =>
|
|
33
|
-
ui.
|
|
35
|
+
ui.watch(
|
|
34
36
|
src,
|
|
35
37
|
// A throw in here is NOT covered by the `error:` handler below — that one is for
|
|
36
38
|
// the observable failing, not for the mapper. Rendering untrusted content is
|