Add a Storage page

Nothing showed what was using the drive, and removable media was handled
by a tray helper with no surface in Settings at all.

One scroll rather than tabs: space above, the device below. Every other
settings page is a scrolling card stack, and a tab would not be
deep-linkable from the launcher command or from search.

Three things the page has to get right, each now pinned by a contract,
because each is a way it could quietly lie. / and /home are one btrfs
filesystem sharing one pool of free space, and a page that copies df
shows double the free space that exists. zram is a block device and is
not storage; counting it as a drive overstates this machine by 8 GB.
Unmount and eject refuse anything not on a removable drive, because the
UI is what asks and a UI can be wrong.

The cheap read -- layout, usage, health -- runs when the page opens, at
around 90ms. Measuring what is filling the drive means walking every
file, so it happens on request and says so rather than showing an empty
list that reads as "nothing here".

Partitioning and formatting are deliberately absent. A settings pane is
the wrong place to put erasing a disk two clicks deep; the page opens
GNOME Disks for that.

Adding the page found a fourth hard-coded page list in ShellState. A
page missing from it does not error -- openSettings() falls back to
"home", so the launcher opens the wrong page and logs nothing. A
registry contract now holds the three lists together.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 10:52:10 -04:00
parent a68e4f6dcd
commit 99433c0e8e
12 changed files with 1141 additions and 1 deletions
+183
View File
@@ -0,0 +1,183 @@
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
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;
}
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: 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()
}
}
@@ -64,6 +64,11 @@ Singleton {
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
{ label: "Free space", detail: "How full each drive and filesystem is", page: "storage" },
{ label: "Disk usage", detail: "What is using the space on this machine", page: "storage" },
{ label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" },
{ label: "Removable drives", detail: "Unmount a USB drive or memory card safely", page: "storage" },
{ label: "Encryption", detail: "Whether the filesystem is encrypted", page: "storage" },
{ label: "Output volume", detail: "Choose the output device and its level", page: "sound" },
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;