diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
| commit | 7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch) | |
| tree | 05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | 813d18424ec57963bb56e6f40824a2db0ccce50d (diff) | |
| parent | e6f895c473a0b19e7b186889c1836d3945bc880b (diff) | |
| download | meshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz | |
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day.
A node now caps how many transfers it runs at once (8 downloads, 8 uploads,
node-wide) and how many one member may run in one group (2 by default,
operator-signed). Beyond that the node answers "queued" and the client waits its
turn, visibly, in the transfers panel — and a slot that frees starts whatever is
next, skipping past a member who is at their own cap rather than letting them
stall everyone behind them.
Browsing is never subject to a slot: not the poster grid, not the covers, not
opening a photo to look at it. That is structural — a transfer is what the
transfers widget shows — and the exemption is bounded rather than open, at two
files in flight per session, because an exemption with no bound is a leaseless
branch under another name.
Transfers can be cancelled, and now paused and resumed. A paused one holds
nothing: its slot goes back at once and resuming rejoins the queue at the tail.
Uploads survive the connection that started them and resume where the node
stopped, asked for inside the seal rather than on a clear message. What they
leave behind when they are abandoned is reaped, which closes a disk leak that
predates this work.
MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the
desktop client checking `client.minimum` before connecting so an un-updated one
says "update" instead of failing every connection in a protocol vocabulary.
Fourteen defects were found on the way, eight of them by a person clicking
Download and pasting a console — none of which 2075 tests could reach. Section
12 of ~/next/improve-downloads.md is that report, including the three this work
introduced itself and the one that turned out to be caused by an instruction to
hard-reload after each deployment.
Node suite 1209 passed, hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 252 |
1 files changed, 201 insertions, 51 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c2858f4..dfd0aeb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -4,8 +4,9 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; -import { transfers, formatSpeed } from './transfers.js'; +import { transfers, formatSpeed, etaSeconds } from './transfers.js'; import * as platform from './platform.js'; +import * as downloads from './downloads.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; import { @@ -156,69 +157,80 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); + const waiting = items.filter( + i => i.status === 'queued' || i.status === 'preparing'); + // Its own group, and not a leftover. + // + // "Finished" used to be defined as everything that is not running, queued or + // preparing — a definition by exclusion, which quietly swallowed `paused` the + // day pausing shipped. A transfer somebody stopped on purpose then sat under + // "Finished", beside the ones that are actually over, offering a resume + // button in the section of things that cannot be resumed. + const paused = items.filter(i => i.status === 'paused'); + const finished = items.filter( + i => i.status !== 'running' && i.status !== 'queued' + && i.status !== 'preparing' && i.status !== 'paused'); + // Paused counts as active: it is not over, the person means to come back to + // it, and the badge saying nothing is happening would be a lie. + const active = running.length + waiting.length + paused.length; + + // Grouped, and in this order: what is moving, what is waiting, what is over. + // Re-sorting the flat list on every emit made rows jump under the pointer + // each time a neighbour finished — the group is what changes, not the + // position within it, so a row only moves when its own state does. + const groups = [ + ['running', running], + ['waiting', waiting], + ['paused', paused], + ['finished', finished], + ].filter(([, rows]) => rows.length); + if (!items.length) return null; return html` <div class="transfer-wrap" ref=${ref}> <button class="nav-notif transfer-btn ${running.length ? 'active' : ''}" + aria-label=${t('transfers.title')} + aria-expanded=${open ? 'true' : 'false'} title=${t('transfers.title')} onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}> <${Icon} name="transfer" /> - ${running.length > 0 && html` - <span class="notif-badge">${running.length}</span> + ${active > 0 && html` + <span class="notif-badge">${active}</span> `} </button> + ${/* One live region for the panel, announcing what changed state rather + than every progress tick — a reader that says "62%… 63%… 64%" for a + four-gigabyte film is a reader nobody leaves on. */''} + <span class="sr-only" aria-live="polite"> + ${t('transfers.summary', { running: running.length, waiting: waiting.length })} + </span> ${open && html` - <div class="transfer-panel"> + <div class="transfer-panel" role="group" + aria-label=${t('transfers.title')}> <div class="transfer-head"> - ${t('transfers.title')} - <button class="btn-secondary" - onClick=${() => transfers.clearFinished()}> - ${t('transfers.clear')} - </button> + <span class="transfer-head-title">${t('transfers.title')}</span> + ${active > 0 && html` + <span class="transfer-head-summary"> + ${t('transfers.summary', { + running: running.length, waiting: waiting.length })} + </span> + `} + ${finished.length > 0 && html` + <button class="btn-secondary" + onClick=${() => transfers.clearFinished()}> + ${t('transfers.clear')} + </button> + `} </div> - ${items.map(it => html` - <div class="transfer-item" key=${it.id}> - <div class="transfer-line"> - <span class="transfer-kind"> - <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> - </span> - ${it.canOpen - ? html`<a class="transfer-name" href="#" title=${it.name} - onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` - : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} - ${it.status === 'running' && html` - <button class="transfer-cancel" title=${t('transfers.cancel')} - onClick=${() => transfers.cancel(it.id)}> - <${Icon} name="close" /> - </button> - `} - </div> - ${it.status === 'running' - ? html` - <div class="dl-progress"> - <div class="dl-fill" style="width:${it.percent}%"></div> - </div> - <div class="transfer-meta"> - <span>${formatSize(it.done)}${it.total - ? ' / ' + formatSize(it.total) : ''}</span> - <span>${formatSpeed(it.speed)}</span> - </div> - ` - : html` - <div class="transfer-meta"> - <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> - ${it.status === 'done' ? t('transfers.done') - : it.status === 'cancelled' ? t('transfers.cancelled') - : it.error || t('transfers.failed')} - </span> - ${it.canOpen && html` - <button class="link-btn" onClick=${() => transfers.open(it.id)}> - ${t('transfers.open')} - </button> - `} - </div> - `} + ${groups.map(([label, rows]) => html` + <div class="transfer-group" key=${label}> + ${groups.length > 1 && html` + <div class="transfer-group-head"> + ${t('transfers.group_' + label, { n: rows.length })} + </div> + `} + ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)} </div> `)} </div> @@ -227,6 +239,136 @@ function TransferWidget() { `; } +/** One row. Split out so the panel above reads as a layout and this as a state + * machine — they change for different reasons. */ +function TransferRow({ it }) { + const eta = etaSeconds(it); + return html` + <div class="transfer-item transfer-${it.status}"> + <div class="transfer-line"> + <span class="transfer-kind" aria-hidden="true"> + <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> + </span> + ${it.canOpen + ? html`<a class="transfer-name" href="#" title=${it.name} + onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` + : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} + ${it.pausable && (it.status === 'running' || it.status === 'paused') + && html` + ${/* Offered only where the target can actually do it: a + service-worker stream is a download the browser already owns, + and a pause there would restart from zero. */''} + <button class="transfer-pause" + aria-label=${t(it.status === 'paused' + ? 'transfers.resume_one' : 'transfers.pause_one', + { name: it.name })} + title=${t(it.status === 'paused' + ? 'transfers.resume' : 'transfers.pause')} + onClick=${() => (it.status === 'paused' + ? transfers.resume(it.id) : transfers.pause(it.id))}> + <${Icon} name=${it.status === 'paused' ? 'play' : 'pause'} /> + </button> + `} + ${!it.pausable && downloads.SUPPORTED && it.kind === 'download' + && (it.status === 'running' || it.status === 'queued') && html` + ${/* Say why, rather than leaving a gap where a button is on the row + above. Without a granted folder this browser writes through the + service worker — a download it already owns, which cannot be + paused — so the button is absent for a reason nobody can see, + and an upload beside it has one. Shown only where choosing a + folder is actually possible: on Firefox and Safari there is no + folder to choose and this hint would be a lie. */''} + <span class="transfer-nopause" title=${t('transfers.not_pausable')} + aria-label=${t('transfers.not_pausable')}> + <${Icon} name="pause" /> + </span> + `} + ${(it.status === 'running' || it.status === 'queued' + || it.status === 'preparing' || it.status === 'paused') && html` + <button class="transfer-cancel" + aria-label=${t('transfers.cancel_one', { name: it.name })} + title=${t('transfers.cancel')} + onClick=${() => transfers.cancel(it.id)}> + <${Icon} name="close" /> + </button> + `} + </div> + ${it.status === 'preparing' + ? html` + ${/* Not a progress bar at 0%: nothing is wrong and nothing is + stalled, the download is still finding somewhere to write. The + row exists from the click precisely so this state is visible + instead of being an empty panel. */''} + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${t('transfers.preparing')}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'queued' + ? html` + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${it.queuedByOwnLimit + ? t('transfers.waiting_own_slots') + : t('transfers.waiting_node', { n: it.ahead })}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'paused' + ? html` + ${/* The bar keeps its fill: what has been written is still there, + and resuming continues from it rather than starting again. */''} + <div class="dl-progress" role="progressbar" + aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100"> + <div class="dl-fill dl-paused" style="width:${it.percent}%"></div> + </div> + <div class="transfer-meta"> + <span>${t('transfers.paused')}</span> + <span>${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''}</span> + </div> + ` + : it.status === 'running' + ? html` + <div class="dl-progress" role="progressbar" + aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100"> + <div class="dl-fill" style="width:${it.percent}%"></div> + </div> + <div class="transfer-meta"> + <span>${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''}</span> + <span>${[formatSpeed(it.speed), + it.settled && eta !== null ? formatEta(eta) : ''] + .filter(Boolean).join(' · ')}</span> + </div> + ` + : html` + <div class="transfer-meta"> + <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> + ${it.status === 'done' ? t('transfers.done') + : it.status === 'cancelled' ? t('transfers.cancelled') + : it.error || t('transfers.failed')} + </span> + ${it.canOpen && html` + <button class="link-btn" onClick=${() => transfers.open(it.id)}> + ${t('transfers.open')} + </button> + `} + </div> + `} + </div> + `; +} + +/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose + * speed varies is a number that is wrong most of the time and looks precise. */ +function formatEta(seconds) { + if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) }); + if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) }); + return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 }); +} + // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, @@ -941,6 +1083,14 @@ const trayLabels = () => ({ // falls back to English rather than rejecting, so this cannot strand the page. const mount = () => { render(html`<${App} />`, document.getElementById('app')); + // Get the download worker registered and this page under its control now, + // rather than inside the first click on Download. On Firefox and Safari it is + // the only unbounded way to write a file to disk, and it used to be + // registered lazily — so the first download of a session paid install, + // activate and claim while somebody watched, and a claim that missed its + // budget sent the file to a path that cannot hold a film. Fire-and-forget: + // nothing renders differently for it, and a failure is retried on demand. + downloads.primeServiceWorker(); // After the catalogue, so the labels are in the right language. A no-op in a // browser and on macOS. A language change reloads the page, which comes back // through here, so nothing else has to watch for it. |