Files
Panama/config/dot/quickshell/services/Disks.qml
T

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()
}
}