@3sln/trove 0.0.18 → 0.0.20
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 +38 -0
- package/package.json +1 -1
- package/packages/core/src/index.js +3 -0
- package/packages/core/src/sqlite-d1.js +6 -0
- package/packages/core/src/sqlite-do.js +182 -0
- package/packages/server/src/adapters/worker.js +25 -6
- package/packages/web/dist/assets/main-6tn55drx.js +742 -0
- package/packages/web/dist/assets/{main-b9jd0fyt.js.map → main-6tn55drx.js.map} +16 -16
- package/packages/web/dist/assets/{styles-hskanqc0.css → styles-15b7sgsm.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +100 -7
- package/packages/web/src/bl/commands.js +3 -1
- package/packages/web/src/bl/fileType.js +31 -0
- package/packages/web/src/bl/launcher.js +17 -4
- package/packages/web/src/bl/openers.js +16 -0
- package/packages/web/src/bl/state.js +4 -0
- package/packages/web/src/platform/pluginRpc.js +14 -1
- package/packages/web/src/styles.css +27 -0
- package/packages/web/src/ui/components/launcher.js +3 -1
- package/packages/web/src/ui/components/overlays.js +20 -1
- package/packages/web/src/ui/components/statusBar.js +20 -6
- package/packages/web/src/ui/components/views/grid.js +4 -4
- package/packages/web/src/ui/components/views/list.js +4 -4
- package/packages/web/src/ui/components/views/parts.js +47 -2
- package/packages/web/src/ui/compositions/workbench.js +3 -1
- package/packages/web/dist/assets/main-b9jd0fyt.js +0 -742
|
@@ -49,6 +49,10 @@ export function slice(initial = {}) {
|
|
|
49
49
|
export const explorerState = (settings) => slice({
|
|
50
50
|
items: [], loading: false, error: null,
|
|
51
51
|
selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
|
|
52
|
+
// Whether the list is in SELECTION MODE. It lives here rather than in `viewState`
|
|
53
|
+
// because it is a property of the selection it governs, not a transient overlay: the
|
|
54
|
+
// selection is already here, and splitting the two would let them disagree.
|
|
55
|
+
bulk: false,
|
|
52
56
|
// No collection until one is chosen or created. `gate` is 'create' | 'choose' | null —
|
|
53
57
|
// when set, it is the ONLY thing the workbench shows, because every request needs a
|
|
54
58
|
// collection and there is nothing sensible to render without one.
|
|
@@ -363,7 +363,20 @@ export class PluginRpcRouter {
|
|
|
363
363
|
/** blob:/data: carry their own bytes; anything else must be a declared endpoint. */
|
|
364
364
|
#artworkAllowed(record, src) {
|
|
365
365
|
if (!src) return false;
|
|
366
|
-
if (/^
|
|
366
|
+
if (/^data:image\//i.test(src)) return true;
|
|
367
|
+
// A `blob:` URL from a plugin frame is ALWAYS unusable here, and accepting one was
|
|
368
|
+
// worse than refusing it: the frame runs on an opaque origin, so its object URLs are
|
|
369
|
+
// `blob:null/…`, and this page cannot load another origin's blob. The browser says
|
|
370
|
+
// "Not allowed to load local resource" against the HOST document — a message that
|
|
371
|
+
// names neither the plugin nor the artwork, for a lock-screen image that simply never
|
|
372
|
+
// appeared.
|
|
373
|
+
//
|
|
374
|
+
// There is no version of this that works: a frame cannot mint a URL in this origin.
|
|
375
|
+
// Artwork has to arrive as bytes, which `data:image/…` above is.
|
|
376
|
+
if (/^blob:/i.test(src)) {
|
|
377
|
+
console.warn(`[trove] ${record.id}: media artwork must be a data: URL — a blob: from a sandboxed frame is opaque-origin and cannot be loaded here`);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
367
380
|
return isAllowedUrl(networkEndpoints(record.manifest), src);
|
|
368
381
|
}
|
|
369
382
|
|
|
@@ -957,6 +957,33 @@ kbd {
|
|
|
957
957
|
.launch-item:hover .launch-more, .launch-item.active .launch-more, .launch-more:focus-visible { opacity: 1; }
|
|
958
958
|
.launch-more:hover { background: var(--bg-active); color: var(--text); }
|
|
959
959
|
.launch-more:focus-visible { outline: none; color: var(--text); background: var(--bg-active); box-shadow: 0 0 0 2px var(--focus-halo); }
|
|
960
|
+
/* IN SELECTION MODE the same control is this item's checkbox, so it is always visible —
|
|
961
|
+
a checkbox that appears on hover is one you cannot see the state of, which is the only
|
|
962
|
+
thing a checkbox is for. */
|
|
963
|
+
.launch-more.check { opacity: 1; color: var(--text-faint); }
|
|
964
|
+
.launch-more.check.on { color: var(--accent); }
|
|
965
|
+
.launch-more.check:hover { color: var(--text); }
|
|
966
|
+
|
|
967
|
+
/* The bar over a selection. Above the results, because it is about the set. */
|
|
968
|
+
.sel-bar {
|
|
969
|
+
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
970
|
+
padding: 8px 12px; margin-bottom: 8px; border-radius: 10px;
|
|
971
|
+
background: var(--bg-active); border: 1px solid var(--border);
|
|
972
|
+
}
|
|
973
|
+
.sel-count { font-weight: 600; font-size: 13px; min-width: 8ch; }
|
|
974
|
+
.sel-actions { display: flex; align-items: center; gap: 6px; flex: 1; flex-wrap: wrap; }
|
|
975
|
+
.sel-act, .sel-done {
|
|
976
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
977
|
+
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--border);
|
|
978
|
+
background: var(--bg); color: var(--text); font: inherit; font-size: 12px; cursor: pointer;
|
|
979
|
+
}
|
|
980
|
+
.sel-act:hover:not(:disabled), .sel-done:hover { background: var(--bg-active); }
|
|
981
|
+
/* Disabled rather than hidden while nothing is picked: the actions are what the mode is
|
|
982
|
+
FOR, and hiding them would leave a bar that looks broken instead of one that is waiting. */
|
|
983
|
+
.sel-act:disabled { opacity: .45; cursor: default; }
|
|
984
|
+
.sel-act.danger:not(:disabled) { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
985
|
+
.sel-done { margin-left: auto; }
|
|
986
|
+
|
|
960
987
|
/* On a touch screen there is no hover to reveal it, so it is simply always there. */
|
|
961
988
|
@media (hover: none) { .launch-more { opacity: 1; } }
|
|
962
989
|
.launch-kind { flex: none; color: var(--text-faint); font-size: 11px; padding: 1px 6px; border: 1px solid var(--border); border-radius: 5px; }
|
|
@@ -9,7 +9,7 @@ import { icon } from '../icon.js';
|
|
|
9
9
|
import { ExecCommandAction, FilterAction, MoveLaunchAction, SearchAction, SelectLaunchAction, SetLaunchIndexAction, SetLaunchQueryAction } from '../../bl/actions.js';
|
|
10
10
|
import { parseTagQuery, filterLabel } from '../../bl/tagQuery.js';
|
|
11
11
|
import { renderView, viewSwitcher, viewMove } from './views/index.js';
|
|
12
|
-
import { openRowMenu } from './views/parts.js';
|
|
12
|
+
import { openRowMenu, selectionBar } from './views/parts.js';
|
|
13
13
|
import { activate } from '../activate.js';
|
|
14
14
|
|
|
15
15
|
const { div, span, input, button } = dd;
|
|
@@ -160,6 +160,8 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
160
160
|
// WHAT is on screen (these groups, this highlight); the view says how it looks. That
|
|
161
161
|
// split is why a gallery is a contribution rather than another branch in here.
|
|
162
162
|
div({ className: 'launch-body' },
|
|
163
|
+
// Above the results, because it is about the set rather than about a row.
|
|
164
|
+
selectionBar(state, ui),
|
|
163
165
|
renderView(view, { groups, index: idx, handlers: { hover: hoverAt, select: selectAt }, state, ui }),
|
|
164
166
|
searchHelp(content.help, ui),
|
|
165
167
|
),
|
|
@@ -372,7 +372,24 @@ export function toasts(state, ui) {
|
|
|
372
372
|
// ---- Transfer tray ---------------------------------------------------------
|
|
373
373
|
export function transferTray(state, ui) {
|
|
374
374
|
const items = state.tr.items;
|
|
375
|
-
if (!items.length)
|
|
375
|
+
if (!items.length) {
|
|
376
|
+
// The list is empty, so a dismissal from the last batch has served its purpose. Clear
|
|
377
|
+
// it here rather than leaving it set: otherwise the next upload — a fresh decision by
|
|
378
|
+
// the user, minutes or hours later — inherits a choice they made about transfers that
|
|
379
|
+
// no longer exist, and the tray silently never appears again.
|
|
380
|
+
if (state.vs?.transfersDismissed) ui.engine.dispatch(new SetViewStateAction('transfersDismissed', false));
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
// DISMISSABLE, running transfers or not. This is a view of the work, not the work:
|
|
384
|
+
// closing it cancels nothing, and it used to sit over the drive for the whole of a large
|
|
385
|
+
// upload — which is exactly when someone wants to keep using the drive. "Clear finished"
|
|
386
|
+
// was the only control and by definition cannot remove something still running.
|
|
387
|
+
//
|
|
388
|
+
// The dismissal is remembered until the transfer list empties (see `transfersDismissed`
|
|
389
|
+
// in viewState). A NEW transfer does not force it back open: undoing a choice someone
|
|
390
|
+
// just made is worse than being quiet, and the status bar still shows that something is
|
|
391
|
+
// happening and offers the way back in.
|
|
392
|
+
if (state.vs?.transfersDismissed) return null;
|
|
376
393
|
return div({ className: 'tray' },
|
|
377
394
|
div({ className: 'head' },
|
|
378
395
|
icon('upload', { size: 14 }),
|
|
@@ -380,6 +397,8 @@ export function transferTray(state, ui) {
|
|
|
380
397
|
div({ className: 'actions' },
|
|
381
398
|
button({ className: 'iconbtn', title: 'Clear finished' }, icon('check', { size: 14 }))
|
|
382
399
|
.on({ click: () => ui.engine.dispatch(new ClearFinishedTransfersAction()) }),
|
|
400
|
+
button({ className: 'iconbtn', title: 'Hide transfers (they keep going)' }, icon('close', { size: 14 }))
|
|
401
|
+
.on({ click: () => ui.engine.dispatch(new SetViewStateAction('transfersDismissed', true)) }),
|
|
383
402
|
),
|
|
384
403
|
),
|
|
385
404
|
div({ className: 'items' },
|
|
@@ -95,6 +95,8 @@ export default function statusBar(state, ui) {
|
|
|
95
95
|
const { totalItems, totalBytes } = f;
|
|
96
96
|
const caps = state.caps;
|
|
97
97
|
const active = f.uploading;
|
|
98
|
+
const transfers = state.tr?.items || [];
|
|
99
|
+
const trayHidden = !!state.vs?.transfersDismissed;
|
|
98
100
|
|
|
99
101
|
// Plugin-contributed status slots. Already filtered and ordered by the query.
|
|
100
102
|
const slots = state.statusItems || [];
|
|
@@ -141,12 +143,24 @@ export default function statusBar(state, ui) {
|
|
|
141
143
|
}));
|
|
142
144
|
} else ui.engine.dispatch(new ExecCommandAction('workbench.view.home'));
|
|
143
145
|
} }),
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
// The way back to the tray, and the signal that something is happening.
|
|
147
|
+
//
|
|
148
|
+
// Shown whenever there is anything to LOOK AT, not only while something is active: a
|
|
149
|
+
// failed upload from two minutes ago is the case where someone most wants the panel
|
|
150
|
+
// back, and it used to disappear the moment the transfer stopped.
|
|
151
|
+
//
|
|
152
|
+
// A click toggles the panel rather than opening a cancel menu. Cancelling one transfer
|
|
153
|
+
// is a thing to do in the panel, next to the transfer; the status bar's job is to say
|
|
154
|
+
// "there is transfer activity" and to be the handle for it.
|
|
155
|
+
transfers.length
|
|
156
|
+
? button({
|
|
157
|
+
className: `seg ${trayHidden ? '' : 'on'}`,
|
|
158
|
+
title: trayHidden ? 'Show transfers' : 'Hide transfers',
|
|
159
|
+
'aria-pressed': trayHidden ? 'false' : 'true',
|
|
160
|
+
},
|
|
161
|
+
active.length ? div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }) : icon('upload', { size: 12 }),
|
|
162
|
+
span(active.length ? `${active.length} uploading` : `${transfers.length} transfer${transfers.length === 1 ? '' : 's'}`))
|
|
163
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.transfers.toggle')) })
|
|
150
164
|
: span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
|
|
151
165
|
`${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
|
|
152
166
|
...left,
|
|
@@ -22,7 +22,7 @@ import { activate } from '../../activate.js';
|
|
|
22
22
|
|
|
23
23
|
const { div, span, img } = dd;
|
|
24
24
|
|
|
25
|
-
export function gridView({ groups, index, handlers, ui }) {
|
|
25
|
+
export function gridView({ groups, index, handlers, state, ui }) {
|
|
26
26
|
let gi = -1;
|
|
27
27
|
return div({ className: 'launch-view view-grid' },
|
|
28
28
|
...groups.map((group) => div({ className: 'launch-group' },
|
|
@@ -33,7 +33,7 @@ export function gridView({ groups, index, handlers, ui }) {
|
|
|
33
33
|
return tile(it, at === index, {
|
|
34
34
|
hover: () => handlers.hover(at),
|
|
35
35
|
select: () => handlers.select(at),
|
|
36
|
-
}, ui);
|
|
36
|
+
}, ui, state);
|
|
37
37
|
}))
|
|
38
38
|
: div({ className: 'launch-empty' }, group.empty || 'Nothing here.'),
|
|
39
39
|
)),
|
|
@@ -80,7 +80,7 @@ function columns() {
|
|
|
80
80
|
return n || 1;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function tile(it, active, { hover, select }, ui) {
|
|
83
|
+
function tile(it, active, { hover, select }, ui, state) {
|
|
84
84
|
const node = it.node;
|
|
85
85
|
// A contributed thumbnail beats the file itself. It is already a picture of the right
|
|
86
86
|
// size, it needs no minted URL, and for anything that is not an image it is the only
|
|
@@ -118,7 +118,7 @@ function tile(it, active, { hover, select }, ui) {
|
|
|
118
118
|
}).opaque()
|
|
119
119
|
: null,
|
|
120
120
|
it.badge ? span({ className: 'gt-badge' }, it.badge) : null,
|
|
121
|
-
menuButton(it, ui, select),
|
|
121
|
+
menuButton(it, ui, select, state),
|
|
122
122
|
),
|
|
123
123
|
div({ className: 'gt-name' }, it.title),
|
|
124
124
|
).on({
|
|
@@ -12,7 +12,7 @@ import { activate } from '../../activate.js';
|
|
|
12
12
|
|
|
13
13
|
const { div, span } = dd;
|
|
14
14
|
|
|
15
|
-
export function listView({ groups, index, handlers, ui }) {
|
|
15
|
+
export function listView({ groups, index, handlers, state, ui }) {
|
|
16
16
|
let gi = -1;
|
|
17
17
|
return div({ className: 'launch-view view-list' },
|
|
18
18
|
...groups.map((group) => div({ className: 'launch-group' },
|
|
@@ -23,14 +23,14 @@ export function listView({ groups, index, handlers, ui }) {
|
|
|
23
23
|
return itemRow(it, at === index, {
|
|
24
24
|
hover: () => handlers.hover(at),
|
|
25
25
|
select: () => handlers.select(at),
|
|
26
|
-
}, ui);
|
|
26
|
+
}, ui, state);
|
|
27
27
|
}))
|
|
28
28
|
: div({ className: 'launch-empty' }, group.empty || 'Nothing here.'),
|
|
29
29
|
)),
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function itemRow(it, active, { hover, select }, ui) {
|
|
33
|
+
function itemRow(it, active, { hover, select }, ui, state) {
|
|
34
34
|
return div({ className: `launch-item ${active ? 'active' : ''}` },
|
|
35
35
|
icon(it.icon, { size: 15 }),
|
|
36
36
|
span({ className: 'name' }, it.title),
|
|
@@ -39,7 +39,7 @@ function itemRow(it, active, { hover, select }, ui) {
|
|
|
39
39
|
// Everything you can do to a file, on the file. Rename, download, copy link and
|
|
40
40
|
// delete were all commands with no way to reach them: the palette's versions act on
|
|
41
41
|
// "the selection", and until the highlight became a selection there never was one.
|
|
42
|
-
menuButton(it, ui, select),
|
|
42
|
+
menuButton(it, ui, select, state),
|
|
43
43
|
// One `.on()` call: a second replaces the handler map rather than merging into it,
|
|
44
44
|
// which is how adding `contextmenu` silently removed `click` and stopped every file
|
|
45
45
|
// in the drive from opening.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { dd } from '../../../runtime.js';
|
|
10
10
|
import { icon } from '../../icon.js';
|
|
11
|
-
import { ShowContextMenuAction } from '../../../bl/actions.js';
|
|
11
|
+
import { ShowContextMenuAction, ToggleItemSelectedAction, ExitSelectionModeAction, ExecCommandAction } from '../../../bl/actions.js';
|
|
12
12
|
import { activate } from '../../activate.js';
|
|
13
13
|
|
|
14
14
|
const { div, span, button } = dd;
|
|
@@ -41,8 +41,21 @@ export function groupHeader(group, ui) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** The "…" button, for items that have a menu. */
|
|
44
|
-
export function menuButton(it, ui, select) {
|
|
44
|
+
export function menuButton(it, ui, select, state) {
|
|
45
45
|
if (!it.menu) return null;
|
|
46
|
+
// IN SELECTION MODE the `⋯` becomes this item's checkbox, in the same place. Putting the
|
|
47
|
+
// checkbox somewhere else and leaving `⋯` where it was would mean the thing under the
|
|
48
|
+
// cursor is no longer the thing it was a moment ago — and the per-item menu is the wrong
|
|
49
|
+
// verb in a mode where actions apply to everything picked. Those live in the bar above.
|
|
50
|
+
if (state?.ex?.bulk) {
|
|
51
|
+
const on = (state.ex.selection || []).includes(it.node?.id);
|
|
52
|
+
return button({
|
|
53
|
+
className: `launch-more check ${on ? 'on' : ''}`,
|
|
54
|
+
title: on ? `Deselect ${it.title}` : `Select ${it.title}`,
|
|
55
|
+
$attrs: { 'aria-label': on ? `Deselect ${it.title}` : `Select ${it.title}`, 'aria-pressed': on ? 'true' : 'false', role: 'checkbox', 'aria-checked': on ? 'true' : 'false' },
|
|
56
|
+
}, icon(on ? 'check' : 'square', { size: 14 }))
|
|
57
|
+
.on({ click: (e) => { e.stopPropagation(); ui.engine.dispatch(new ToggleItemSelectedAction(it.node)); } });
|
|
58
|
+
}
|
|
46
59
|
return button({
|
|
47
60
|
className: 'launch-more',
|
|
48
61
|
title: `Actions for ${it.title}`,
|
|
@@ -51,6 +64,38 @@ export function menuButton(it, ui, select) {
|
|
|
51
64
|
.on({ click: (e) => { e.stopPropagation(); openRowMenu(e.currentTarget, it, ui, null, select); } });
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The bar over a selection: what is picked, and what can be done to all of it.
|
|
69
|
+
*
|
|
70
|
+
* Above the list rather than on a row, because "act on these six" is a different verb from
|
|
71
|
+
* "act on this one" and should not share a place with it. Only actions that MEAN something
|
|
72
|
+
* over many items appear — rename and open-with are single-item verbs and are absent
|
|
73
|
+
* rather than present and disabled, which reads as broken rather than as inapplicable.
|
|
74
|
+
*/
|
|
75
|
+
export function selectionBar(state, ui) {
|
|
76
|
+
if (!state?.ex?.bulk) return null;
|
|
77
|
+
const n = (state.ex.selection || []).length;
|
|
78
|
+
const act = (label, iconName, command, danger = false) => button({
|
|
79
|
+
className: `sel-act ${danger ? 'danger' : ''}`,
|
|
80
|
+
disabled: !n,
|
|
81
|
+
title: n ? `${label} ${n} item${n === 1 ? '' : 's'}` : `Pick something first`,
|
|
82
|
+
}, icon(iconName, { size: 14 }), span(label))
|
|
83
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction(command)) });
|
|
84
|
+
|
|
85
|
+
return div({ className: 'sel-bar' },
|
|
86
|
+
// Says what is selected, including when that is nothing: pressing Delete on an empty
|
|
87
|
+
// selection should visibly do nothing, and the count is what makes that unsurprising.
|
|
88
|
+
span({ className: 'sel-count' }, n ? `${n} selected` : 'Nothing selected'),
|
|
89
|
+
div({ className: 'sel-actions' },
|
|
90
|
+
act('Download', 'download', 'explorer.download'),
|
|
91
|
+
act('Keep offline', 'download', 'offline.pin'),
|
|
92
|
+
act('Delete', 'trash', 'explorer.delete', true),
|
|
93
|
+
),
|
|
94
|
+
button({ className: 'sel-done', title: 'Done selecting (Esc)' }, icon('close', { size: 14 }), span('Done'))
|
|
95
|
+
.on({ click: () => ui.engine.dispatch(new ExitSelectionModeAction()) }),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
54
99
|
/**
|
|
55
100
|
* Open an item's menu, anchored to the thing it belongs to.
|
|
56
101
|
*
|
|
@@ -125,6 +125,8 @@ export default function workbench({ engine, platform }) {
|
|
|
125
125
|
// than by the status bar and imported from there by the phone chrome, which is what it
|
|
126
126
|
// was — a business-layer derivation living in a component. See bl/status.js.
|
|
127
127
|
facts: q.statusFacts,
|
|
128
|
+
// Whether the transfer tray is dismissed — the status bar is the way back to it.
|
|
129
|
+
vs: q.viewState,
|
|
128
130
|
};
|
|
129
131
|
// A query boots asynchronously — it awaits a container lease first — so a region is
|
|
130
132
|
// PENDING for the first frame or two and `watch` renders its placeholder. For a bar with
|
|
@@ -137,7 +139,7 @@ export default function workbench({ engine, platform }) {
|
|
|
137
139
|
// Both render nothing when there is nothing to show, so an absent first frame is what
|
|
138
140
|
// they would have drawn anyway.
|
|
139
141
|
phoneSheet: region(engine, chrome, (s) => phoneSheet(s, ui)),
|
|
140
|
-
transferTray: region(engine, { tr: q.transfers }, (s) => transferTray(s, ui)),
|
|
142
|
+
transferTray: region(engine, { tr: q.transfers, vs: q.viewState }, (s) => transferTray(s, ui)),
|
|
141
143
|
// Both read one slice each, and both change often enough to matter: a toast arriving
|
|
142
144
|
// or auto-dismissing used to rebuild the entire shell — including every row of the
|
|
143
145
|
// file list — to add a line in the corner.
|