// 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 } } } }