Delete extra themes from the theme picker (#14713)

* Delete extra themes from the theme picker

Delete on an extra (user-installed) theme opens the shared faded confirmation dialog; confirming runs omarchy-theme-remove and selection lands on the theme before the deleted one. Stock themes and user overrides of them are not offered for deletion, apply/re-ask are blocked while a removal runs, and row refreshes queue so a deletion never leaves the list stale.

* Fail closed on theme deletion until stock themes are known

Keep Delete disabled until the stock listing finishes successfully, so a stock override cannot look deletable in the startup window. Also hold theme apply (and further delete asks) from removal exit until the refreshed rows load, so Enter cannot apply the just-deleted theme in the gap.
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-10-09 13:18:28 +02:00
1 parent fcf9eeb5c4
commit 988f44ea1a
3 files changed
+403 -5

No files matched your search

+139 -5
View File
@@ -15,6 +15,7 @@ Item {
// Injected by omarchy-shell; defaults to the session OMARCHY_PATH.
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property string stateHome: Quickshell.env("HOME") + "/.local/state"
property string userThemesPath: Quickshell.env("HOME") + "/.config/omarchy/themes"
property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (stateHome + "/omarchy/current/theme/backgrounds")
property string imageRows: ""
property string loadedImageRows: ""
@@ -39,6 +40,13 @@ Item {
property string themeRows: ""
property bool themeMode: false
property bool themeOpenPending: false
property var extraThemeNames: []
property var stockThemeNames: []
property bool stockThemesKnown: false
property bool awaitingDeleteRefresh: false
property string pendingDeleteTheme: ""
property string deleteSelectionPath: ""
property bool deleteConfirmOpen: false
// Bound to the central [image-picker] section in shell.toml via Commons.Color.qml.
// `dimColor` tints unselected slices and text outlines against the desktop
// behind the picker; it intentionally tracks the foundational background,
@@ -58,7 +66,7 @@ Item {
readonly property int previewRadius: Math.max(1, Math.min(16, Math.ceil((panel.width - expandedWidth) / (2 * (sliceWidth + sliceSpacing))) + 1))
onPreviewRadiusChanged: updateVisibleItems()
onOpenedChanged: if (!opened) { layoutSettled = false; renderedFrames = 0 }
onOpenedChanged: if (!opened) { layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false }
function scriptPath(name) {
return omarchyPath + "/shell/plugins/image-picker/" + name
@@ -179,6 +187,7 @@ Item {
}
function applySelected() {
if (themeMode && (deleteThemeProc.running || awaitingDeleteRefresh)) return
var path = currentPath()
if (themeMode) {
@@ -251,6 +260,10 @@ Item {
}
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
deleteConfirmOpen = false
pendingDeleteTheme = ""
deleteSelectionPath = ""
awaitingDeleteRefresh = false
if (requestActive && doneFile && doneFile !== nextDoneFile)
finishDoneFile(doneFile)
@@ -356,6 +369,8 @@ Item {
themeOpenPending = true
}
refreshThemeRows()
refreshExtraThemes()
refreshStockThemes()
}
function openThemeRows() {
@@ -363,8 +378,53 @@ Item {
themeMode = true
}
// A refresh asked mid-refresh (a deletion finishing behind the open-time
// one) queues a second pass instead of leaving the list stale.
function refreshThemeRows() {
if (!themeRowsProc.running) themeRowsProc.running = true
if (themeRowsProc.running) { themeRowsProc.queued = true; return }
themeRowsProc.running = true
}
function refreshExtraThemes() {
if (extraThemesProc.running) { extraThemesProc.queued = true; return }
extraThemesProc.running = true
}
function refreshStockThemes() {
if (!stockThemesProc.running) stockThemesProc.running = true
}
function selectedThemeName() {
return nameForPath(currentPath())
}
function canDeleteSelectedTheme() {
return themeMode && !deleteThemeProc.running && !awaitingDeleteRefresh && stockThemesKnown && ImagePickerModel.canDeleteTheme(selectedThemeName(), extraThemeNames, stockThemeNames)
}
function requestDeleteSelectedTheme() {
if (!canDeleteSelectedTheme()) return
pendingDeleteTheme = selectedThemeName()
deleteConfirm.selectedIndex = 1
deleteConfirmOpen = true
}
function cancelDeleteTheme() {
deleteConfirmOpen = false
pendingDeleteTheme = ""
}
// Removal itself is omarchy-theme-remove's job (including its guards and
// notification); the picker only refreshes its rows once it finishes.
function confirmDeleteTheme() {
var name = pendingDeleteTheme
deleteConfirmOpen = false
pendingDeleteTheme = ""
if (!name || deleteThemeProc.running) return
// Keep the user's place in the list once the deleted theme is gone.
if (name === selectedThemeName()) deleteSelectionPath = ImagePickerModel.replacementSelectionPath(imageArray, selectedIndex, filterText)
deleteThemeProc.command = ["omarchy-theme-remove", name]
deleteThemeProc.running = true
}
function updateThemeRows(rows) {
@@ -376,8 +436,11 @@ Item {
if (rows) openThemeRows()
} else if (changed && rows && themeMode && opened) {
// A theme was added or removed since the rows were last read. Keep the
// user's place in the carousel rather than jumping back to the current.
selectedImage = currentPath() || currentThemePreview()
// user's place in the carousel rather than jumping back to the current;
// a just-deleted selection lands on the theme before it.
selectedImage = deleteSelectionPath || currentPath() || currentThemePreview()
deleteSelectionPath = ""
awaitingDeleteRefresh = false
imageRows = rows
loadRows(rows, false)
}
@@ -392,13 +455,62 @@ Item {
Process {
id: themeRowsProc
property bool queued: false
command: [root.omarchyPath + "/bin/omarchy-theme-switcher", "--print-rows"]
stdout: StdioCollector {
onStreamFinished: root.updateThemeRows(String(text || "").trim())
}
onExited: {
if (queued) {
queued = false
running = true
}
}
}
Component.onCompleted: refreshThemeRows()
// The same listing omarchy-theme-remove offers interactively: real
// directories under the user themes path, never symlinked working copies.
Process {
id: extraThemesProc
property bool queued: false
command: ["find", root.userThemesPath, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "!", "-xtype", "l", "-printf", "%f\n"]
stdout: StdioCollector {
onStreamFinished: root.extraThemeNames = ImagePickerModel.parseThemeNames(String(text || ""))
}
onExited: {
if (queued) {
queued = false
running = true
}
}
}
Process {
id: stockThemesProc
command: ["find", root.omarchyPath + "/themes", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\n"]
stdout: StdioCollector {
onStreamFinished: root.stockThemeNames = ImagePickerModel.parseThemeNames(String(text || ""))
}
// Until this lands, stockThemeNames is empty and an override would look
// deletable: deletion stays disabled unless the listing succeeded.
onExited: function(exitCode) { if (exitCode === 0) root.stockThemesKnown = true }
}
Process {
id: deleteThemeProc
onExited: function(exitCode) {
if (exitCode === 0) root.awaitingDeleteRefresh = true
else root.deleteSelectionPath = ""
root.refreshExtraThemes()
root.refreshThemeRows()
}
}
Component.onCompleted: {
refreshThemeRows()
refreshExtraThemes()
refreshStockThemes()
}
function startImageScan(serial, dirs) {
if (loadImagesProc.running) {
@@ -564,6 +676,10 @@ Item {
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (root.deleteConfirmOpen) {
if (deleteConfirm.handleKey(event)) event.accepted = true
return
}
// Nothing is visible before the reveal: only let the user back
// out, never filter into a dead end or apply an unseen pick.
if (!root.layoutSettled) {
@@ -583,6 +699,9 @@ Item {
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySelected()
event.accepted = true
} else if (event.key === Qt.Key_Delete && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) {
root.requestDeleteSelectedTheme()
event.accepted = true
} else if (root.filterable && Util.editsFilter(event, root.filterText)) {
root.updateFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
@@ -755,5 +874,20 @@ Item {
elide: Text.ElideRight
}
}
// Unlike the picker itself, the confirmation keeps the faded backdrop:
// a destructive choice should take over the screen.
ConfirmDialog {
id: deleteConfirm
anchors.fill: parent
opened: root.deleteConfirmOpen
z: 10
message: "Do you want to delete " + (root.pendingDeleteTheme ? root.labelForPath(root.pendingDeleteTheme) : "") + "?"
confirmText: "Delete"
background: root.dimColor
foreground: root.foreground
onCanceled: root.cancelDeleteTheme()
onConfirmed: root.confirmDeleteTheme()
}
}
}
@@ -6,6 +6,41 @@ function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
}
// Extra (user-installed) theme names, one per line, as listed from the user
// themes directory: the set omarchy-theme-remove can delete.
function parseThemeNames(text) {
var names = []
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var name = lines[i].trim()
if (name) names.push(name)
}
return names
}
// A user directory shadowing a stock theme holds customizations, not an
// extra theme: removing it from here would silently revert to stock (and a
// shadow can even list twice, once per preview extension), so only themes
// with no stock twin can be deleted from the picker.
function canDeleteTheme(name, extraNames, stockNames) {
if (!name || !Array.isArray(extraNames) || extraNames.indexOf(name) === -1) return false
return !Array.isArray(stockNames) || stockNames.indexOf(name) === -1
}
// Where selection lands when the selected image goes away: the previous
// match in list order, or the next one when the first match is deleted.
function replacementSelectionPath(images, selectedIndex, filterText) {
var values = Array.isArray(images) ? images : []
if (selectedIndex < 0 || selectedIndex >= values.length) return ""
for (var i = selectedIndex - 1; i >= 0; i--) {
if (itemMatches(values, i, filterText)) return values[i].filePath
}
for (var i = selectedIndex + 1; i < values.length; i++) {
if (itemMatches(values, i, filterText)) return values[i].filePath
}
return ""
}
function loadRows(rows) {
var images = []
var seen = {}
@@ -125,6 +160,9 @@ if (typeof module !== "undefined") {
module.exports = {
nameForPath: nameForPath,
labelForPath: labelForPath,
parseThemeNames: parseThemeNames,
canDeleteTheme: canDeleteTheme,
replacementSelectionPath: replacementSelectionPath,
loadRows: loadRows,
itemMatches: itemMatches,
firstMatchingIndex: firstMatchingIndex,