@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.
@@ -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 (/^(blob:|data:image\/)/i.test(src)) return true;
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) return null;
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
- active.length
145
- ? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
146
- .on({ click: (e) => ui.engine.dispatch(new ShowContextMenuAction(active.map((t) => ({
147
- label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
148
- actions: [new CancelTransferAction(t.id)],
149
- })), { x: e.clientX, y: e.clientY })) })
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.