Make Applications a real app manager, and clean up storage without the racket

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
@@ -1,22 +1,56 @@
// Applications and session startup.
// Applications: what is installed, what opens your files, and what starts with
// your session.
//
// This page used to answer only the middle question. That made it the one place
// in Settings you could not do the obvious thing -- see what is on the machine
// and remove something -- so the list came first and everything else arranged
// itself around it.
//
// Two rules the page keeps to:
//
// It never pretends to own a system package. A Flatpak can be inspected and
// removed here because that is a per-user operation with per-user blast
// radius. `dnf remove` is not: removing the wrong package takes the desktop
// with it, and a settings pane should not put that one press away. The row
// says so and prints the command, which is more useful than a button that
// refuses.
//
// It never links somewhere with nothing on it. The "Elsewhere in Settings"
// chips appear per application, and only where that application already has a
// rule on the page being linked to.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Choose what opens your files and links, and what starts with your session."
lede: "What is installed, what opens your files, and what starts with your session."
property string expandedRole: ""
property bool addingAutostart: false
// Which installed application is unfolded. One at a time: the body is tall
// enough that two open rows would push the third off the screen.
property string expandedApp: ""
// Uninstalling deletes an application, so it never happens on a first
// press. Holds the Flatpak id that is one press away from being removed.
property string pendingUninstall: ""
// Which entry has been asked to be removed. Removal deletes a file, so
// it never happens on a first press.
property string confirmingAutostartRemoval: ""
property bool addingAutostart: false
property bool showingFileTypes: false
// Empty means "whichever category the catalog lists first", so the card is
// never blank before anything has been chosen.
property string catalogCategory: ""
readonly property var applications: DesktopEntries.applications.values
// Each role governs a whole family of types, not one representative: setting
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
@@ -90,6 +124,154 @@ SettingsPage {
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
// ── The installed list ───────────────────────────────────────────────────
// How many rows the card draws before it stops and says how many are left.
// A machine with 243 applications is a scroll, not a list; the search field
// is what actually finds one.
readonly property int installedLimit: 10
readonly property var installedApps: (AppLibrary.apps ?? []).filter(
app => AppLibrary.matches(app, installedSearch.text))
readonly property var expandedAppRecord:
(AppLibrary.apps ?? []).find(app => String(app.entryId ?? "") === root.expandedApp) ?? null
// Read for the open row only. The cache is read directly so this binding
// has a dependency on it -- that is what turns "Reading…" into the summary
// when the answer lands -- and permissionsFor() is what asks for an answer
// that is not there yet.
readonly property var expandedPermissions: {
const cache = AppLibrary.permissionCache;
const app = root.expandedAppRecord;
if (!app || String(app.kind ?? "") !== "flatpak")
return null;
const key = String(app.flatpakId ?? "");
const info = cache[key] ?? AppLibrary.permissionsFor(key);
return info && Array.isArray(info.summary) ? info : null;
}
// Flatseal is the tool that edits these; offering it when it is not
// installed would be a button that does nothing.
readonly property bool flatsealAvailable: root.applications.some(
entry => String(entry.id ?? "").replace(/\.desktop$/, "") === "com.github.tchx84.Flatseal")
function autostartId(app: var): string {
const id = String(app?.entryId ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
function autostartEntry(desktopId: string): var {
return DefaultApps.autostartEntries.find(entry => String(entry.id) === desktopId) ?? null;
}
function autostartOn(app: var): bool {
const entry = root.autostartEntry(root.autostartId(app));
return entry !== null && entry.enabled !== false;
}
// Turning it on for an application that has never had an entry writes one;
// there is no separate "add" step from here, because from the row's point
// of view there is one switch and it means one thing.
function setAppAutostart(app: var, value: bool): void {
const desktopId = root.autostartId(app);
if (root.autostartEntry(desktopId) === null) {
if (value)
DefaultApps.addAutostart(desktopId);
return;
}
DefaultApps.setAutostart(desktopId, value);
}
// ── Where else this application already has a rule ───────────────────────
function appIdentifiers(app: var): var {
const out = [];
const entryId = String(app?.entryId ?? "").replace(/\.desktop$/, "");
const flatpakId = String(app?.flatpakId ?? "");
const name = String(app?.name ?? "");
for (const value of [entryId, flatpakId, name]) {
if (value !== "")
out.push(value.toLowerCase());
}
return out;
}
function hasNotificationRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (Notifs.applications ?? []).some(
known => identifiers.indexOf(String(known.id ?? "").toLowerCase()) >= 0);
}
// A stream, not a stored rule: the Sound page's per-application rows come
// from what is playing right now, so the chip is honest only while there is
// something there to change.
function hasSoundRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (AudioDevices.applications ?? []).some(stream =>
identifiers.indexOf(String(stream.key ?? "").toLowerCase()) >= 0
|| identifiers.indexOf(String(stream.label ?? "").toLowerCase()) >= 0);
}
// The portal's permission store records answers under the application's own
// id, which is the same id this list carries -- so the match is exact and
// costs nothing.
function hasPrivacyRule(app: var): bool {
const identifiers = root.appIdentifiers(app);
return (Permissions.devices ?? []).some(device =>
(device.applications ?? []).some(
recorded => identifiers.indexOf(String(recorded.app ?? "").toLowerCase()) >= 0));
}
// ── The catalog ──────────────────────────────────────────────────────────
readonly property var catalogCategories: AppLibrary.categories ?? []
// A category arrives either as a plain name or as an object carrying its
// entries with it, so both are read rather than one being assumed.
function categoryId(category: var): string {
return String(category?.id ?? category?.name ?? category ?? "");
}
function categoryLabel(category: var): string {
const label = String(category?.label ?? category?.name ?? category?.id ?? category ?? "");
const count = Number(category?.count ?? (category?.entries ?? []).length ?? 0);
return count > 0 ? label + " · " + count : label;
}
readonly property string activeCategory: {
if (root.catalogCategory !== "")
return root.catalogCategory;
const categories = root.catalogCategories;
return categories.length > 0 ? root.categoryId(categories[0]) : "";
}
readonly property var catalogEntries: {
// Reading the category list is what makes this re-run once the catalog
// has been parsed; entriesFor() alone is a function call and would not.
const categories = root.catalogCategories;
if (categories.length === 0 || root.activeCategory === "")
return [];
return AppLibrary.entriesFor(root.activeCategory) ?? [];
}
// ── The launcher's own chord, read from the keymap ───────────────────────
// Hardcoding "Super+Space" survived two rebinds of the launcher and told
// the wrong story both times. The keymap is already loaded; this reads it.
readonly property var launcherChords: (Keybinds.binds ?? [])
.filter(bind => String(bind.description ?? "") === "Launcher")
.map(bind => String(bind.chord ?? ""))
.filter(chord => chord !== "")
// AppLibrary loads nothing on its own: `flatpak list` and the catalog cost
// a process each and only this page wants them, so a shell start where
// nobody opens Applications pays for neither.
Component.onCompleted: {
AppLibrary.refresh();
AppLibrary.refreshCatalog();
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
@@ -98,143 +280,248 @@ SettingsPage {
divider: false
}
TextRow {
visible: String(AppLibrary.lastError ?? "") !== ""
label: "The application list needs attention"
detail: String(AppLibrary.lastError ?? "")
value: ""
divider: false
}
// ── What is installed ────────────────────────────────────────────────────
SettingsCard {
title: "Installed applications"
subtitle: !AppLibrary.flatpaksLoaded
? "Reading the applications installed on this machine…"
: (AppLibrary.apps ?? []).length + " installed · "
+ AppLibrary.flatpakCount + " through Flatpak"
SearchField {
id: installedSearch
width: parent.width
placeholder: "Search installed applications"
}
Item { width: 1; height: 8 }
Repeater {
model: root.installedApps.slice(0, root.installedLimit)
delegate: InstalledAppRow {
id: installedRow
required property var modelData
required property int index
readonly property bool open: root.expandedApp === String(installedRow.modelData.entryId ?? "")
width: parent.width
app: installedRow.modelData
expanded: installedRow.open
// A write in flight, not a read: the catalog refresh this page
// starts on open must not grey out every row for a second.
busy: AppLibrary.busyEntryId !== "" || DefaultApps.busy
divider: installedRow.index
< Math.min(root.installedApps.length, root.installedLimit) - 1
autostart: root.autostartOn(installedRow.modelData)
flatsealAvailable: root.flatsealAvailable
permissionsKnown: installedRow.open && root.expandedPermissions !== null
permissionSummary: installedRow.open && root.expandedPermissions
? root.expandedPermissions.summary
: []
hasNotificationRule: root.hasNotificationRule(installedRow.modelData)
hasSoundRule: root.hasSoundRule(installedRow.modelData)
hasPrivacyRule: root.hasPrivacyRule(installedRow.modelData)
confirmingUninstall: root.pendingUninstall !== ""
&& root.pendingUninstall === String(installedRow.modelData.flatpakId ?? "")
onActivated: {
root.pendingUninstall = "";
root.expandedApp = installedRow.open
? ""
: String(installedRow.modelData.entryId ?? "");
}
onAutostartToggled: value => root.setAppAutostart(installedRow.modelData, value)
onJumped: page => ShellState.openSettings(page)
onFlatsealRequested: Quickshell.execDetached(
["flatpak", "run", "com.github.tchx84.Flatseal"])
onUninstallArmed: root.pendingUninstall = String(installedRow.modelData.flatpakId ?? "")
onUninstallCancelled: root.pendingUninstall = ""
onUninstallConfirmed: {
const flatpakId = String(installedRow.modelData.flatpakId ?? "");
root.pendingUninstall = "";
root.expandedApp = "";
AppLibrary.uninstall(flatpakId);
}
}
}
TextRow {
visible: root.installedApps.length > root.installedLimit
label: (root.installedApps.length - root.installedLimit) + " more"
detail: "Search finds any of them by name or by application id."
value: ""
divider: false
}
TextRow {
visible: (AppLibrary.apps ?? []).length > 0 && root.installedApps.length === 0
label: "Nothing matches that"
detail: "Both the application's name and its id are searched."
value: ""
divider: false
}
}
// ── What could be installed ──────────────────────────────────────────────
SettingsCard {
title: "Browse the catalog"
subtitle: "Panama's curated applications — the same list `panama apps` offers, installed the same way."
Flow {
width: parent.width
spacing: 7
Repeater {
model: root.catalogCategories
delegate: SettingsChip {
required property var modelData
text: root.categoryLabel(modelData)
active: root.categoryId(modelData) === root.activeCategory
onClicked: root.catalogCategory = root.categoryId(modelData)
}
}
}
Item { width: 1; height: 10 }
Repeater {
model: root.catalogEntries
delegate: SettingRow {
id: catalogRow
required property var modelData
required property int index
readonly property bool installed: catalogRow.modelData.installed === true
readonly property bool flatpak: String(catalogRow.modelData.kind ?? "") === "flatpak"
// `id` is the catalog line verbatim, "flatpak:" prefix and all,
// because that is what install matches. `ref` is the same thing
// with the prefix taken off, which is what a person reads.
readonly property string ref: String(catalogRow.modelData.ref ?? catalogRow.modelData.id ?? "")
width: parent.width
label: String(catalogRow.modelData.label ?? "") !== ""
? String(catalogRow.modelData.label)
: catalogRow.ref
detail: catalogRow.flatpak
? catalogRow.ref + " · Flatpak, from Flathub"
: catalogRow.ref + " · system package, so installing asks for your password"
value: catalogRow.installed ? "Installed" : ""
controlWidth: 110
divider: catalogRow.index < root.catalogEntries.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !catalogRow.installed
// Only the row being installed says so, rather than the
// whole card greying out around one press.
text: AppLibrary.busyEntryId === String(catalogRow.modelData.id ?? "")
? "Installing…"
: "Install"
tone: "accent"
enabled: !AppLibrary.busy
onClicked: AppLibrary.install(root.activeCategory,
String(catalogRow.modelData.id ?? ""))
}
}
}
TextRow {
visible: root.catalogCategories.length === 0
label: AppLibrary.catalogLoaded
? "The catalog is empty"
: "Reading the catalog…"
detail: "It is parsed from setup/packages/extras, the same files the command line uses."
value: ""
divider: false
}
}
// ── What opens what ──────────────────────────────────────────────────────
SettingsCard {
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
subtitle: "Each role sets its whole family of types together, so one picture never opens somewhere different from the next."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
delegate: OptionPickerRow {
id: roleRow
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
readonly property var choices: root.choicesForRole(roleRow.modelData)
width: parent.width
SettingRow {
id: roleRow
readonly property bool open: root.expandedRole === roleBlock.modelData.key
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
controlWidth: 210
// Drawn rather than left to SettingRow's plain value text, so
// the row carries the same chevron a PickerRow does. These
// open a chooser but looked completely inert without it.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: roleBlock.choices.length > 0
text: roleRow.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
label: roleRow.modelData.label
detail: roleRow.modelData.detail
options: roleRow.choices.map(entry => ({
value: root.desktopId(entry),
label: root.displayName(entry),
detail: String(entry.genericName || entry.comment || root.desktopId(entry))
}))
current: root.currentHandler(roleRow.modelData.key)
enabled: roleRow.choices.length > 0 && !DefaultApps.busy
value: DefaultApps.busy ? "Loading…" : (
roleRow.currentOption
? String(roleRow.currentOption.label)
: (root.currentHandler(roleRow.modelData.key) || "Not set")
)
onPicked: value => DefaultApps.setDefault(roleRow.modelData.key, String(value))
}
}
}
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
// The escape hatch from the paragraph above: one type, on its own.
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
label: "One file type"
detail: root.showingFileTypes
? "Search the type database and give a single type its own application"
: "Override a single type when a role's family is too broad"
action: root.showingFileTypes ? "Close" : "Choose"
divider: root.showingFileTypes
onTriggered: {
root.showingFileTypes = !root.showingFileTypes;
if (!root.showingFileTypes)
DefaultApps.clearTypeSearch();
}
}
FileTypePicker {
visible: root.showingFileTypes
width: parent.width
matches: DefaultApps.typeMatches ?? []
busy: DefaultApps.searchingTypes
truncated: DefaultApps.typeSearchTruncated
onQueried: query => DefaultApps.searchTypes(query)
onChosen: (mime, desktopId) => DefaultApps.setType(mime, desktopId)
}
}
// ── What starts with the session ─────────────────────────────────────────
SettingsCard {
title: "User autostart"
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
title: "Autostart"
subtitle: "Entries live in your user configuration. The switch in an application's own row above writes the same files."
ActionRow {
label: "Add an application"
@@ -262,7 +549,6 @@ SettingsPage {
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
@@ -281,7 +567,6 @@ SettingsPage {
detail: autostartRow.confirming
? "Removing deletes this entry. Turning it off instead is reversible."
: autostartRow.modelData.id
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
controlWidth: 210
// A switch, not the words "Enabled"/"Disabled". The row always
@@ -320,11 +605,19 @@ SettingsPage {
}
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "These are started from the Hyprland configuration. They are read-only here."
// The compositor's own autostart, described rather than offered. These
// are started from config/dot/hypr/autostart.lua, which is where they
// are changed -- a switch here would edit a file the compositor reads
// once at launch and give no sign that nothing had happened.
TextRow {
label: "Compositor autostart"
detail: "Started from the Hyprland configuration, and read-only here."
value: DefaultApps.luaAutostartEntries.length > 0
? DefaultApps.luaAutostartEntries.length
+ (DefaultApps.luaAutostartEntries.length === 1 ? " entry" : " entries")
: ""
}
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -351,16 +644,63 @@ SettingsPage {
}
}
// ── Searching from the launcher ──────────────────────────────────────────
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" }
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: root.launcherChords.length > 1
? root.launcherChords.join(", ") + " all open it, because Super+A and Super+R"
+ " were GNOME's app grid and run dialog"
: "Rebindable from the keyboard page, like every other launcher chord"
// The keymap is the source of truth; the literal is what shows
// while it is still being read.
value: root.launcherChords.length > 0 ? root.launcherChords[0] : "Super + Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
detail: "Re-read desktop entries, defaults, user autostart files, and the installed list"
action: DefaultApps.busy || AppLibrary.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy && !AppLibrary.busy
divider: false
onTriggered: DefaultApps.refresh()
onTriggered: {
DefaultApps.refresh();
AppLibrary.refresh();
AppLibrary.refreshCatalog();
}
}
}
}
@@ -0,0 +1,139 @@
// Overriding one file type, when the role above it covers too much.
//
// The role rows set a whole family together on purpose -- a file manager must
// not open one image in a viewer and its neighbour in an editor. That is right
// almost always and wrong exactly once: the one type somebody wants somewhere
// else. This is that escape hatch, and it is deliberately a search rather than
// a list, because the MIME database has thousands of entries and none of them
// is worth scrolling past.
//
// Nothing is written here. The page owns the service call, so a type set from
// this row goes down the same validated path as a role does.
import QtQuick
import qs.config
import qs.modules.clipboard
Column {
id: root
// [{ mime, label, extensions, handler, handlerName, candidates: [{ id, name }] }]
property var matches: []
property bool busy: false
property bool truncated: false
// The helper answers nothing shorter than this, so the row says so rather
// than looking broken while somebody types the first letter.
readonly property int minimumQuery: 2
// Emitted after the typing settles, so a query is one search rather than
// one per keystroke.
signal queried(string query)
signal chosen(string mime, string desktopId)
readonly property string query: search.text.trim()
function typeLabel(match: var): string {
const label = String(match?.label ?? "").trim();
return label !== "" ? label : String(match?.mime ?? "");
}
function typeDetail(match: var): string {
const parts = [String(match?.mime ?? "")];
const extensions = match?.extensions ?? [];
if (extensions.length > 0)
parts.push(extensions.join(" "));
const handler = String(match?.handlerName ?? match?.handler ?? "").trim();
parts.push(handler === "" ? "nothing opens it yet" : "opens with " + handler);
return parts.join(" · ");
}
function optionsFor(match: var): var {
return (match?.candidates ?? []).map(candidate => ({
value: String(candidate.id ?? ""),
label: String(candidate.name ?? candidate.id ?? ""),
detail: String(candidate.id ?? "")
}));
}
width: parent ? parent.width : 620
spacing: 0
SearchField {
id: search
width: parent.width
placeholder: "png, pdf, video/… — the type or its extension"
onTextChanged: settle.restart()
}
Timer {
id: settle
interval: 250
onTriggered: root.queried(search.text.trim())
}
Item { width: 1; height: 8 }
TextRow {
width: parent.width
visible: root.query.length < root.minimumQuery
label: "Type what you are looking for"
detail: "The type and its file extensions are both searched, so \"svg\" and"
+ " \"image/svg+xml\" land on the same row. Two letters is the shortest"
+ " search the type database answers."
value: ""
divider: false
}
TextRow {
width: parent.width
visible: root.busy && root.query.length >= root.minimumQuery
label: "Searching the type database…"
detail: "Reading which applications advertise each type"
value: ""
divider: false
}
Repeater {
model: root.busy ? [] : root.matches
delegate: OptionPickerRow {
id: typeRow
required property var modelData
required property int index
width: parent.width
label: root.typeLabel(typeRow.modelData)
detail: root.typeDetail(typeRow.modelData)
options: root.optionsFor(typeRow.modelData)
current: String(typeRow.modelData.handler ?? "")
enabled: typeRow.options.length > 0
divider: typeRow.index < root.matches.length - 1
value: typeRow.currentOption
? String(typeRow.currentOption.label)
: (typeRow.options.length === 0 ? "Nothing offers it" : "Not set")
onPicked: value => root.chosen(String(typeRow.modelData.mime ?? ""), String(value))
}
}
TextRow {
width: parent.width
visible: !root.busy && root.truncated
label: "Only the closest matches are shown"
detail: "Narrow the search to reach the rest."
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !root.busy && root.query.length >= root.minimumQuery
&& root.matches.length === 0
label: "No file type matches that"
detail: "Only types this system knows about can be given a handler."
value: ""
divider: false
}
}
@@ -0,0 +1,402 @@
// One installed application: a line you can read, unfolding in place into
// everything this desktop can honestly say about it.
//
// Built like NotificationAppRow rather than out of SettingRow, for the same
// reason -- the head carries the application's own icon and a badge saying
// where it came from, and neither fits a row whose trailing slot is one fixed
// width. What is different here is that the body's contents depend on where the
// application came from: a Flatpak can be inspected and removed, a system
// package can only be described.
//
// Nothing in here writes. Every control leaves through a signal so the page
// owns the service calls, which is what keeps the uninstall confirmation a
// single piece of page state rather than something each row remembers for
// itself.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
Column {
id: root
// { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size }
required property var app
property bool expanded: false
property bool divider: true
property bool busy: false
// Whether ~/.config/autostart currently starts this application.
property bool autostart: false
// The helper's curated permission summary, as a list of human strings.
// Empty while it is still being read, which the row says rather than
// showing a blank line.
property var permissionSummary: []
property bool permissionsKnown: false
property bool flatsealAvailable: false
// Jump chips appear only where a rule for this application actually
// exists somewhere else. A chip that lands on a page with nothing about
// this application on it is worse than no chip.
property bool hasNotificationRule: false
property bool hasSoundRule: false
property bool hasPrivacyRule: false
// Set by the page while this row's uninstall is one press from happening.
property bool confirmingUninstall: false
signal activated
signal autostartToggled(bool value)
signal jumped(string page)
signal flatsealRequested
signal uninstallArmed
signal uninstallCancelled
signal uninstallConfirmed
readonly property string appName: String(root.app?.name ?? "")
readonly property string entryId: String(root.app?.entryId ?? "")
readonly property string flatpakId: String(root.app?.flatpakId ?? "")
readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak"
// The dnf package name, for the refusal row's exact command. The desktop
// entry id is the closest thing to it this desktop can know without asking
// rpm about every application on the machine, so the row says "the package
// that provides" rather than claiming the name is the package.
readonly property string systemName: {
const id = root.entryId.replace(/\.desktop$/, "");
const short = id.split(".").pop();
return short !== "" ? short : id;
}
// `sizeBytes` when the helper could turn flatpak's answer into a number and
// `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever
// arrived is shown as it arrived, rather than one being coerced into the
// other and rounded to nothing.
readonly property string subtitle: {
if (!root.flatpak)
return "Installed by the system package manager";
const bytes = Number(root.app?.sizeBytes ?? 0);
if (Number.isFinite(bytes) && bytes > 0)
return root.flatpakId + " · " + root.formatBytes(bytes);
const text = String(root.app?.size ?? "").trim();
return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text;
}
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;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
readonly property string iconSource: {
const name = String(root.app?.icon ?? "");
return name === "" ? "" : Quickshell.iconPath(name, true);
}
// A stable colour per application, so the tile keeps its identity as the
// list re-sorts or the search narrows it.
readonly property color tileColor: {
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
Theme.green, Theme.orange, Theme.pink];
let hash = 0;
for (let index = 0; index < root.entryId.length; index++)
hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973;
return palette[hash % palette.length];
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(56, copy.implicitHeight + 20)
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 9
z: -1
visible: headHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Rectangle {
id: tile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 8
color: root.iconSource === "" ? root.tileColor : "transparent"
border.width: 0
Text {
anchors.centerIn: parent
visible: root.iconSource === ""
text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?"
color: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Bold
}
IconImage {
anchors.centerIn: parent
implicitSize: 24
asynchronous: true
visible: root.iconSource !== ""
source: root.iconSource
}
}
Column {
id: copy
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: chevron.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
width: parent.width
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8))
text: root.appName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
// Where it came from, said once and in the same place on every
// row -- the difference decides what the body can offer.
Rectangle {
id: badge
anchors.verticalCenter: parent.verticalCenter
width: badgeText.implicitWidth + 14
height: 17
radius: Theme.pillRadius
color: root.flatpak
? Theme.alpha(Theme.cyan, 0.09)
: Theme.alpha(Theme.fgMuted, 0.12)
border.width: 1
border.color: root.flatpak
? Theme.alpha(Theme.cyan, 0.28)
: Theme.alpha(Theme.fgMuted, 0.3)
Text {
id: badgeText
anchors.centerIn: parent
text: root.flatpak ? "FLATPAK" : "SYSTEM"
color: root.flatpak ? Theme.cyan : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: root.subtitle
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.expanded
color: Theme.alpha(Theme.fg, 0.065)
}
HoverHandler {
id: headHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.activated()
}
}
// Indented under the head, so what is inside reads as belonging to the
// application above it rather than to the card.
Column {
id: body
x: 42
width: Math.max(0, parent.width - 42)
visible: root.expanded
// ── What it can reach ────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Permissions"
detail: !root.permissionsKnown
? "Reading what this application is allowed to reach…"
: (root.permissionSummary.length > 0
? root.permissionSummary.join(" · ")
: "Nothing beyond its own sandbox")
controlWidth: root.flatsealAvailable ? 130 : 0
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.flatsealAvailable
text: "Open Flatseal"
onClicked: root.flatsealRequested()
}
}
// The honest refusal, with the command that does the thing this page
// will not. Removing a system package can take the desktop with it, so
// it happens where the consequences are visible.
TextRow {
width: parent.width
visible: !root.flatpak
label: "Managed by dnf"
detail: "Settings does not remove system packages — removing one can take the desktop"
+ " with it. To remove the package that provides this: sudo dnf remove "
+ root.systemName
value: ""
}
// ── What it does at sign-in ──────────────────────────────────────────
SettingRow {
width: parent.width
label: "Start with the session"
detail: root.autostart
? "An entry in ~/.config/autostart starts this when you sign in"
: "Nothing starts this automatically"
controlWidth: 48
divider: root.flatpak || root.hasNotificationRule
|| root.hasSoundRule || root.hasPrivacyRule
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.autostart
onToggled: value => root.autostartToggled(value)
}
}
// ── Where its other settings live ────────────────────────────────────
SettingRow {
width: parent.width
visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule
label: "Elsewhere in Settings"
detail: "This application already has a rule on these pages"
controlWidth: 270
divider: root.flatpak
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasNotificationRule
text: "Notifications"
onClicked: root.jumped("notifications")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasSoundRule
text: "Sound"
onClicked: root.jumped("sound")
}
SettingsChip {
anchors.verticalCenter: parent.verticalCenter
visible: root.hasPrivacyRule
text: "Privacy"
onClicked: root.jumped("privacy")
}
}
}
// ── Removing it ──────────────────────────────────────────────────────
SettingRow {
width: parent.width
visible: root.flatpak
label: "Uninstall"
detail: root.confirmingUninstall
? "This removes the application. Data it kept under ~/.var/app stays behind."
: "Removes the application, and its runtime if nothing else needs it"
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.confirmingUninstall
text: "Uninstall it"
tone: "danger"
enabled: !root.busy
onClicked: root.uninstallConfirmed()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.confirmingUninstall ? "Keep" : "Uninstall…"
enabled: !root.busy
onClicked: root.confirmingUninstall
? root.uninstallCancelled()
: root.uninstallArmed()
}
}
}
Item { width: 1; height: 6 }
}
}
@@ -0,0 +1,64 @@
// A pill-shaped button, for the places a row's trailing slot needs several
// small targets rather than one.
//
// Two of them exist on the Applications page and they want the same shape: the
// catalog's category filter, where one chip is active and the rest are not, and
// the per-application "elsewhere in Settings" links, where none is. `active`
// covers the first case; leaving it false gives the second.
//
// SettingsButton stays the control for a row's primary action -- it is taller,
// squarer, and reads as a button. A chip reads as a tag you can press.
import QtQuick
import qs.config
Rectangle {
id: root
property string text: ""
property bool active: false
signal clicked
implicitWidth: caption.implicitWidth + 22
implicitHeight: 26
radius: Theme.pillRadius
opacity: root.enabled ? 1 : 0.45
color: root.active
? Theme.alpha(Theme.accent, 0.14)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.055)
border.width: root.activeFocus || root.active ? 2 : 1
border.color: root.activeFocus
? Theme.accentSecondary
: (root.active ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.1))
activeFocusOnTab: root.enabled
Accessible.role: Accessible.Button
Accessible.name: root.text
Accessible.focusable: root.enabled
Accessible.focused: root.activeFocus
Accessible.onPressAction: root.clicked()
Keys.onReturnPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
Text {
id: caption
anchors.centerIn: parent
text: root.text
color: root.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.clicked()
}
}
@@ -8,9 +8,16 @@
// Inside a volume, the timeline is the Time Machine view: points in time,
// newest first, each one openable as a folder tree you can take a file out of.
//
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
// it -- so it would report success and change nothing after a reboot.
// The browser is its own card, not a section inside a volume card. It used to
// live inside, behind `volumeCard.open` -- so opening a point in time from a
// collapsed card set the browsing state, drew nothing, and hid the timeline as
// well. Whether a volume's older points are showing has nothing to do with
// whether you are looking inside one of them.
//
// Going back to a previous state of the whole machine is deliberately absent.
// snapper's version of it changes the btrfs default subvolume, and this
// system's fstab pins subvol= explicitly, which overrides it -- so it would
// report success and change nothing after a reboot.
import Quickshell
import QtQuick
@@ -34,7 +41,38 @@ SettingsPage {
property string confirmingDelete: ""
property string confirmingRestore: ""
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
// The service's own answer, kept under the page's name so every `visible:`
// below reads as "because something is being browsed" and never as
// "because a volume card happens to be expanded".
readonly property bool browsingOpen: Snapshots.browserOpen
readonly property var browsingConfigRecord: Snapshots.configs.find(
config => String(config.name ?? "") === Snapshots.browsingConfig) ?? null
// How many of each horizon the timeline keeps. A subset of the 0-50 the
// service accepts, because a dropdown of fifty-one numbers is a list, not a
// choice -- and whatever the configuration is actually set to is added to
// it, so a snapper default of 24 hourly is a row you can see rather than a
// value the picker cannot display.
readonly property var retentionCounts: [0, 1, 2, 3, 5, 7, 10, 14, 21, 30, 50]
function retentionOptions(unit: string, current: int): var {
const counts = root.retentionCounts.slice();
if (counts.indexOf(current) < 0 && current >= 0 && current <= Snapshots.retentionMax)
counts.push(current);
counts.sort((left, right) => left - right);
return counts.map(count => ({
value: count,
label: count === 0 ? "None kept" : count + " " + unit,
detail: count === 0
? "Nothing is kept on this horizon"
: ""
}));
}
function retentionLimit(config: var, horizon: string): int {
return Number((config?.limits ?? ({}))[horizon] ?? 0);
}
Component.onCompleted: Snapshots.refresh()
@@ -96,6 +134,8 @@ SettingsPage {
readonly property string configName: String(volumeCard.modelData.name ?? "")
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
readonly property bool open: root.openConfig === volumeCard.configName
readonly property bool editable: !Snapshots.busy
&& volumeCard.modelData.readable === true
readonly property int shownCount: volumeCard.open
? volumeCard.snapshots.length
: Math.min(root.previewCount, volumeCard.snapshots.length)
@@ -109,21 +149,75 @@ SettingsPage {
? Snapshots.describe(volumeCard.modelData)
: "Nothing is being taken for this volume"
checked: volumeCard.modelData.timelineEnabled === true
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
enabled: volumeCard.editable
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
}
// The three horizons, editable. This was a read-only summary of
// numbers only snapper's config file could change, sitting one
// line above a service function nothing ever called.
TextRow {
label: "Keep"
detail: "Older points are removed automatically once these counts are exceeded"
value: Snapshots.retentionSummary(volumeCard.modelData)
}
OptionPickerRow {
id: hourlyRow
width: parent.width
label: "Hourly"
detail: "Points taken on the hour, and the first to be removed"
options: root.retentionOptions(
"hourly", root.retentionLimit(volumeCard.modelData, "hourly"))
current: root.retentionLimit(volumeCard.modelData, "hourly")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
Number(value),
root.retentionLimit(volumeCard.modelData, "daily"),
root.retentionLimit(volumeCard.modelData, "weekly"))
}
OptionPickerRow {
id: dailyRow
width: parent.width
label: "Daily"
detail: "One point kept per day, for this many days"
options: root.retentionOptions(
"daily", root.retentionLimit(volumeCard.modelData, "daily"))
current: root.retentionLimit(volumeCard.modelData, "daily")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
root.retentionLimit(volumeCard.modelData, "hourly"),
Number(value),
root.retentionLimit(volumeCard.modelData, "weekly"))
}
OptionPickerRow {
id: weeklyRow
width: parent.width
label: "Weekly"
detail: "One point kept per week, for this many weeks — the longest reach back"
options: root.retentionOptions(
"weekly", root.retentionLimit(volumeCard.modelData, "weekly"))
current: root.retentionLimit(volumeCard.modelData, "weekly")
enabled: volumeCard.editable
onPicked: value => Snapshots.setRetention(
volumeCard.configName,
root.retentionLimit(volumeCard.modelData, "hourly"),
root.retentionLimit(volumeCard.modelData, "daily"),
Number(value))
}
ActionRow {
label: "Take one now"
detail: "Kept until you remove it, unlike the automatic ones"
action: "Take snapshot"
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
enabled: volumeCard.editable
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
}
@@ -139,7 +233,6 @@ SettingsPage {
Column {
width: parent.width
visible: !root.browsingOpen
Repeater {
model: volumeCard.open
@@ -154,6 +247,9 @@ SettingsPage {
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
readonly property bool confirming: root.confirmingDelete === pointRow.token
readonly property bool browsingThis: root.browsingOpen
&& Snapshots.browsingConfig === volumeCard.configName
&& Snapshots.browsingSnapshot === Number(pointRow.modelData.number)
width: parent.width
// A kept snapshot is one the timeline will not remove,
@@ -164,6 +260,7 @@ SettingsPage {
detail: String(pointRow.modelData.description ?? "")
+ " · #" + pointRow.modelData.number
+ (pointRow.modelData.kept ? " · kept" : "")
+ (pointRow.browsingThis ? " · open below" : "")
controlWidth: 250
divider: pointRow.index < volumeCard.shownCount - 1
@@ -173,7 +270,7 @@ SettingsPage {
spacing: 8
SettingsButton {
text: "Open"
text: pointRow.browsingThis ? "Open again" : "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(volumeCard.configName,
Number(pointRow.modelData.number), "")
@@ -224,119 +321,128 @@ SettingsPage {
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
text: volumeCard.open ? "Show fewer " : "Show all "
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
// ── Inside one point in time ─────────────────────────────────────
// ── Inside one point in time ─────────────────────────────────────────────
//
// Its own card, rendered on the browsing state alone. Nothing about which
// volume card happens to be expanded can hide it.
SettingsCard {
visible: root.browsingOpen
title: "Browsing " + (root.browsingConfigRecord
? Snapshots.labelFor(root.browsingConfigRecord)
: Snapshots.browsingConfig)
+ " · #" + Snapshots.browsingSnapshot
subtitle: Snapshots.browsingPath === ""
? "The top of that point in time."
: "Inside " + Snapshots.browsingPath + "."
ActionRow {
width: parent.width
label: Snapshots.browsingPath === ""
? "Close this point in time"
: "…/" + Snapshots.browsingPath
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
action: Snapshots.browsingPath === "" ? "Close" : "Back"
enabled: !Snapshots.browsing
onTriggered: Snapshots.browseUp()
}
TextRow {
width: parent.width
visible: Snapshots.browsing
label: "Reading the snapshot…"
detail: "Listing a folder from a point in time"
value: ""
divider: false
}
Repeater {
model: Snapshots.browsing ? [] : Snapshots.browseEntries
delegate: SettingRow {
id: entryRow
required property var modelData
required property int index
readonly property string entryPath: Snapshots.browsingPath === ""
? String(entryRow.modelData.name)
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
Column {
width: parent.width
visible: volumeCard.open && root.browsingOpen
&& Snapshots.browsingConfig === volumeCard.configName
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
label: String(entryRow.modelData.name ?? "")
detail: entryRow.modelData.directory
? "Folder"
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
controlWidth: 230
divider: entryRow.index < Snapshots.browseEntries.length - 1
ActionRow {
width: parent.width
label: Snapshots.browsingPath === ""
? "Snapshot #" + Snapshots.browsingSnapshot
: "…/" + Snapshots.browsingPath
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
action: "Back"
enabled: !Snapshots.browsing
onTriggered: Snapshots.browseUp()
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
TextRow {
width: parent.width
visible: Snapshots.browsing
label: "Reading the snapshot…"
detail: "Listing a folder from a point in time"
value: ""
}
SettingsButton {
visible: entryRow.modelData.directory === true
text: "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath)
}
Repeater {
model: Snapshots.browsing ? [] : Snapshots.browseEntries
SettingsButton {
text: entryRow.confirming ? "Cancel" : "Restore"
enabled: !Snapshots.busy
onClicked: root.confirmingRestore =
entryRow.confirming ? "" : entryRow.entryPath
}
delegate: SettingRow {
id: entryRow
required property var modelData
required property int index
readonly property string entryPath: Snapshots.browsingPath === ""
? String(entryRow.modelData.name)
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
width: parent.width
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
label: String(entryRow.modelData.name ?? "")
detail: entryRow.modelData.directory
? "Folder"
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
controlWidth: 230
divider: entryRow.index < Snapshots.browseEntries.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
visible: entryRow.modelData.directory === true
text: "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath)
}
SettingsButton {
text: entryRow.confirming ? "Cancel" : "Restore"
enabled: !Snapshots.busy
onClicked: root.confirmingRestore =
entryRow.confirming ? "" : entryRow.entryPath
}
SettingsButton {
visible: entryRow.confirming
text: "Put it back"
tone: "danger"
enabled: !Snapshots.busy
onClicked: {
root.confirmingRestore = "";
Snapshots.restore(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath);
}
}
SettingsButton {
visible: entryRow.confirming
text: "Put it back"
tone: "danger"
enabled: !Snapshots.busy
onClicked: {
root.confirmingRestore = "";
Snapshots.restore(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath);
}
}
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseTruncated
label: "Only the first entries are shown"
detail: "This folder holds more than this list can usefully show"
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
label: "Nothing here"
detail: "This folder was empty at that point in time"
value: ""
divider: false
}
}
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseTruncated
label: "Only the first entries are shown"
detail: "This folder holds more than this list can usefully show"
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
label: "Nothing here"
detail: "This folder was empty at that point in time"
value: ""
divider: false
}
}
// ── What it costs ────────────────────────────────────────────────────────
@@ -0,0 +1,153 @@
// What is on the disk, drawn as the disk.
//
// One bar whose segments add up to the whole filesystem, because the question
// people bring to this page is "what is taking my space" and the only answer
// that cannot mislead is one where the parts sum to the total. Four of the five
// segments are measured; the fifth is what is left over, and it is labelled as
// exactly that rather than being called "System" and quietly absorbing every
// measurement error in the other four.
//
// The legend carries the numbers. A bar this wide can hold a 2% segment but it
// cannot label one, and a segment nobody can name is decoration.
import QtQuick
import qs.config
import qs.services
Column {
id: root
readonly property var breakdown: Disks.breakdown ?? null
readonly property var segments: {
const source = root.breakdown?.segments ?? null;
if (!source)
return [];
return [
{
key: "home",
label: "Home",
detail: "Your files, not counting caches",
bytes: Number(source.home ?? 0),
color: Theme.accent
},
{
key: "applications",
label: "Applications",
detail: "Flatpak applications and the runtimes they share",
bytes: Number(source.applications ?? 0),
color: Theme.teal
},
{
key: "caches",
label: "Caches",
detail: "~/.cache, rebuilt as applications run",
bytes: Number(source.caches ?? 0),
color: Theme.warn
},
{
key: "system",
label: "System & everything else",
detail: "Whatever the measured segments do not account for",
bytes: Number(source.system ?? 0),
color: Theme.magenta
},
{
key: "free",
label: "Free",
detail: "Reported by the filesystem",
bytes: Number(source.free ?? 0),
color: Theme.alpha(Theme.fg, 0.1)
}
];
}
readonly property real total: root.segments.reduce(
(sum, segment) => sum + Math.max(0, segment.bytes), 0)
width: parent ? parent.width : 620
spacing: 10
Text {
width: parent.width
visible: root.total <= 0
text: Disks.measuringBreakdown
? "Measuring what is on this disk…"
: (Disks.lastError !== "" ? Disks.lastError : "Not measured yet.")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
visible: root.total > 0
height: 26
radius: 8
clip: true
color: Theme.alpha(Theme.fg, 0.06)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
Row {
anchors.fill: parent
anchors.margins: 1
spacing: 0
Repeater {
model: root.segments
delegate: Rectangle {
required property var modelData
// A measured segment always gets a sliver, so "1 GB of
// caches" is visible as a fact rather than rounded away.
width: root.total > 0 && modelData.bytes > 0
? Math.max(parent.width * (modelData.bytes / root.total), 2)
: 0
height: parent.height
color: modelData.color
border.width: 0
}
}
}
}
Flow {
width: parent.width
visible: root.total > 0
spacing: 14
Repeater {
model: root.segments
delegate: Row {
required property var modelData
spacing: 6
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 9
height: 9
radius: 3
color: modelData.key === "free"
? Theme.alpha(Theme.fg, 0.25)
: modelData.color
border.width: 0
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.label + " " + Disks.formatBytes(modelData.bytes)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
}
@@ -9,6 +9,13 @@
// 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.
@@ -22,19 +29,25 @@ SettingsPage {
objectName: "storage"
title: "Storage"
lede: "How much space is left, what is using it, and whether the drive is healthy."
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
// 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;
}
// 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)
@@ -51,12 +64,74 @@ SettingsPage {
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Free space"
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
@@ -118,9 +193,9 @@ SettingsPage {
}
SettingsCard {
title: "What is using it"
title: "Folders"
subtitle: Disks.foldersMeasured
? "Measured by walking each folder."
? "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 {
@@ -160,12 +235,9 @@ SettingsPage {
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)
width: root.diskBytes > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2)
: 0
height: parent.height
radius: parent.radius
@@ -182,26 +254,98 @@ SettingsPage {
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
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.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"
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
// 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"])
}
}
@@ -281,6 +425,11 @@ SettingsPage {
// 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)
@@ -63,6 +63,10 @@ WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
InstalledAppRow 1.0 InstalledAppRow.qml
FileTypePicker 1.0 FileTypePicker.qml
SettingsChip 1.0 SettingsChip.qml
StorageBreakdownBar 1.0 StorageBreakdownBar.qml
DockPinsStrip 1.0 DockPinsStrip.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml