// 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". // // The cleanup card has rules, and they are the whole reason it can exist at // all. Every row names what it is and what is lost by clearing it. Every row // carries its size. Nothing is selected in advance, nothing is recommended, // nothing counts down, and a row with nothing in it says so and offers no // button. A "clean up your PC" panel that nags is a racket; this one is a list // of facts with a button next to each. // // 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: root.rootFs ? Disks.formatBytes(root.rootFs.usedBytes) + " used of " + Disks.formatBytes(root.rootFs.sizeBytes) + " — " + Disks.formatBytes(root.rootFs.availBytes) + " free." : "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 // Clearing something deletes it, so it never happens on a first press. // Holds the id of the cleanable that is one press away from running. property string pendingClean: "" // Folder bars are drawn against the DISK, not against the largest folder. // Against the largest, the top row is always full and the bar says nothing // except which row is biggest -- which the sizes beside them already say. // Against the disk, the bar means the same thing as the free-space bar // above it, and a 212 GB folder on a 2 TB disk looks like what it is. readonly property real diskBytes: Number(root.rootFs?.sizeBytes ?? 0) readonly property var removableDrives: Disks.drives.filter(entry => entry.removable) // Unguarded on purpose, unlike the other refresh-on-open pages: this is the // cheap snapshot, not the walk. Free space moves while Settings is open -- // that is most of the reason someone comes back to this page -- and // disks-contract pins the bare call so a later sweep cannot quietly turn it // into a once-a-session read. The expensive walks are asked for by hand. Component.onCompleted: Disks.refresh() ErrorRow { label: "Storage needs attention" message: Disks.lastError } // ── Space ──────────────────────────────────────────────────────────────── SettingsCard { title: "What is on this disk" 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." // Measuring the segments walks the same folders the Folders card walks, // so it costs the same and is asked for the same way. ActionRow { visible: !Disks.breakdownMeasured || Disks.measuringBreakdown label: Disks.measuringBreakdown ? "Measuring…" : "Measure what is on this disk" detail: Disks.measuringBreakdown ? "Walking your home folder, the flatpak installations, and the cache." : "Three of the four segments are measured by walking folders, which takes a minute." action: "Measure" enabled: !Disks.measuringBreakdown divider: false onTriggered: Disks.measureBreakdown() } StorageBreakdownBar { visible: Disks.breakdownMeasured width: parent.width } // The caption belongs to the bar's arithmetic, so it lives with the // card rather than inside the component: home, applications and caches // are measured, and the fourth segment is what is left of the used // space once those three are subtracted. Calling that "System" would // blame the desktop for the user's own unscanned files. Text { width: parent.width visible: Disks.breakdownMeasured topPadding: 12 text: "Home, applications and caches are measured. \"System & everything else\" is" + " the remainder — the packages this machine runs on, plus anything those" + " three measurements did not reach." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } // What the walk could not finish, said rather than absorbed into the // remainder without comment. TextRow { visible: Disks.breakdownMeasured && Disks.breakdown && Disks.breakdown.complete === false label: "The walk ran out of time" detail: "Home, applications and caches are floors rather than totals, so the remainder is larger than it should be" value: "" divider: false } TextRow { visible: Disks.breakdownMeasured && Disks.breakdown && Disks.breakdown.exceedsUsed === true label: "The measured parts overlap" detail: "They add up to more than the used space, which means something was counted twice. The remainder is shown as zero rather than as a negative number." value: "" divider: false } } SettingsCard { title: "Free space" subtitle: "The number the rest of this page is about." 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: "Folders" subtitle: Disks.foldersMeasured ? "Measured by walking each folder. Bars are drawn against the whole disk, so they mean the same thing as the free-space bar above." : "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) Rectangle { width: root.diskBytes > 0 ? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2) : 0 height: parent.height radius: parent.radius color: Theme.accent } } Item { width: 1; height: 10 } } } NotMeasuredRow { visible: Disks.foldersMeasured && Disks.folderScanTruncated label: "Some folders were not measured" because: "The walk ran out of time before reaching them, so this list is incomplete" divider: false } } // ── Reclaiming space, without the racket ───────────────────────────────── SettingsCard { title: "Clean up, honestly" subtitle: "Each row says what it is and what clearing it costs. Nothing here is chosen for you, and nothing on this page needs doing." ActionRow { visible: !Disks.cleanablesMeasured || Disks.measuringCleanables label: Disks.measuringCleanables ? "Measuring…" : "Measure what could be cleared" detail: "Measuring only counts bytes. Every row below is cleared by its own button, one at a time." action: "Measure" enabled: !Disks.measuringCleanables divider: Disks.cleanablesMeasured onTriggered: Disks.measureCleanables() } Repeater { model: Disks.cleanables ?? [] delegate: SettingRow { id: cleanRow required property var modelData required property int index readonly property string cleanId: String(cleanRow.modelData.id ?? "") readonly property real bytes: Number(cleanRow.modelData.bytes ?? 0) readonly property bool empty: cleanRow.bytes <= 0 readonly property bool privileged: cleanRow.modelData.privileged === true readonly property bool confirming: root.pendingClean !== "" && root.pendingClean === cleanRow.cleanId width: parent.width label: String(cleanRow.modelData.label ?? "") detail: cleanRow.empty ? String(cleanRow.modelData.detail ?? "") + " · empty, nothing to do" : (cleanRow.confirming ? "This deletes it now. Nothing here is recoverable from the trash afterwards." : String(cleanRow.modelData.detail ?? "") + (cleanRow.privileged ? " · the system will ask for your password" : "")) value: cleanRow.empty ? Disks.formatBytes(0) : "" controlWidth: cleanRow.empty ? 90 : 210 divider: cleanRow.index < (Disks.cleanables ?? []).length - 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: cleanRow.bytes > 0 spacing: 9 Text { anchors.verticalCenter: parent.verticalCenter text: Disks.formatBytes(cleanRow.bytes) color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSize } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: cleanRow.confirming text: "Clear it" tone: "danger" enabled: Disks.cleaningId === "" onClicked: { root.pendingClean = ""; Disks.clean(cleanRow.cleanId); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: cleanRow.confirming ? "Keep" : "Clear…" enabled: Disks.cleaningId === "" onClicked: root.pendingClean = cleanRow.confirming ? "" : cleanRow.cleanId } } } } TextRow { visible: Disks.cleanablesMeasured && (Disks.cleanables ?? []).length === 0 label: "Nothing itemized" detail: "Caches, the trash, unused Flatpak runtimes and the package download cache were all measured and none of them holds anything" value: "" divider: false } } // ── 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. // // This is the ONLY container affordance on the page. There used to be a // second one above that opened a terminal running `podman system df`, which // meant two rows about the same gigabytes leading two different places -- // and the terminal one could not actually reclaim anything. 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 } } } }