@3sln/trove 0.0.2
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/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The main-panel launcher — search-first "home" that replaces the explorer
|
|
2
|
+
// sidebar. Empty query shows recents + everything in the collection; typing
|
|
3
|
+
// searches files; a leading `!` switches to command execution; a leading `#`
|
|
4
|
+
// filters by tag/property (parsed in the search layer). One keyboard-navigable
|
|
5
|
+
// list across whichever mode is active.
|
|
6
|
+
|
|
7
|
+
import { dd } from '../../runtime.js';
|
|
8
|
+
import { icon } from '../icon.js';
|
|
9
|
+
import { OpenFileAction, SearchAction, FilterAction } from '../../bl/actions.js';
|
|
10
|
+
import { parseTagQuery, filterLabel } from '../../bl/tagQuery.js';
|
|
11
|
+
import { activeView, renderView, viewSwitcher, viewMove } from './views/index.js';
|
|
12
|
+
import { openRowMenu } from './views/parts.js';
|
|
13
|
+
|
|
14
|
+
const { div, span, input, button } = dd;
|
|
15
|
+
|
|
16
|
+
// The keys a view may claim. Everything else the search box keeps.
|
|
17
|
+
const ARROWS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
|
|
18
|
+
|
|
19
|
+
// What to put in the search box, and how much of it fits.
|
|
20
|
+
//
|
|
21
|
+
// The server tells us, because the search TRANSFORMER decides what the box accepts and
|
|
22
|
+
// that is deployment configuration — swap in an LLM transformer and "# filter by tag"
|
|
23
|
+
// stops being the right advice. The fallback covers the moment before capabilities
|
|
24
|
+
// arrive and any server too old to say; `!` is ours either way, since running a command
|
|
25
|
+
// from here is a client convention the server knows nothing about.
|
|
26
|
+
function promptFor(ui, { compact = false, modal = false } = {}) {
|
|
27
|
+
const p = ui.platform.capabilities?.searchPrompt;
|
|
28
|
+
const base = (compact ? p?.short || p?.placeholder : p?.placeholder)
|
|
29
|
+
|| (compact ? 'Search files' : 'Search files · # filter by tag');
|
|
30
|
+
// On a phone the box is ~300px wide; adding a second clause guarantees an ellipsis
|
|
31
|
+
// in the middle of the instructions, which reads as broken rather than terse.
|
|
32
|
+
return compact || modal ? base : `${base} · ! run a command`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let searchTimer = null;
|
|
36
|
+
function runSearch(ui, query) {
|
|
37
|
+
// SearchAction owns search state; the launcher only dispatches (no direct .set).
|
|
38
|
+
clearTimeout(searchTimer);
|
|
39
|
+
searchTimer = setTimeout(() => ui.go(new SearchAction(query)), 240);
|
|
40
|
+
}
|
|
41
|
+
function clearSearch(ui) {
|
|
42
|
+
ui.platform.workbench.setLaunchQuery('');
|
|
43
|
+
ui.go(new SearchAction('')); // empty query resets results/ran/error in the service
|
|
44
|
+
}
|
|
45
|
+
function runFilter(ui, filters, text) {
|
|
46
|
+
clearTimeout(searchTimer);
|
|
47
|
+
searchTimer = setTimeout(() => ui.go(new FilterAction(filters, text)), 200);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function launcher(state, ui, opts = {}) {
|
|
51
|
+
const wb = ui.platform.workbench;
|
|
52
|
+
const modal = !!opts.modal; // rendered as the double-shift overlay → picks reset the stack
|
|
53
|
+
const q = state.wb.launch.query;
|
|
54
|
+
const mode = q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
|
|
55
|
+
|
|
56
|
+
const groups = buildContent(state, ui, q, mode, modal);
|
|
57
|
+
const flat = groups.flatMap((g) => g.items);
|
|
58
|
+
const idx = flat.length ? Math.min(state.wb.launch.index, flat.length - 1) : 0;
|
|
59
|
+
|
|
60
|
+
const onInput = (e) => {
|
|
61
|
+
const v = e.target.value;
|
|
62
|
+
wb.setLaunchQuery(v);
|
|
63
|
+
if (v.startsWith('!')) return; // command mode: no query dispatch
|
|
64
|
+
const { text, filters } = parseTagQuery(v);
|
|
65
|
+
if (filters.length) runFilter(ui, filters, text); // drive-wide tag/property query
|
|
66
|
+
else runSearch(ui, text);
|
|
67
|
+
};
|
|
68
|
+
// Moving the highlight with the KEYBOARD is selecting, as far as the rest of the app
|
|
69
|
+
// is concerned. Without this, `explorer.selectedNodes()` was permanently empty and
|
|
70
|
+
// every command that works on "the selected item" — delete above all — silently did
|
|
71
|
+
// nothing.
|
|
72
|
+
//
|
|
73
|
+
// Hovering deliberately does not select. It moves the highlight (so Enter opens what
|
|
74
|
+
// the pointer is over) but leaves the selection alone: a second state push per mouse
|
|
75
|
+
// move re-renders the list under the pointer, and a row replaced between mousedown and
|
|
76
|
+
// mouseup never receives its click. A pointer user acts through the row's own menu,
|
|
77
|
+
// which selects explicitly before it opens.
|
|
78
|
+
const hoverAt = (at) => wb.setLaunchIndex(at);
|
|
79
|
+
const selectAt = (at) => { wb.setLaunchIndex(at); syncSelection(ui, flat[at]); };
|
|
80
|
+
const move = (delta) => {
|
|
81
|
+
wb.moveLaunch(delta, flat.length);
|
|
82
|
+
syncSelection(ui, flat[wb.state.launch.index]);
|
|
83
|
+
};
|
|
84
|
+
// How the results are drawn right now. The view gets a say in what an arrow key
|
|
85
|
+
// means — one row down is one tile down in a grid, not one tile across — but the
|
|
86
|
+
// index, the selection and the wrapping stay here, so up and down mean the same
|
|
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);
|
|
92
|
+
const onKey = (e) => {
|
|
93
|
+
// `textual`: there is text in the box, so left/right are the caret's and no view
|
|
94
|
+
// may claim them. Fixing a typo must not move the highlight.
|
|
95
|
+
const claimed = ARROWS.has(e.key)
|
|
96
|
+
? viewMove(view, e.key, { index: idx, count: flat.length, textual: q.length > 0 })
|
|
97
|
+
: null;
|
|
98
|
+
if (claimed !== null) { e.preventDefault(); move(claimed); }
|
|
99
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
|
|
100
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
|
|
101
|
+
else if (e.key === 'Enter') { e.preventDefault(); flat[idx]?.run(); }
|
|
102
|
+
else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui); }
|
|
103
|
+
// The row under the highlight is the subject of the row menu, so the key that opens
|
|
104
|
+
// one on every other list opens this one too — without making the user leave the
|
|
105
|
+
// search box to reach it.
|
|
106
|
+
else if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
|
107
|
+
const it = flat[idx];
|
|
108
|
+
// Arrowing here already selected the row, so the menu acts on the right thing.
|
|
109
|
+
if (it?.menu) { e.preventDefault(); openRowMenu(document.querySelector('.launch-item.active, .grid-tile.active'), it, ui); }
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// What the server actually searched (transformer output) — shown when it differs
|
|
114
|
+
// from what the user typed, so an LLM-assisted transform stays honest.
|
|
115
|
+
const resolved = mode === 'search' ? state.se.resolved : null;
|
|
116
|
+
const showResolved = resolved && (resolved.source === 'llm'
|
|
117
|
+
|| (resolved.tagFilters && resolved.tagFilters.length)
|
|
118
|
+
|| (resolved.semanticText || '').trim() !== q.trim());
|
|
119
|
+
|
|
120
|
+
const inner = div({ className: 'launcher' },
|
|
121
|
+
div({ className: 'launch-box' },
|
|
122
|
+
icon(mode === 'command' ? 'command' : mode === 'filter' ? 'tag' : 'search', { size: 18 }),
|
|
123
|
+
// `false`, not `'false'`: this is set as a property, and the string is truthy —
|
|
124
|
+
// so the search box had spellcheck ON, red-underlining every filename typed into it.
|
|
125
|
+
input({ className: 'launch-input', value: q, autofocus: true, spellcheck: false,
|
|
126
|
+
placeholder: promptFor(ui, { compact: state.vp?.mode === 'phone', modal }) })
|
|
127
|
+
.on({ input: onInput, keydown: onKey }),
|
|
128
|
+
q ? button({ className: 'launch-clear', title: 'Clear' }, icon('close', { size: 14 }))
|
|
129
|
+
.on({ click: () => clearSearch(ui) }) : null,
|
|
130
|
+
// Only where the browser can transcribe WITHOUT sending audio anywhere. A remote's
|
|
131
|
+
// own mic needs no button from us — it dictates into this field once it is focused,
|
|
132
|
+
// which is what `search.voice` is really for.
|
|
133
|
+
ui.platform.voice?.canListen()
|
|
134
|
+
? button({
|
|
135
|
+
className: `launch-mic ${state.voice?.listening ? 'on' : ''}`,
|
|
136
|
+
title: state.voice?.listening ? 'Stop listening' : 'Search by voice',
|
|
137
|
+
'aria-pressed': state.voice?.listening ? 'true' : 'false',
|
|
138
|
+
}, icon('mic', { size: 15 })).on({ click: () => ui.exec('search.voice') })
|
|
139
|
+
: null,
|
|
140
|
+
// Which way to look at the drive. Not in the modal search, where the answer is
|
|
141
|
+
// always "the one thing I am about to press Enter on".
|
|
142
|
+
modal ? null : viewSwitcher(ui.platform, view),
|
|
143
|
+
),
|
|
144
|
+
showResolved ? resolvedBar(resolved) : null,
|
|
145
|
+
// The results belong to the active view — see ui/components/views. The launcher says
|
|
146
|
+
// WHAT is on screen (these groups, this highlight); the view says how it looks. That
|
|
147
|
+
// split is why a gallery is a contribution rather than another branch in here.
|
|
148
|
+
div({ className: 'launch-body' },
|
|
149
|
+
renderView(view, { groups, index: idx, handlers: { hover: hoverAt, select: selectAt }, state, ui }),
|
|
150
|
+
searchHelp(state, ui, mode),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
return modal ? inner : div({ className: 'editor' }, inner);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// An "under-bar" reflecting what the search actually ran (the transformer's output):
|
|
157
|
+
// the semantic text it searched for + any tag filters it applied. Read-only and
|
|
158
|
+
// honest — we don't rewrite the user's input, we just show what was dispatched.
|
|
159
|
+
function resolvedBar(r) {
|
|
160
|
+
return div({ className: 'launch-resolved' },
|
|
161
|
+
span({ className: 'rq-label' }, r.source === 'llm' ? 'Interpreted as' : 'Searching'),
|
|
162
|
+
(r.semanticText || '').trim() ? span({ className: 'rq-text' }, `“${r.semanticText.trim()}”`) : null,
|
|
163
|
+
...(r.tagFilters || []).map((f) => span({ className: 'rq-chip' }, icon('tag', { size: 11 }), filterLabel(f))),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Keep the drive's idea of "what is selected" in step with the highlighted row.
|
|
168
|
+
//
|
|
169
|
+
// The NODE goes along with the id. A search result or a recent can be from a collection
|
|
170
|
+
// that isn't loaded — or a page that isn't — so an id alone is something the explorer
|
|
171
|
+
// 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
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How this search box works — shown when, and only when, a search came back empty.
|
|
179
|
+
*
|
|
180
|
+
* That is the one moment the syntax is worth explaining: the user asked for something
|
|
181
|
+
* and got nothing, and "you typed it wrong" is a real possibility they can act on.
|
|
182
|
+
* Showing it any earlier is a permanent instruction panel above an empty drive, and
|
|
183
|
+
* showing it when there ARE results would be explaining a thing that just worked.
|
|
184
|
+
*
|
|
185
|
+
* The text comes from the server's transformer, so it describes the grammar this
|
|
186
|
+
* deployment actually runs rather than the one this file was written against.
|
|
187
|
+
*/
|
|
188
|
+
function searchHelp(state, ui, mode) {
|
|
189
|
+
if (mode === 'command') return null;
|
|
190
|
+
const se = state.se;
|
|
191
|
+
if (!se.ran || se.loading || se.error || (se.results || []).length) return null;
|
|
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
|
+
};
|
|
201
|
+
return div({ className: 'launch-help' },
|
|
202
|
+
p.hint ? div({ className: 'lh-hint' }, icon('info', { size: 13 }), span(p.hint)) : null,
|
|
203
|
+
p.examples?.length
|
|
204
|
+
? div({ className: 'lh-examples' }, ...p.examples.map((ex) =>
|
|
205
|
+
button({ className: 'lh-example', title: ex.label || 'Try this search' },
|
|
206
|
+
span({ className: 'lh-q' }, ex.query),
|
|
207
|
+
ex.label ? span({ className: 'lh-label' }, ex.label) : null,
|
|
208
|
+
).on({ click: tryIt(ex.query) })))
|
|
209
|
+
: null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
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
|
+
|
|
227
|
+
const { text, filters } = parseTagQuery(q);
|
|
228
|
+
const nodes = (state.se.results || []).map((r) => r.node);
|
|
229
|
+
const err = state.se.error;
|
|
230
|
+
|
|
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
|
+
|
|
365
|
+
// The trash is the one place "delete" means destroy, so its two verbs live together:
|
|
366
|
+
// put it back, or finish the job on this one item. Emptying the whole trash was
|
|
367
|
+
// 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
|
+
|
|
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
|
+
|
|
384
|
+
// 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
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Built-in openers, registered through the same contribution system plugins use.
|
|
2
|
+
// Each declares a selector (extensions / mime) and a component(node, ui) → vnode.
|
|
3
|
+
// The editor area picks the highest-priority opener whose selector matches.
|
|
4
|
+
|
|
5
|
+
import { dd, cell, fromAsync, watch } from '../../../runtime.js';
|
|
6
|
+
import { icon } from '../../icon.js';
|
|
7
|
+
import { bytes } from '../../format.js';
|
|
8
|
+
import { markdownOpener } from './markdown.js';
|
|
9
|
+
import { attachMedia } from '../../media.js';
|
|
10
|
+
|
|
11
|
+
const { div, pre, img, span, button, video, audio } = dd;
|
|
12
|
+
|
|
13
|
+
// Built-ins register the same way a plugin's manifest declares an opener — same
|
|
14
|
+
// registry, same `match` selector, same priority ordering. The only difference is
|
|
15
|
+
// that a built-in supplies a `component` (it runs in the host) where a plugin
|
|
16
|
+
// supplies an `entry` module (it runs in its own sandboxed frame).
|
|
17
|
+
const BUILT_IN = {
|
|
18
|
+
'core.audio': {
|
|
19
|
+
title: 'Audio Player', priority: 20,
|
|
20
|
+
match: { mime: ['audio/*'], ext: ['.mp3', '.flac', '.wav', '.opus', '.ogg', '.m4a', '.m4b'] },
|
|
21
|
+
component: audioOpener,
|
|
22
|
+
},
|
|
23
|
+
'core.video': {
|
|
24
|
+
title: 'Video Player', priority: 20,
|
|
25
|
+
match: { mime: ['video/*'], ext: ['.mp4', '.webm', '.mkv', '.mov'] },
|
|
26
|
+
component: videoOpener,
|
|
27
|
+
},
|
|
28
|
+
'core.image': {
|
|
29
|
+
title: 'Image Viewer', priority: 20,
|
|
30
|
+
match: { mime: ['image/*'], ext: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'] },
|
|
31
|
+
component: imageOpener,
|
|
32
|
+
},
|
|
33
|
+
// Markdown outranks the plain text viewer: a document that links its sources is how
|
|
34
|
+
// things are grouped now, so those links have to be clickable by default.
|
|
35
|
+
'core.markdown': {
|
|
36
|
+
title: 'Markdown', priority: 30,
|
|
37
|
+
match: { ext: ['.md', '.markdown'], mime: ['text/markdown'] },
|
|
38
|
+
component: markdownOpener,
|
|
39
|
+
},
|
|
40
|
+
'core.text': {
|
|
41
|
+
title: 'Text Viewer', priority: 10,
|
|
42
|
+
match: {
|
|
43
|
+
mime: ['text/*', 'application/json'],
|
|
44
|
+
ext: ['.txt', '.md', '.json', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.css', '.html', '.xml', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.py', '.rb', '.go', '.rs', '.sh', '.c', '.h', '.cpp', '.java'],
|
|
45
|
+
},
|
|
46
|
+
component: textOpener,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function registerBuiltinOpeners(platform) {
|
|
51
|
+
for (const [name, spec] of Object.entries(BUILT_IN)) {
|
|
52
|
+
platform.contributions.register(name, { type: 'opener', ...spec });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Render a node with a resolved opener; falls back gracefully. */
|
|
57
|
+
export function renderOpener(node, openerId, ui) {
|
|
58
|
+
const opener = ui.platform.contributions.get(openerId);
|
|
59
|
+
if (opener?.component) {
|
|
60
|
+
try {
|
|
61
|
+
return opener.component(node, ui);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return errorView(err.message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (opener?.pluginId) return pluginOpenerView(opener, node, ui);
|
|
67
|
+
return fallbackOpener(node, ui);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- component openers -----------------------------------------------------
|
|
71
|
+
|
|
72
|
+
// How much of a text file the viewer will pull and draw. A drive holds logs, CSV
|
|
73
|
+
// exports and database dumps in the hundreds of megabytes; reading one whole just to
|
|
74
|
+
// show its beginning buffers the entire file in the tab and then asks the browser to
|
|
75
|
+
// lay out ten million characters. Both halves are bounded here — the Range request
|
|
76
|
+
// means the bytes never arrive, not merely that they aren't drawn.
|
|
77
|
+
const TEXT_VIEW_BYTES = 512 * 1024;
|
|
78
|
+
|
|
79
|
+
function textOpener(node, ui) {
|
|
80
|
+
const src = fromAsync(() => ui.platform.api.readTextCapped(node.id, { maxBytes: TEXT_VIEW_BYTES, size: node.size }));
|
|
81
|
+
return dd.alias(() =>
|
|
82
|
+
ui.platform.reactive.watch(
|
|
83
|
+
src,
|
|
84
|
+
({ text, truncated, total }) => div({ className: 'viewer text' },
|
|
85
|
+
pre(text),
|
|
86
|
+
// Say what is missing rather than trailing off. A viewer that silently shows
|
|
87
|
+
// the first slice of a file is worse than one that refuses: the reader believes
|
|
88
|
+
// they have seen the end of it.
|
|
89
|
+
truncated
|
|
90
|
+
? div({ className: 'md-truncated' },
|
|
91
|
+
`Showing the first ${Math.round(text.length / 1024)} KB${total ? ` of ${(total / 1048576).toFixed(1)} MB` : ''}. `,
|
|
92
|
+
span('Download the file to read all of it.'))
|
|
93
|
+
: null,
|
|
94
|
+
),
|
|
95
|
+
{
|
|
96
|
+
placeholder: () => div({ className: 'viewer' }, div({ className: 'loading' }, div({ className: 'spinner' }), span('Loading…'))),
|
|
97
|
+
error: (e) => errorView(e.message),
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
)();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A media element (img/audio/video) sets its `src` to a download URL that can 404 or
|
|
104
|
+
// hold undecodable bytes. Those failures surface only as an async `error` event on the
|
|
105
|
+
// element itself (media error events don't bubble), which would otherwise leave a
|
|
106
|
+
// broken glyph / black box with no message. `mediaWithError` passes an `onError` the
|
|
107
|
+
// caller MUST wire onto the actual media element, and swaps in the download fallback
|
|
108
|
+
// (with a reason) the moment it fires.
|
|
109
|
+
const MEDIA_ERR = "This file couldn't be loaded — it may be missing or in an unsupported format.";
|
|
110
|
+
function mediaWithError(node, ui, makeEl) {
|
|
111
|
+
const state = cell({ error: null });
|
|
112
|
+
const onError = (msg) => state.setValue({ error: msg || MEDIA_ERR });
|
|
113
|
+
const el = makeEl(onError);
|
|
114
|
+
return dd.alias(() =>
|
|
115
|
+
watch(state, (s) => (s.error ? fallbackOpener(node, ui, s.error) : el)),
|
|
116
|
+
)();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `src` is not set here.
|
|
121
|
+
*
|
|
122
|
+
* It is minted — a URL that carries its own grant, because a media element fetches
|
|
123
|
+
* without our Authorization header — and then KEPT current: it expires, and a paused
|
|
124
|
+
* film resumed the next morning has to swap its source without losing its place. All of
|
|
125
|
+
* that is `attachMedia`; see ui/media.js.
|
|
126
|
+
*/
|
|
127
|
+
const sourced = (el, node, ui, onError) => el.on({
|
|
128
|
+
$attach: (dom) => { dom._detachMedia = attachMedia(dom, node, ui, { onError }); },
|
|
129
|
+
$detach: (dom) => { dom._detachMedia?.(); dom._detachMedia = null; },
|
|
130
|
+
}).opaque();
|
|
131
|
+
|
|
132
|
+
function imageOpener(node, ui) {
|
|
133
|
+
return mediaWithError(node, ui, (onError) =>
|
|
134
|
+
div({ className: 'viewer image' }, sourced(img({ alt: node.name }), node, ui, onError)),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function audioOpener(node, ui) {
|
|
139
|
+
return mediaWithError(node, ui, (onError) =>
|
|
140
|
+
div({ className: 'viewer', $styling: { display: 'grid', 'place-items': 'center', gap: '16px', padding: '40px' } },
|
|
141
|
+
icon('file-audio', { size: 48 }),
|
|
142
|
+
span({ $styling: { color: 'var(--text-dim)' } }, node.name),
|
|
143
|
+
sourced(audio({ controls: true, $styling: { width: 'min(520px, 90%)' } }), node, ui, onError),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function videoOpener(node, ui) {
|
|
149
|
+
return mediaWithError(node, ui, (onError) =>
|
|
150
|
+
div({ className: 'viewer', $styling: { display: 'grid', 'place-items': 'center', background: '#000' } },
|
|
151
|
+
sourced(video({ controls: true, $styling: { 'max-width': '100%', 'max-height': '100%' } }), node, ui, onError),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fallbackOpener(node, ui, reason) {
|
|
157
|
+
return div({ className: 'viewer' },
|
|
158
|
+
div({ className: 'fallback' },
|
|
159
|
+
icon(reason ? 'warn' : 'file', { size: 44 }),
|
|
160
|
+
span({ $styling: { 'font-weight': 600 } }, node.name),
|
|
161
|
+
span(`${node.contentType || 'Unknown type'} · ${bytes(node.size)}`),
|
|
162
|
+
span({ $styling: { color: 'var(--text-faint)', 'max-width': '340px' } },
|
|
163
|
+
reason || 'No preview available for this file type. Install a plugin that handles it, or download the file.'),
|
|
164
|
+
button({ className: 'btn primary' }, icon('download', { size: 15 }), 'Download')
|
|
165
|
+
.on({ click: () => ui.exec('explorer.download', node) }),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pluginOpenerView(opener, node, ui) {
|
|
171
|
+
// A plugin opener renders in its OWN sandboxed iframe (spawned by mountViewer with
|
|
172
|
+
// the plugin's capabilities). That iframe is not a child here — it's a host-owned
|
|
173
|
+
// position:fixed overlay whose box tracks this .pv-host element (moving an <iframe>
|
|
174
|
+
// in the DOM would reload it). $attach hands .pv-host to mountViewer as the tracking
|
|
175
|
+
// target; $detach lets it dock or tear down. `.opaque()` keeps dodo from reconciling
|
|
176
|
+
// these (host-managed) nodes so their lifecycle hooks stay stable across re-renders.
|
|
177
|
+
// A .pv-status overlay shows a spinner until the plugin opens the file (the iframe
|
|
178
|
+
// is transparent while loading) and an error if it never does.
|
|
179
|
+
return div({ className: 'viewer plugin-viewer' },
|
|
180
|
+
div({ className: 'pv-status' }).on({
|
|
181
|
+
$attach: (el) => {
|
|
182
|
+
el.innerHTML = '<div class="loading"><div class="spinner"></div><span>Opening…</span></div>';
|
|
183
|
+
el._ready = () => { el.style.display = 'none'; };
|
|
184
|
+
el._error = (msg) => { el.style.display = 'grid'; el.innerHTML = ''; el.appendChild(errorEl(msg)); };
|
|
185
|
+
},
|
|
186
|
+
}).opaque(),
|
|
187
|
+
div({ className: 'pv-host' }).on({
|
|
188
|
+
$attach: (el) => {
|
|
189
|
+
const status = el.parentElement?.querySelector('.pv-status');
|
|
190
|
+
el._detach = ui.platform.plugins.mountViewer(opener.pluginId, el, node, opener.id, {
|
|
191
|
+
onReady: () => status?._ready?.(),
|
|
192
|
+
onError: (msg) => status?._error?.(msg || 'This viewer failed to load'),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
$detach: (el) => { el._detach?.(); el._detach = null; },
|
|
196
|
+
}).opaque(),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A plain DOM error node for the imperatively-managed .pv-status overlay.
|
|
201
|
+
function errorEl(message) {
|
|
202
|
+
const wrap = document.createElement('div');
|
|
203
|
+
wrap.className = 'fallback';
|
|
204
|
+
const s = document.createElement('span');
|
|
205
|
+
s.textContent = message || 'Failed to open';
|
|
206
|
+
wrap.appendChild(s);
|
|
207
|
+
return wrap;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function errorView(message) {
|
|
211
|
+
return div({ className: 'viewer' }, div({ className: 'fallback' }, icon('warn', { size: 40 }), span(message || 'Failed to open')));
|
|
212
|
+
}
|