Files
Panama/config/dot/quickshell/modules/settings/StoragePage.qml
T
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00

357 lines
13 KiB
QML

// Storage: what is using the drive, then what the drive is.
//
// The page is one scroll with a rule behind it: everything above the divider
// answers "how much space do I have and what took it", everything below answers
// "what is this device and is it healthy". A card that answers neither does not
// belong here.
//
// Measuring what is using the space is expensive -- a Steam library alone can
// be a terabyte -- so it happens on request rather than on open. The page says
// so plainly instead of showing an empty list that reads as "nothing here".
//
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
// away for that.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "storage"
title: "Storage"
lede: "How much space is left, what is using it, and whether the drive is healthy."
readonly property var rootFs: Disks.rootFilesystem
readonly property var drive: Disks.primaryDrive
// The measured folders, as a share of the largest one, so the bars compare
// against each other rather than against a total they do not sum to.
readonly property real largestFolder: {
let largest = 0;
for (const folder of Disks.folders)
largest = Math.max(largest, Number(folder.bytes ?? 0));
return largest;
}
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
Component.onCompleted: Disks.refresh()
TextRow {
visible: Disks.lastError !== ""
label: "Storage needs attention"
detail: Disks.lastError
value: ""
divider: false
}
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Free space"
subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1
? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs)
+ ", so they share the same space."
: "The filesystem this session runs from."
Column {
width: parent.width
spacing: 10
Row {
width: parent.width
spacing: 12
Text {
text: root.rootFs ? Disks.formatBytes(root.rootFs.availBytes) + " free" : "Reading…"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.rootFs !== null
text: root.rootFs
? "of " + Disks.formatBytes(root.rootFs.sizeBytes)
+ " · " + String(root.rootFs.fstype ?? "")
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Rectangle {
width: parent.width
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.09)
Rectangle {
width: Math.max(parent.width * Disks.usedFraction(root.rootFs), parent.height)
height: parent.height
radius: parent.radius
// Color is the only warning this bar gives, so it changes
// at the point where free space starts to be a problem
// rather than at a round number.
color: Disks.usedFraction(root.rootFs) > 0.92
? Theme.danger
: (Disks.usedFraction(root.rootFs) > 0.8 ? Theme.warn : Theme.accent)
}
}
Text {
text: root.rootFs
? Disks.formatBytes(root.rootFs.usedBytes) + " used · "
+ Math.round(Disks.usedFraction(root.rootFs) * 100) + "%"
: ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingsCard {
title: "What is using it"
subtitle: Disks.foldersMeasured
? "Measured by walking each folder."
: "Measuring means walking every file, so it is not done automatically."
ActionRow {
visible: !Disks.foldersMeasured || Disks.scanning
label: Disks.scanning ? "Measuring…" : "Measure folders"
detail: Disks.scanning
? "Walking the largest folders. This can take a minute."
: "Walk the usual suspects and report what is biggest."
action: "Measure"
enabled: !Disks.scanning
divider: Disks.foldersMeasured
onTriggered: Disks.scan()
}
Repeater {
model: Disks.foldersMeasured ? Disks.folders : []
delegate: Column {
id: folderBlock
required property var modelData
required property int index
width: parent.width
TextRow {
width: folderBlock.width
label: String(folderBlock.modelData.label ?? "")
detail: String(folderBlock.modelData.path ?? "")
value: Disks.formatBytes(folderBlock.modelData.bytes ?? 0)
divider: false
}
Rectangle {
width: folderBlock.width
height: 4
radius: 2
color: Theme.alpha(Theme.fg, 0.08)
// Relative to the LARGEST folder, not to the drive. At this
// scale one folder holds almost everything, so bars drawn
// against the total would leave every other row invisible.
Rectangle {
width: root.largestFolder > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2)
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
Item { width: 1; height: 10 }
}
}
TextRow {
visible: Disks.foldersMeasured && Disks.folderScanTruncated
label: "Some folders were not measured"
detail: "The walk ran out of time before reaching them, so this list is incomplete."
value: ""
divider: Disks.containers !== null
}
ActionRow {
visible: Disks.containers !== null
&& Number(Disks.containers?.reclaimableBytes ?? 0) > 0
label: "Unused container images"
detail: Disks.containers
? Disks.formatBytes(Disks.containers.reclaimableBytes)
+ " of " + Disks.formatBytes(Disks.containers.totalBytes)
+ " is not used by any container"
: ""
action: "Show"
divider: false
// Reclaiming is not offered here on purpose: pruning images can
// destroy work that lives outside this desktop, and a settings pane
// should not put that one click deep. This opens a terminal showing
// what would be reclaimed, and leaves the decision there.
onTriggered: Quickshell.execDetached(
["kitty", "--hold", "-e", "podman", "system", "df"])
}
}
// ── The device ───────────────────────────────────────────────────────────
SettingsCard {
title: "Drive"
subtitle: root.drive
? String(root.drive.model) + (root.drive.encrypted ? " · encrypted" : "")
: "Reading…"
TextRow {
visible: root.drive !== null
label: "Health"
detail: root.drive && root.drive.selfTest !== ""
? "Last self-test: " + String(root.drive.selfTest)
: "Reported by the drive itself"
value: Disks.healthSummary(root.drive)
}
TextRow {
visible: root.drive !== null && root.drive.temperatureC !== null
label: "Temperature"
detail: "Measured by the drive controller"
value: root.drive && root.drive.temperatureC !== null
? String(root.drive.temperatureC) + " °C" : ""
}
TextRow {
visible: root.drive !== null && root.drive.powerOnHours !== null
label: "Powered on"
detail: "Total hours this drive has been running"
value: root.drive && root.drive.powerOnHours !== null
? String(root.drive.powerOnHours) + " hours" : ""
}
TextRow {
visible: root.drive !== null && root.drive.encrypted
label: "Encryption"
detail: "The filesystem is encrypted and unlocked at boot"
value: "LUKS"
}
ActionRow {
label: "Partitioning and formatting"
detail: "Deliberately not here. Disks is the tool that owns erasing a drive."
action: "Open Disks"
divider: false
onTriggered: Quickshell.execDetached(["gapplication", "launch", "org.gnome.DiskUtility"])
}
}
SettingsCard {
title: "Filesystems"
subtitle: "Every mounted filesystem, grouped by the device behind it."
Repeater {
model: Disks.filesystems
delegate: TextRow {
id: filesystemRow
required property var modelData
required property int index
width: parent.width
label: Disks.mountLabel(filesystemRow.modelData)
detail: String(filesystemRow.modelData.fstype ?? "")
+ " on " + String(filesystemRow.modelData.device ?? "")
value: Disks.formatBytes(filesystemRow.modelData.availBytes) + " free of "
+ Disks.formatBytes(filesystemRow.modelData.sizeBytes)
divider: filesystemRow.index < Disks.filesystems.length - 1
}
}
}
// Named here because this is the page somebody opens when space is short,
// and container images are usually the largest thing nobody remembers
// having. Only shown when there is actually something to reclaim.
SettingsCard {
visible: Containers.available && Containers.reclaimable > 0
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)
ActionRow {
label: "Images and volumes nothing references"
detail: Containers.unusedImages.length + " image"
+ (Containers.unusedImages.length === 1 ? "" : "s") + " and "
+ Containers.unusedVolumes.length + " volume"
+ (Containers.unusedVolumes.length === 1 ? "" : "s")
action: "Review"
divider: false
onTriggered: ShellState.settingsPage = "containers"
}
}
SettingsCard {
title: "Removable drives"
subtitle: "USB drives and memory cards."
Repeater {
model: root.removableDrives
delegate: ActionRow {
id: removableRow
required property var modelData
width: parent.width
label: String(removableRow.modelData.model ?? removableRow.modelData.name)
detail: Disks.formatBytes(removableRow.modelData.sizeBytes)
+ " · " + String(removableRow.modelData.path)
action: removableRow.modelData.ejectable ? "Eject" : "Unmount"
divider: false
onTriggered: removableRow.modelData.ejectable
? Disks.eject(String(removableRow.modelData.path))
: Disks.unmount(String(removableRow.modelData.path))
}
}
TextRow {
visible: root.removableDrives.length === 0
label: "None connected"
detail: "Drives you plug in appear here, with a way to unmount them safely."
value: ""
divider: false
}
}
SettingsCard {
visible: Disks.swap.length > 0
title: "Swap"
subtitle: "Compressed swap lives in memory, not on the drive."
Repeater {
model: Disks.swap
delegate: TextRow {
id: swapRow
required property var modelData
required property int index
width: parent.width
label: String(swapRow.modelData.name ?? "")
detail: String(swapRow.modelData.kind) === "zram"
? "Compressed swap in RAM" : "Swap"
value: Disks.formatBytes(swapRow.modelData.sizeBytes)
divider: swapRow.index < Disks.swap.length - 1
}
}
}
}