feat(downloads): browsable downloaded library with on-disk usage

Replace the flat download list with a Downloaded browse surface that
reuses the online grids/cards/detail pages, filtered to on-device media,
plus a demoted Transfers tab. Add repository browse commands
(getDownloadedLibraries/Items, disk usage) with offline/hybrid
implementations, a downloadedCatalog service, formatBytes helper, and
per-item/device disk-usage labels on cards and grids. Regenerated
bindings.

Also carries the inseparable UR-052 offline-filter hunks in
offline.rs/hybrid.rs.

TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
This commit is contained in:
2026-07-23 20:02:55 +02:00
parent 8f4f651bac
commit f25deba824
14 changed files with 1418 additions and 224 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { formatBytes } from "./formatBytes";
// TRACES: UR-056 | DR-085 | UT-050
describe("formatBytes", () => {
it("renders zero and non-positive as '0 B'", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(NaN)).toBe("0 B");
expect(formatBytes(Infinity)).toBe("0 B");
});
it("renders whole bytes below 1 KB", () => {
expect(formatBytes(1)).toBe("1 B");
expect(formatBytes(999)).toBe("999 B");
});
it("crosses to KB at 1000 bytes (decimal units)", () => {
expect(formatBytes(1000)).toBe("1 KB");
expect(formatBytes(1500)).toBe("1.5 KB");
});
it("shows 2-3 significant figures", () => {
expect(formatBytes(340_000_000)).toBe("340 MB");
expect(formatBytes(1_200_000_000)).toBe("1.2 GB");
expect(formatBytes(48_000_000)).toBe("48 MB");
});
it("trims trailing zeros", () => {
expect(formatBytes(2_000_000_000)).toBe("2 GB");
expect(formatBytes(10_000_000)).toBe("10 MB");
});
it("scales into large units", () => {
expect(formatBytes(3_400_000_000)).toBe("3.4 GB");
expect(formatBytes(1_000_000_000_000)).toBe("1 TB");
});
it("uses no decimals at or above 100 of a unit", () => {
// 123.4 MB → "123 MB" (2-3 sig figs, whole number band)
expect(formatBytes(123_400_000)).toBe("123 MB");
});
});
+50
View File
@@ -0,0 +1,50 @@
// Shared byte-size formatter for the Downloads surface.
//
// One formatter, used everywhere disk usage is shown (cards, detail pages, the
// device total, and the remove-reclaim prompt) so units are consistent. We use
// DECIMAL units (1 GB = 1000 MB), matching how phone storage screens and file
// browsers present sizes, and show 23 significant figures.
//
// TRACES: UR-056 | DR-085
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
/**
* Format a byte count as a human-readable size string (decimal units).
*
* Examples: 0 → "0 B", 340_000_000 → "340 MB", 1_200_000_000 → "1.2 GB".
*
* - Bytes render as whole numbers (no "0.5 B").
* - KB and above show enough decimals for 23 significant figures: values
* ≥ 100 render with no decimals, ≥ 10 with one, otherwise two.
* - Negative / non-finite inputs are treated as 0 (sizes are never negative).
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unitIndex = 0;
while (value >= 1000 && unitIndex < UNITS.length - 1) {
value /= 1000;
unitIndex += 1;
}
// Bytes are always whole; larger units get 23 significant figures.
let formatted: string;
if (unitIndex === 0) {
formatted = Math.round(value).toString();
} else if (value >= 100) {
formatted = Math.round(value).toString();
} else if (value >= 10) {
formatted = value.toFixed(1);
} else {
formatted = value.toFixed(2);
}
// Trim trailing zeros ("1.20" → "1.2", "1.00" → "1") for a cleaner label.
if (formatted.includes(".")) {
formatted = formatted.replace(/\.?0+$/, "");
}
return `${formatted} ${UNITS[unitIndex]}`;
}