301 lines
11 KiB
QML
301 lines
11 KiB
QML
pragma Singleton
|
|
|
|
// Storage: what is in this machine, how full it is, and what is filling it.
|
|
//
|
|
// Two reads with very different costs, kept apart on purpose:
|
|
//
|
|
// refresh() layout, usage, and drive health. Around 90ms -- lsblk plus one
|
|
// udisks call -- so the page opens with it and re-reads freely.
|
|
// scan() what is using the space. Measuring a folder means walking it,
|
|
// and a Steam library alone can be a terabyte, so this happens
|
|
// only when asked and the answer is kept until asked again.
|
|
//
|
|
// Not a stored preference: every value here is the machine's, not the user's.
|
|
//
|
|
// Deliberately absent: partitioning and formatting. Those stay in GNOME Disks,
|
|
// which the page can launch.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-disks"
|
|
|
|
property var drives: []
|
|
property var filesystems: []
|
|
property var swap: []
|
|
property bool scanned: false
|
|
property string lastError: ""
|
|
|
|
// The expensive half.
|
|
property var folders: []
|
|
property var containers: null
|
|
property bool scanning: false
|
|
property bool folderScanTruncated: false
|
|
// Empty until a scan has completed, which is what the page shows a prompt
|
|
// for rather than an empty list -- "nothing here" and "not measured yet"
|
|
// are different answers.
|
|
property bool foldersMeasured: false
|
|
|
|
// What the used space is made of, and what could be freed.
|
|
//
|
|
// { segments: { home, applications, caches, system, free }, usedBytes,
|
|
// totalBytes, freeBytes, complete, exceedsUsed }
|
|
//
|
|
// `system` is the remainder -- used bytes minus the three measured segments
|
|
// -- and the page calls it "System & everything else" for that reason. It
|
|
// is not a measurement of the system; it is everything the walk did not
|
|
// reach. The segments never sum past used, and when a measurement would
|
|
// overshoot, `exceedsUsed` says so instead of a number being scaled to make
|
|
// the bar look tidy.
|
|
property var breakdown: null
|
|
property bool breakdownMeasured: false
|
|
property bool measuringBreakdown: false
|
|
|
|
// [{ id, label, detail, bytes, privileged }]
|
|
//
|
|
// Nothing here is selected, ordered by urgency, or acted on. Each row is
|
|
// freed only by its own id being passed to clean(), which is what keeps a
|
|
// mis-wired button from emptying four things at once.
|
|
property var cleanables: []
|
|
property bool cleanablesMeasured: false
|
|
property bool measuringCleanables: false
|
|
// Which row is mid-clean, so the page can disable that one row.
|
|
property string cleaningId: ""
|
|
|
|
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
|
|
|
|
// The filesystem the user means when they ask how full the machine is.
|
|
readonly property var rootFilesystem: {
|
|
for (const filesystem of root.filesystems) {
|
|
if ((filesystem.mountpoints ?? []).includes("/"))
|
|
return filesystem;
|
|
}
|
|
return root.filesystems.length > 0 ? root.filesystems[0] : null;
|
|
}
|
|
|
|
// Bytes, in the units people actually read. Binary units with decimal-style
|
|
// names would be a lie in the other direction; this matches what df -h and
|
|
// the drive's own packaging say.
|
|
function formatBytes(bytes: real): string {
|
|
if (!(bytes > 0))
|
|
return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = bytes;
|
|
let index = 0;
|
|
while (value >= 1000 && index < units.length - 1) {
|
|
value /= 1000;
|
|
index += 1;
|
|
}
|
|
const decimals = value < 10 && index > 1 ? 1 : 0;
|
|
return value.toFixed(decimals) + " " + units[index];
|
|
}
|
|
|
|
function usedFraction(filesystem: var): real {
|
|
const size = Number(filesystem?.sizeBytes ?? 0);
|
|
if (!(size > 0))
|
|
return 0;
|
|
return Math.max(0, Math.min(1, Number(filesystem.usedBytes ?? 0) / size));
|
|
}
|
|
|
|
// "/ and /home" rather than two rows: btrfs subvolumes share one pool of
|
|
// free space, and showing them separately doubles it on screen.
|
|
function mountLabel(filesystem: var): string {
|
|
const points = filesystem?.mountpoints ?? [];
|
|
if (points.length === 0)
|
|
return String(filesystem?.device ?? "");
|
|
if (points.length === 1)
|
|
return points[0];
|
|
return points.slice(0, -1).join(", ") + " and " + points[points.length - 1];
|
|
}
|
|
|
|
function healthSummary(drive: var): string {
|
|
if (!drive)
|
|
return "";
|
|
if (drive.healthy === false)
|
|
return (drive.warnings ?? []).length > 0
|
|
? "Reporting " + drive.warnings.join(", ")
|
|
: "Reporting a failure";
|
|
if (drive.healthy === true)
|
|
return "No warnings";
|
|
return "Health not reported";
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (!query.running)
|
|
query.running = true;
|
|
}
|
|
|
|
function scan(): void {
|
|
if (root.scanning)
|
|
return;
|
|
root.scanning = true;
|
|
folderScan.running = true;
|
|
}
|
|
|
|
// The same walk `scan` does, so it costs the same and is asked for on
|
|
// demand rather than when the page opens.
|
|
function measureBreakdown(): void {
|
|
if (root.measuringBreakdown)
|
|
return;
|
|
root.measuringBreakdown = true;
|
|
breakdownScan.running = true;
|
|
}
|
|
|
|
function measureCleanables(): void {
|
|
if (root.measuringCleanables)
|
|
return;
|
|
root.measuringCleanables = true;
|
|
cleanableScan.running = true;
|
|
}
|
|
|
|
// Exactly one, named. An id this service has not been told about is
|
|
// refused here and refused again by the helper.
|
|
function clean(identifier: string): void {
|
|
if (cleaner.running || root.measuringCleanables)
|
|
return;
|
|
const known = root.cleanables.some(item => String(item.id) === String(identifier));
|
|
if (!known) {
|
|
root.lastError = "There is nothing by that name to clean up.";
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
root.cleaningId = String(identifier);
|
|
cleaner.command = [root.helperPath, "clean", String(identifier)];
|
|
cleaner.running = true;
|
|
}
|
|
|
|
function absorbCleanables(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
root.cleanables = Array.isArray(parsed) ? parsed : [];
|
|
root.cleanablesMeasured = true;
|
|
} catch (error) {
|
|
root.lastError = "Could not measure what could be cleaned up.";
|
|
console.warn("Disks: could not parse cleanables output:", error);
|
|
}
|
|
}
|
|
|
|
function unmount(devicePath: string): void {
|
|
root.runMedia(["unmount", devicePath]);
|
|
}
|
|
|
|
function eject(devicePath: string): void {
|
|
root.runMedia(["eject", devicePath]);
|
|
}
|
|
|
|
function runMedia(arguments: var): void {
|
|
if (media.running)
|
|
return;
|
|
root.lastError = "";
|
|
media.command = [root.helperPath].concat(arguments);
|
|
media.running = true;
|
|
}
|
|
|
|
Process {
|
|
id: query
|
|
command: [root.helperPath, "snapshot"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const parsed = JSON.parse(this.text);
|
|
root.drives = Array.isArray(parsed.drives) ? parsed.drives : [];
|
|
root.filesystems = Array.isArray(parsed.filesystems) ? parsed.filesystems : [];
|
|
root.swap = Array.isArray(parsed.swap) ? parsed.swap : [];
|
|
root.lastError = "";
|
|
} catch (error) {
|
|
root.drives = [];
|
|
root.filesystems = [];
|
|
root.lastError = "Could not read the storage helper's output.";
|
|
console.warn("Disks: could not parse helper output:", error);
|
|
}
|
|
root.scanned = true;
|
|
}
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: folderScan
|
|
command: [root.helperPath, "scan"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const parsed = JSON.parse(this.text);
|
|
root.folders = Array.isArray(parsed.folders) ? parsed.folders : [];
|
|
root.containers = parsed.containers ?? null;
|
|
root.folderScanTruncated = parsed.truncated === true;
|
|
root.foldersMeasured = true;
|
|
} catch (error) {
|
|
root.lastError = "Could not measure what is using the drive.";
|
|
console.warn("Disks: could not parse scan output:", error);
|
|
}
|
|
}
|
|
}
|
|
onExited: root.scanning = false
|
|
}
|
|
|
|
Process {
|
|
id: breakdownScan
|
|
command: [root.helperPath, "breakdown"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
root.breakdown = JSON.parse(this.text);
|
|
root.breakdownMeasured = true;
|
|
} catch (error) {
|
|
root.lastError = "Could not measure what is using the drive.";
|
|
console.warn("Disks: could not parse breakdown output:", error);
|
|
}
|
|
}
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
onExited: root.measuringBreakdown = false
|
|
}
|
|
|
|
Process {
|
|
id: cleanableScan
|
|
command: [root.helperPath, "cleanables"]
|
|
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
onExited: root.measuringCleanables = false
|
|
}
|
|
|
|
Process {
|
|
id: cleaner
|
|
// The helper answers with the fresh list, so the sizes on screen are
|
|
// what is there now rather than what was there before the clean.
|
|
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
onExited: {
|
|
root.cleaningId = "";
|
|
// Freeing space changes the drive's usage and its breakdown, and
|
|
// both are on screen while this happens.
|
|
root.refresh();
|
|
if (root.breakdownMeasured)
|
|
root.measureBreakdown();
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: media
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
// Re-read rather than assuming: a device may refuse to unmount because
|
|
// something still has a file open on it.
|
|
onExited: root.refresh()
|
|
}
|
|
}
|