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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user