Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 35 additions & 3 deletions cli/src/mpftp/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``)."""
Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions cli/tests/test_transfer_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions extension/src/test/transferProgress.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
23 changes: 23 additions & 0 deletions extension/src/transferProgress.ts
Original file line number Diff line number Diff line change
@@ -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}%`;
}
14 changes: 11 additions & 3 deletions extension/src/webview/FtpViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === "/") {
Expand Down Expand Up @@ -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<typeof setInterval> | undefined;
Expand All @@ -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({
Expand Down Expand Up @@ -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"
);
}
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading