From 717a951de7eeb75fc731a49e73190073b33206e3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:04:23 -0600 Subject: [PATCH] File transfer: show the bytes moved inside one large file A single big file used to show only elapsed time. The status line now says how far into the file the transfer is, for example 315.5 KB of 512.0 KB, 61%, from the byte counts the sidecar already sends. The browser panel and the VS Code extension both show it. Checked with a 512 KB put to a board over USB serial: the line stepped from 0% to 96% as the file moved, where before it only counted seconds. The new tests fail without the change. --- ROADMAP.md | 2 -- cli/src/mpftp/panel.py | 38 +++++++++++++++++++-- cli/tests/test_transfer_progress.py | 25 ++++++++++++++ extension/src/test/transferProgress.test.ts | 9 +++++ extension/src/transferProgress.ts | 23 +++++++++++++ extension/src/webview/FtpViewProvider.ts | 14 ++++++-- 6 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 extension/src/test/transferProgress.test.ts create mode 100644 extension/src/transferProgress.ts diff --git a/ROADMAP.md b/ROADMAP.md index 52ceb94..190e614 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,8 +13,6 @@ if something is broken or missing for you, open an libraries, for distributions the `.deb` doesn't cover. - **Desktop updates itself.** Today it links to the latest release; an updater would fetch and install a new version for you. -- **Progress inside a large file.** A long transfer of one big file shows - only elapsed time; a percentage and the bytes moved would show how far along it is. - **Where a search result comes from, in the row.** Package search in the browser shows a result's index and install command only on hover, so two results with one name look alike until you point at them. diff --git a/cli/src/mpftp/panel.py b/cli/src/mpftp/panel.py index d10f4eb..95f055e 100644 --- a/cli/src/mpftp/panel.py +++ b/cli/src/mpftp/panel.py @@ -95,6 +95,23 @@ class SidecarError(RuntimeError): """An error reply from the sidecar.""" +def format_bytes(n: int) -> str: + """``n`` bytes as B, KB or MB, one decimal from KB up.""" + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.1f} MB" + + +def bytes_part(done: int, total: int) -> str: + """How far into one file a transfer is: ``1.2 of 3.4 MB, 35%``.""" + if total <= 0: + return format_bytes(done) + pct = min(100, int(done * 100 / total)) + return f"{format_bytes(done)} of {format_bytes(total)}, {pct}%" + + class TransferMonitor: """Per-file transfer progress, reporting every ``tick`` seconds whether the work is still moving or looks hung (no file finished for ``stall``).""" @@ -114,6 +131,9 @@ def __init__( self.tick = tick self.done = 0 self.current = "" + #: Bytes moved so far in the current file and its size, from the + #: sidecar's ``transfer_progress`` notes; None until the first one. + self.file_bytes: Optional[tuple[int, int]] = None self.started_at = time.monotonic() self.last_activity = time.monotonic() self._stop = threading.Event() @@ -131,15 +151,19 @@ def _loop(self) -> None: def begin_file(self, label: str) -> None: self.current = label + self.file_bytes = None self.last_activity = time.monotonic() self.emit() - def note_progress(self) -> None: + def note_progress(self, done: Optional[int] = None, total: Optional[int] = None) -> None: """Bytes moved inside the current file: the work is not hung.""" self.last_activity = time.monotonic() + if done is not None and total is not None: + self.file_bytes = (done, total) def finish_file(self) -> None: self.done += 1 + self.file_bytes = None self.last_activity = time.monotonic() self.emit() @@ -165,7 +189,10 @@ def emit(self) -> None: "stalled", ) return - self.on_update(f"{self.verb} {file_part} ({counts}) — in progress · {elapsed}s", "active") + where = f" — {bytes_part(*self.file_bytes)}" if self.file_bytes else "" + self.on_update( + f"{self.verb} {file_part} ({counts}){where} — in progress · {elapsed}s", "active" + ) class PanelHost: @@ -809,7 +836,12 @@ def transfer_progress(self, params: Any = None) -> None: """The sidecar's ``transfer_progress`` note: bytes are moving.""" monitor = self._monitor if monitor is not None: - monitor.note_progress() + p = params if isinstance(params, dict) else {} + done, total = p.get("done"), p.get("total") + if isinstance(done, int) and isinstance(total, int): + monitor.note_progress(done, total) + else: + monitor.note_progress() def upload_many(self, tab: Any, local_paths: list[str]) -> None: if not self.connected: diff --git a/cli/tests/test_transfer_progress.py b/cli/tests/test_transfer_progress.py index 0d15136..0202f07 100644 --- a/cli/tests/test_transfer_progress.py +++ b/cli/tests/test_transfer_progress.py @@ -48,6 +48,31 @@ def test_no_bytes_for_the_stall_window_is_still_reported_hung(self): self.assertIn("stalled", updates) +class BytesInOneFileTests(unittest.TestCase): + def test_the_status_line_says_how_far_into_the_file_it_is(self): + updates = [] + m = panel.TransferMonitor("Uploading", 1, lambda text, phase: updates.append(text), tick=60) + m.start() + m.begin_file("/fw.bin") + host = panel.PanelHost(lambda *a, **k: None, lambda *a: True, lambda: [], ".") + host._monitor = m + host.transfer_progress({"path": "/fw.bin", "done": 1024 * 1024, "total": 4 * 1024 * 1024}) + m.emit() + m.dispose() + self.assertIn("1.0 MB of 4.0 MB, 25%", updates[-1]) + m.finish_file() # the next file starts with no figure + self.assertIsNone(m.file_bytes) + + def test_a_note_without_sizes_still_counts_as_activity(self): + m = panel.TransferMonitor("Uploading", 1, lambda *a: None, tick=60) + m.begin_file("/a") + host = panel.PanelHost(lambda *a, **k: None, lambda *a: True, lambda: [], ".") + host._monitor = m + host.transfer_progress({}) + m.dispose() + self.assertIsNone(m.file_bytes) + + class SidecarProgressTests(unittest.TestCase): def test_fs_read_reports_bytes_while_the_file_moves(self): class T: diff --git a/extension/src/test/transferProgress.test.ts b/extension/src/test/transferProgress.test.ts new file mode 100644 index 0000000..b48b006 --- /dev/null +++ b/extension/src/test/transferProgress.test.ts @@ -0,0 +1,9 @@ +import { strict as assert } from "assert"; +import { test } from "node:test"; +import { bytesPart } from "../transferProgress"; + +test("bytes inside one file show as a size and a percentage", () => { + assert.equal(bytesPart(1.2 * 1024 * 1024, 3.4 * 1024 * 1024), "1.2 MB of 3.4 MB, 35%"); + assert.equal(bytesPart(512, 2048), "512 B of 2.0 KB, 25%"); + assert.equal(bytesPart(300, 0), "300 B"); +}); diff --git a/extension/src/transferProgress.ts b/extension/src/transferProgress.ts new file mode 100644 index 0000000..a95f8d1 --- /dev/null +++ b/extension/src/transferProgress.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Brad Barnett +// +// SPDX-License-Identifier: MIT + +/** `n` bytes as B, KB or MB, one decimal from KB up. */ +export function formatBytes(n: number): string { + if (n < 1024) { + return `${n} B`; + } + if (n < 1024 * 1024) { + return `${(n / 1024).toFixed(1)} KB`; + } + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** How far into one file a transfer is: `1.2 of 3.4 MB, 35%`. */ +export function bytesPart(done: number, total: number): string { + if (total <= 0) { + return formatBytes(done); + } + const pct = Math.min(100, Math.floor((done * 100) / total)); + return `${formatBytes(done)} of ${formatBytes(total)}, ${pct}%`; +} diff --git a/extension/src/webview/FtpViewProvider.ts b/extension/src/webview/FtpViewProvider.ts index 9234dfd..dceab02 100644 --- a/extension/src/webview/FtpViewProvider.ts +++ b/extension/src/webview/FtpViewProvider.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode"; import { DirEntry, SidecarBridge } from "../bridge/SidecarBridge"; import { openBoardFileInEditor } from "../editRemote"; import { getConfig } from "../platform"; +import { bytesPart } from "../transferProgress"; function joinRemote(base: string, name: string): string { if (!base || base === "/") { @@ -55,6 +56,7 @@ type TransferPhase = "active" | "stalled" | "done" | "idle"; class TransferMonitor { private done = 0; private current = ""; + private fileBytes: { done: number; total: number } | undefined; private startedAt = Date.now(); private lastActivityAt = Date.now(); private timer: ReturnType | undefined; @@ -78,19 +80,24 @@ class TransferMonitor { beginFile(label: string): void { this.current = label; + this.fileBytes = undefined; this.lastActivityAt = Date.now(); this.emit(); } /** Bytes moved inside the current file: a slow transfer that is still going isn't hung. */ - noteProgress(): void { + noteProgress(params?: { done?: number; total?: number }): void { if (!this.disposed) { this.lastActivityAt = Date.now(); + if (typeof params?.done === "number" && typeof params?.total === "number") { + this.fileBytes = { done: params.done, total: params.total }; + } } } finishFile(): void { this.done += 1; + this.fileBytes = undefined; this.lastActivityAt = Date.now(); const increment = this.total > 0 ? 100 / this.total : 0; this.progress.report({ @@ -135,8 +142,9 @@ class TransferMonitor { }); return; } + const where = this.fileBytes ? ` — ${bytesPart(this.fileBytes.done, this.fileBytes.total)}` : ""; this.onUpdate( - `${this.verb} ${filePart} (${counts}) — in progress · ${elapsedSec}s`, + `${this.verb} ${filePart} (${counts})${where} — in progress · ${elapsedSec}s`, "active" ); } @@ -335,7 +343,7 @@ export class FtpViewProvider implements vscode.WebviewViewProvider { return; } this.bridgeEventsBound = true; - this.bridge.on("transfer_progress", () => this.activeMonitor?.noteProgress()); + this.bridge.on("transfer_progress", (p) => this.activeMonitor?.noteProgress(p)); this.bridge.on("connected", () => { this.updateTitles(); void this.refreshDeviceInfo().then(() => {