summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 13:13:08 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 13:13:08 +0200
commit41e2b79cb1bc9d188853aeff5a55cd2237268587 (patch)
treebe19e833e633d23cd42068ce40b8ab9baabed532 /packages/meshbay-hub/src/meshbay_hub/static/transfers.js
parent8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (diff)
downloadmeshbay-41e2b79cb1bc9d188853aeff5a55cd2237268587.tar.gz
feat(files): transfers that outlive the page, and selection instead of per-row menus
Downloads and uploads were state inside GroupPage. Leaving a group unmounted the component, its cleanup closed the DataChannel, and a half-written file was all you had — which is also why only one thing could be in flight at a time. They live in a module-level store now. A group page hands its transport over on the way out rather than closing it, and the last transfer using it closes it; signing out is the one thing that cancels everything, because those transfers are moving data on a token about to stop being ours. The store is plain JavaScript with no browser globals, so test_transfers.py runs it under Node and pins the parts that are timing and lifetime rather than markup: that a cancel stops the work instead of greying out a row, that a stalled transfer reads as stalled rather than reporting its own historical average, and that a released transport is closed by the last transfer and not before. The widget by the bell shows each transfer with its rate and a cancel button, so the Files panel no longer carries progress bars — you can watch a 40 GB archive from the chat, or from another group. Selection replaces the per-row menu: a Select toggle puts checkboxes on files and folders, and ⋮ Actions acts on what is ticked. Ticks survive walking into another folder, so a selection can span directories. Downloads start together and run together. Videos offer Play only — View did the same thing, which is the sort of duplication that makes people wonder what the difference is. Uploads had to become parallel-safe for any of this to mean anything: their acks were matched by arrival order, so two at once credited each other's progress. The node names the file in every ack, so they are keyed by name now — with the same file twice refused, since the node keys its own upload state that way too. Two mistakes worth recording. The selection column went into the body rows and not the header, because that edit matched nothing and I had not made it assert; the columns were misaligned until a screenshot showed it. And the Actions menu opened leftwards from a button at the right edge of the toolbar, half of it off-screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transfers.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js203
1 files changed, 203 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
new file mode 100644
index 0000000..1ad1ec5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -0,0 +1,203 @@
+/**
+ * Transfers that outlive the page that started them.
+ *
+ * Downloads and uploads used to be state inside GroupPage, which meant leaving
+ * a group killed them — the component unmounted, its effect closed the
+ * DataChannel, and a half-written file was all you had. They live here instead:
+ * a module-level store that nothing unmounts, with the group page as one of
+ * several possible views onto it.
+ *
+ * Two consequences worth stating, because they are the reason this exists:
+ *
+ * - The transport cannot be closed just because a page went away. A group
+ * page hands its transport over with `releaseWhenIdle()`, and the last
+ * transfer using it closes it.
+ * - Signing out is different from navigating. It cancels everything and
+ * closes what it was using, because the tokens those transfers are running
+ * on are about to stop being ours.
+ *
+ * No browser globals: exercised under Node by
+ * packages/meshbay-hub/tests/test_transfers.py.
+ */
+
+const SPEED_WINDOW_MS = 5000;
+
+let _nextId = 1;
+
+export class TransferStore {
+ constructor(now = () => Date.now()) {
+ this._now = now;
+ this._items = [];
+ this._subs = new Set();
+ this._releasing = new Set();
+ }
+
+ subscribe(fn) {
+ this._subs.add(fn);
+ return () => this._subs.delete(fn);
+ }
+
+ _emit() {
+ for (const fn of this._subs) fn(this.list());
+ }
+
+ /**
+ * What a view needs to render, as plain data — never the internals, so a
+ * render cannot accidentally hold a transport alive.
+ */
+ list() {
+ return this._items.map(it => ({
+ id: it.id,
+ kind: it.kind,
+ name: it.name,
+ total: it.total,
+ done: it.done,
+ status: it.status,
+ error: it.error || '',
+ speed: this._speed(it),
+ percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0,
+ }));
+ }
+
+ get active() {
+ return this._items.filter(it => it.status === 'running').length;
+ }
+
+ _speed(it) {
+ // Over a window rather than since the start: a transfer that stalls should
+ // read as slow immediately, not as its own historical average.
+ const s = it.samples;
+ if (s.length < 2) return 0;
+ const dt = (s[s.length - 1].t - s[0].t) / 1000;
+ if (dt <= 0) return 0;
+ return (s[s.length - 1].done - s[0].done) / dt;
+ }
+
+ /**
+ * Start a transfer.
+ *
+ * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a
+ * cancel that only sets a flag nobody reads is a button that lies.
+ */
+ start({ kind, name, total = 0, transport = null, run }) {
+ const item = {
+ id: _nextId++,
+ kind, name, total, transport,
+ done: 0,
+ status: 'running',
+ error: '',
+ samples: [{ t: this._now(), done: 0 }],
+ signal: { aborted: false },
+ };
+ this._items.push(item);
+ this._emit();
+
+ const onProgress = (done, total) => {
+ item.done = done;
+ if (total) item.total = total;
+ const t = this._now();
+ item.samples.push({ t, done });
+ while (item.samples.length > 2 && t - item.samples[0].t > SPEED_WINDOW_MS) {
+ item.samples.shift();
+ }
+ this._emit();
+ };
+
+ const finish = (status, error = '') => {
+ item.status = status;
+ item.error = error;
+ this._emit();
+ this._maybeRelease(item.transport);
+ };
+
+ const promise = Promise.resolve()
+ .then(() => run({ signal: item.signal, onProgress }))
+ .then(() => {
+ if (item.signal.aborted) finish('cancelled');
+ else {
+ if (item.total) item.done = item.total;
+ finish('done');
+ }
+ })
+ .catch(err => {
+ if (item.signal.aborted || err.name === 'AbortError') finish('cancelled');
+ else finish('failed', err.message || String(err));
+ });
+
+ item.promise = promise;
+ return item.id;
+ }
+
+ cancel(id) {
+ const item = this._items.find(it => it.id === id);
+ if (!item || item.status !== 'running') return;
+ item.signal.aborted = true;
+ // Marked at once. The work stops when it next looks, but a cancelled
+ // transfer should not keep reporting progress in the meantime.
+ item.status = 'cancelled';
+ this._emit();
+ this._maybeRelease(item.transport);
+ }
+
+ cancelAll() {
+ for (const it of this._items) {
+ if (it.status === 'running') this.cancel(it.id);
+ }
+ }
+
+ /** Drop everything finished, keeping what is still running. */
+ clearFinished() {
+ this._items = this._items.filter(it => it.status === 'running');
+ this._emit();
+ }
+
+ _busy(transport) {
+ return this._items.some(
+ it => it.transport === transport && it.status === 'running');
+ }
+
+ /**
+ * The group page is going away. Close its transport once nothing is using it,
+ * which may be now or may be in twenty minutes.
+ */
+ releaseWhenIdle(transport) {
+ if (!transport) return;
+ this._releasing.add(transport);
+ this._maybeRelease(transport);
+ }
+
+ _maybeRelease(transport) {
+ if (!transport || !this._releasing.has(transport)) return;
+ if (this._busy(transport)) return;
+ this._releasing.delete(transport);
+ try {
+ transport.onIndexSync = null;
+ transport.close();
+ } catch { /* already gone */ }
+ }
+
+ /** Signing out: stop everything and let go of every transport. */
+ reset() {
+ this.cancelAll();
+ for (const transport of [...this._releasing]) {
+ this._releasing.delete(transport);
+ try { transport.close(); } catch { /* already gone */ }
+ }
+ for (const it of this._items) {
+ if (it.transport) {
+ try { it.transport.close(); } catch { /* already gone */ }
+ }
+ }
+ this._items = [];
+ this._emit();
+ }
+}
+
+export const transfers = new TransferStore();
+
+/** Human-readable rate, for a widget that updates several times a second. */
+export function formatSpeed(bytesPerSecond) {
+ if (!bytesPerSecond || bytesPerSecond < 1) return '';
+ if (bytesPerSecond < 1024 * 1024) return `${Math.round(bytesPerSecond / 1024)} KB/s`;
+ return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
+}