2026-08-23 09:39:54 -07:00
|
|
|
/**
|
|
|
|
|
* The one byte formatter.
|
|
|
|
|
*
|
2026-08-23 09:51:09 -07:00
|
|
|
* The app had four of them — `projects/home/format.ts`,
|
2026-08-23 09:39:54 -07:00
|
|
|
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
|
|
|
|
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
2026-08-23 11:11:43 -07:00
|
|
|
* unit labels and the precision. All four now delegate here, and there are no
|
|
|
|
|
* remaining copies.
|
2026-08-23 09:51:09 -07:00
|
|
|
*
|
2026-08-23 11:11:43 -07:00
|
|
|
* The last two were held back because re-pointing them changes what they
|
|
|
|
|
* render, and that turned out to be the argument for doing it rather than
|
|
|
|
|
* against. `UpdateDialog` rendered KB at `toFixed(0)` (`512 KB` is now
|
|
|
|
|
* `512.0 KB`, consistent with every other size in the app) and both stopped
|
|
|
|
|
* the ladder at MB, so a 2 GB asset or backup read as a five-digit number of
|
|
|
|
|
* megabytes. Both are `{ binary: true }`: they describe files, and a host file
|
|
|
|
|
* browser shows the ÷1024 figure for the same bytes.
|
2026-08-23 09:39:54 -07:00
|
|
|
*
|
|
|
|
|
* ## Why the default is base 1000
|
|
|
|
|
*
|
|
|
|
|
* The Disk panel exists to explain what `docker system df` reports, and Docker
|
|
|
|
|
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
|
|
|
|
|
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
|
|
|
|
|
* same build cache would read as a bug in the panel. So decimal is the default
|
|
|
|
|
* and binary is opt-in, rather than the other way round.
|
|
|
|
|
*
|
2026-08-23 09:51:09 -07:00
|
|
|
* Both existing conventions are preserved for every size either call site can
|
|
|
|
|
* realistically produce — a file size or a payload size, i.e. a non-negative
|
|
|
|
|
* finite number below a terabyte. Outside that range this deliberately differs
|
|
|
|
|
* from what it replaced: a negative or `NaN` input now renders `—` rather than
|
|
|
|
|
* `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of
|
|
|
|
|
* stopping there.
|
2026-08-23 09:39:54 -07:00
|
|
|
*
|
|
|
|
|
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
|
|
|
|
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
|
|
|
|
* labels, what Project Home used
|
|
|
|
|
* — technically a misnomer, but
|
|
|
|
|
* it is the app's convention and
|
|
|
|
|
* changing it is not this
|
|
|
|
|
* feature's business)
|
|
|
|
|
* - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
|
|
|
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
|
|
|
|
|
|
|
|
export interface FormatBytesOptions {
|
|
|
|
|
/** Divide by 1024 instead of 1000. */
|
|
|
|
|
binary?: boolean;
|
|
|
|
|
/** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */
|
|
|
|
|
iec?: boolean;
|
|
|
|
|
/** Decimal places above `B`. Bytes are always whole. */
|
|
|
|
|
precision?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {
|
|
|
|
|
const { binary = false, iec = false, precision = 1 } = options;
|
|
|
|
|
|
|
|
|
|
// A negative or non-finite size is a bug upstream, not something to render as
|
|
|
|
|
// `NaN GB` in the middle of a table. Docker reports -1 for "not computed",
|
|
|
|
|
// and that is the case this actually catches.
|
|
|
|
|
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
|
|
|
|
|
|
|
|
|
const step = binary ? 1024 : 1000;
|
|
|
|
|
const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS;
|
|
|
|
|
|
|
|
|
|
let value = bytes;
|
|
|
|
|
let unit = 0;
|
|
|
|
|
while (value >= step && unit < units.length - 1) {
|
|
|
|
|
value /= step;
|
|
|
|
|
unit += 1;
|
|
|
|
|
}
|
2026-08-23 09:51:09 -07:00
|
|
|
|
|
|
|
|
// **Promote again if rounding pushed the value back up to a whole step.**
|
|
|
|
|
// `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then
|
|
|
|
|
// renders as "1000.0 KB" — a unit the loop had already decided against. The
|
|
|
|
|
// same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575
|
|
|
|
|
// → "1024.0 KB" in binary).
|
|
|
|
|
if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) {
|
|
|
|
|
value /= step;
|
|
|
|
|
unit += 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 09:39:54 -07:00
|
|
|
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
|
|
|
|
return unit === 0
|
|
|
|
|
? `${Math.round(bytes)} ${units[0]}`
|
|
|
|
|
: `${value.toFixed(precision)} ${units[unit]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
|
|
|
|
|
* measured. Used for "next commit adds …", which is the number that explains
|
|
|
|
|
* why a snapshot grows.
|
|
|
|
|
*/
|
|
|
|
|
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
|
|
|
|
|
const formatted = formatBytes(bytes, options);
|
|
|
|
|
return formatted === "—" ? formatted : `+${formatted}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-23 09:51:09 -07:00
|
|
|
* `up to 12.3 GB` — for a bound rather than a measurement.
|
2026-08-23 09:39:54 -07:00
|
|
|
*
|
|
|
|
|
* The Disk panel is careful about this distinction: every figure it shows is
|
|
|
|
|
* measured except a compaction's yield, which cannot be known until it runs.
|
|
|
|
|
* Rendering that one through a different function is what stops it being read
|
|
|
|
|
* as a promise.
|
|
|
|
|
*/
|
|
|
|
|
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
|
|
|
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
|
|
|
|
|
return `up to ${formatBytes(bytes, options)}`;
|
|
|
|
|
}
|