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();
}
}
}
}