Three bugs, all silent, all in the same feature. The write always worked. What failed was the refresh after it. That refresh is called from the mutation's own onExited handler and was guarded on `busy`, a binding over both processes -- and a binding hands back its cached value until the change notification feeding it has been delivered, which inside that handler has not happened yet. So `busy` read true, refresh returned immediately, and the page kept showing the old application with no error anywhere. Guards now read the Process objects directly, where the value is current, and a refresh is no longer blocked by the mutation that asked for it. The service also kept its own list of which roles it would accept. It stayed at seven when the helper and the page grew documents, text and archives, so choosing a PDF viewer set an error and changed nothing. It is derived from the snapshot now. And category matching never worked. DesktopEntries returns a QML list, for which Array.isArray is false, so the code stringified it into "Network,WebBrowser" and split on ";" alone -- one token matching no category. Browsers still appeared because their generic name contains "web browser" and the terms fallback carried the role by itself. Archives matched nothing at all, so that row could only ever offer the application it already had. The harness that should have caught the first bug passed while it was live: it set each role to the value it already had and asserted no error appeared, and the bug produces no error. It now changes a role to a genuinely different application, requires the service to observe the new value, and changes it back -- with the contract restoring the original from the outside however the run ends. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
291 lines
13 KiB
QML
291 lines
13 KiB
QML
// Applications and session startup.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "applications"
|
|
title: "Applications"
|
|
lede: "Choose what opens your files and links, and what starts with your session."
|
|
|
|
property string expandedRole: ""
|
|
property bool addingAutostart: false
|
|
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
|
|
// can never open one image in a viewer and its neighbour in an editor.
|
|
// The detail line names the family the way someone would describe it.
|
|
readonly property var roles: [
|
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
|
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
|
|
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
|
|
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
|
|
{ key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
|
{ key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
|
{ key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
|
|
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
|
|
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
|
|
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
|
|
]
|
|
|
|
function desktopId(entry: var): string {
|
|
const entryId = String(entry?.id ?? "");
|
|
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
|
|
}
|
|
|
|
function displayName(entry: var): string {
|
|
return String(entry?.name || entry?.genericName || root.desktopId(entry));
|
|
}
|
|
|
|
function currentHandler(role: string): string {
|
|
return String(DefaultApps.handlers[role] ?? "");
|
|
}
|
|
|
|
function currentEntry(role: string): var {
|
|
const handler = root.currentHandler(role);
|
|
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
|
|
}
|
|
|
|
function matchesRole(entry: var, role: var): bool {
|
|
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
|
// Array.isArray is false for it. The old code took that as "this is a
|
|
// string", stringified the list into "Network,WebBrowser" and then split
|
|
// on ";" only -- producing the single token "network,webbrowser", which
|
|
// matches no category at all.
|
|
//
|
|
// Nothing failed loudly. Browsers still appeared because their generic
|
|
// name contains "web browser", so the terms fallback carried the role
|
|
// by itself. Archives matched NOTHING, which meant that row could only
|
|
// ever offer the application it already had.
|
|
//
|
|
// Joining first and splitting on both separators handles the list form
|
|
// and a plain string equally.
|
|
const raw = entry.categories;
|
|
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
|
|
const categories = [];
|
|
for (const value of joined.split(/[;,]/)) {
|
|
const category = value.trim().toLowerCase();
|
|
if (category !== "")
|
|
categories.push(category);
|
|
}
|
|
const metadata = [entry.name, entry.genericName]
|
|
.map(value => String(value ?? "").toLowerCase())
|
|
.join(" ");
|
|
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|
|
|| role.terms.some(term => metadata.includes(term));
|
|
}
|
|
|
|
function choicesForRole(role: var): var {
|
|
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
|
|
const currentEntry = root.currentEntry(role.key);
|
|
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
|
|
choices.push(currentEntry);
|
|
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
|
|
}
|
|
|
|
TextRow {
|
|
visible: DefaultApps.lastError !== ""
|
|
label: "Application settings need attention"
|
|
detail: DefaultApps.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Default applications"
|
|
subtitle: "Open a row to choose from applications that advertise the matching role."
|
|
|
|
Repeater {
|
|
model: root.roles
|
|
|
|
delegate: Column {
|
|
id: roleBlock
|
|
|
|
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)
|
|
|
|
width: parent.width
|
|
|
|
SettingRow {
|
|
label: roleBlock.modelData.label
|
|
detail: roleBlock.modelData.detail
|
|
value: DefaultApps.busy ? "Loading…" : (
|
|
roleBlock.selectedEntry
|
|
? root.displayName(roleBlock.selectedEntry)
|
|
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
|
)
|
|
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
|
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 = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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."
|
|
|
|
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"
|
|
}
|
|
|
|
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: "User autostart"
|
|
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
|
|
|
ActionRow {
|
|
label: "Add an application"
|
|
detail: root.addingAutostart
|
|
? "Search the applications installed on this machine"
|
|
: "Start another installed application when you sign in"
|
|
action: root.addingAutostart ? "Close" : "Choose"
|
|
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
|
enabled: !DefaultApps.busy
|
|
onTriggered: root.addingAutostart = !root.addingAutostart
|
|
}
|
|
|
|
AutostartAppPicker {
|
|
visible: root.addingAutostart
|
|
width: parent.width
|
|
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
|
onPicked: id => {
|
|
DefaultApps.addAutostart(id);
|
|
root.addingAutostart = false;
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
|
label: "No user autostart entries"
|
|
detail: "Applications can add entries to ~/.config/autostart."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
Repeater {
|
|
model: DefaultApps.autostartEntries
|
|
|
|
delegate: SettingRow {
|
|
id: autostartRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
label: autostartRow.modelData.name
|
|
detail: autostartRow.modelData.id
|
|
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
|
|
activatable: !DefaultApps.busy
|
|
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
|
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Compositor autostart"
|
|
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
|
|
|
TextRow {
|
|
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
|
label: "No compositor entries found"
|
|
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
Repeater {
|
|
model: DefaultApps.luaAutostartEntries
|
|
|
|
delegate: TextRow {
|
|
id: luaRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
label: luaRow.modelData.name
|
|
detail: luaRow.modelData.command
|
|
value: "Hyprland"
|
|
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
divider: false
|
|
onTriggered: DefaultApps.refresh()
|
|
}
|
|
}
|
|
}
|