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,
+226
View File
@@ -258,4 +258,230 @@ reveal = revealContext([true], 2)
reveal.opened = false
reveal.settleReveal()
assert(!reveal.layoutSettled, 'settle does nothing once the picker is closed')
assertEqual(typeof picker.parseThemeNames, 'function', 'image picker model parses extra theme listings')
assertDeepEqual(picker.parseThemeNames('fjord\ngiants\n\nspace\n'), ['fjord', 'giants', 'space'], 'image picker parses extra theme names one per line')
assertDeepEqual(picker.parseThemeNames(''), [], 'image picker parses an empty extra theme listing')
assert(picker.canDeleteTheme('fjord', ['fjord', 'giants']), 'an extra theme can be deleted from the picker')
assert(!picker.canDeleteTheme('nord', ['fjord', 'giants']), 'a stock theme cannot be deleted from the picker')
assert(!picker.canDeleteTheme('', ['fjord']), 'no selection cannot be deleted from the picker')
assert(!picker.canDeleteTheme('fjord', []), 'nothing can be deleted before extra themes are known')
assert(!picker.canDeleteTheme('nord', ['nord', 'fjord'], ['nord']), 'a user override of a stock theme is not deleted from the picker')
assert(picker.canDeleteTheme('fjord', ['fjord'], ['nord']), 'an extra theme beside the stock list can be deleted from the picker')
assert(
/command: \["find", root\.userThemesPath, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "!", "-xtype", "l", "-printf", "%f\\n"\]/.test(imagePickerQml) &&
/root\.extraThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) &&
/function openThemes\(\) \{[\s\S]*?refreshExtraThemes\(\)/.test(imagePickerQml),
'image picker learns extra themes the way theme remove lists them'
)
assert(
/ConfirmDialog \{\s*id: deleteConfirm\s*anchors\.fill: parent\s*opened: root\.deleteConfirmOpen\s*z: 10\s*message: "Do you want to delete " \+ \(root\.pendingDeleteTheme \? root\.labelForPath\(root\.pendingDeleteTheme\) : ""\) \+ "\?"\s*confirmText: "Delete"/.test(imagePickerQml) &&
/onCanceled: root\.cancelDeleteTheme\(\)\s*onConfirmed: root\.confirmDeleteTheme\(\)/.test(imagePickerQml),
'image picker confirms theme deletion in a faded centered dialog'
)
assert(
/if \(root\.deleteConfirmOpen\) \{\s*if \(deleteConfirm\.handleKey\(event\)\) event\.accepted = true\s*return\s*\}/.test(imagePickerQml) &&
imagePickerQml.indexOf('if (root.deleteConfirmOpen) {') < imagePickerQml.indexOf('if (!root.layoutSettled) {') &&
/else if \(event\.key === Qt\.Key_Delete && !\(event\.modifiers & \(Qt\.ControlModifier \| Qt\.AltModifier \| Qt\.MetaModifier\)\)\) \{\s*root\.requestDeleteSelectedTheme\(\)\s*event\.accepted = true/.test(imagePickerQml),
'image picker routes keys to the delete confirmation first and maps Delete to it'
)
assert(
/onOpenedChanged: if \(!opened\) \{ layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false \}/.test(imagePickerQml) &&
/id: deleteThemeProc\s*onExited: function\(exitCode\) \{\s*if \(exitCode === 0\) root\.awaitingDeleteRefresh = true\s*else root\.deleteSelectionPath = ""\s*root\.refreshExtraThemes\(\)\s*root\.refreshThemeRows\(\)/.test(imagePickerQml),
'image picker drops a pending deletion on close and refreshes after one runs'
)
// Run the delete flow itself against a mocked selection and remove process.
const deleteFns = [
imagePickerQml.match(/function selectedThemeName\(\) \{[\s\S]*?\n \}/)[0],
imagePickerQml.match(/function canDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0],
imagePickerQml.match(/function requestDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0],
imagePickerQml.match(/function cancelDeleteTheme\(\) \{[\s\S]*?\n \}/)[0],
imagePickerQml.match(/function confirmDeleteTheme\(\) \{[\s\S]*?\n \}/)[0]
].join('\n')
function deleteContext(path, extras, inThemeMode) {
const ctx = {
themeMode: inThemeMode,
extraThemeNames: extras,
stockThemeNames: [],
stockThemesKnown: true,
awaitingDeleteRefresh: false,
pendingDeleteTheme: '',
deleteSelectionPath: '',
deleteConfirmOpen: false,
imageArray: [{ filePath: path }],
selectedIndex: 0,
filterText: '',
deleteThemeProc: { running: false, command: [] },
deleteConfirm: { selectedIndex: 0 },
ImagePickerModel: picker,
nameForPath: picker.nameForPath,
currentPath() { return path }
}
ctx.root = ctx
require('vm').runInNewContext(deleteFns, ctx)
return ctx
}
let deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord', 'giants'], true)
deletion.requestDeleteSelectedTheme()
assert(deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === 'fjord', 'Delete asks to confirm the selected extra theme')
deletion.cancelDeleteTheme()
assert(!deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === '' && deletion.deleteThemeProc.command.length === 0, 'canceling deletion removes nothing')
deletion.requestDeleteSelectedTheme()
deletion.confirmDeleteTheme()
assertDeepEqual(deletion.deleteThemeProc.command, ['omarchy-theme-remove', 'fjord'], 'confirming deletion removes exactly the selected theme')
assert(deletion.deleteThemeProc.running && !deletion.deleteConfirmOpen, 'confirming deletion runs the removal and closes the dialog')
deletion = deleteContext('/cache/previews/nord.png', ['fjord'], true)
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme')
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], false)
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does nothing in the background picker')
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
deletion.requestDeleteSelectedTheme()
deletion.deleteThemeProc.running = true
deletion.confirmDeleteTheme()
assertEqual(deletion.deleteThemeProc.command.length, 0, 'a second removal cannot start while one is running')
assertEqual(typeof picker.replacementSelectionPath, 'function', 'image picker model finds where to land after a deletion')
const siblings = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png'].map(filePath => ({ filePath }))
assertEqual(picker.replacementSelectionPath(siblings, 1, ''), '/p/alpha.png', 'deleting a theme lands on the one before it')
assertEqual(picker.replacementSelectionPath(siblings, 2, ''), '/p/bravo.png', 'deleting the last theme lands on the one before it')
assertEqual(picker.replacementSelectionPath(siblings, 0, ''), '/p/bravo.png', 'deleting the first theme lands on the new first theme')
assertEqual(picker.replacementSelectionPath(siblings.slice(0, 1), 0, ''), '', 'deleting the only theme has nowhere to land')
assertEqual(picker.replacementSelectionPath(siblings, 9, ''), '', 'a stale selection has nowhere to land')
const mixed = ['/p/dark-a.png', '/p/light-b.png', '/p/dark-c.png', '/p/dark-d.png'].map(filePath => ({ filePath }))
assertEqual(picker.replacementSelectionPath(mixed, 2, 'dark'), '/p/dark-a.png', 'deleting a filtered theme lands on the previous match')
assertEqual(picker.replacementSelectionPath(mixed, 0, 'dark'), '/p/dark-c.png', 'deleting the first match lands on the next match')
assert(
/function confirmDeleteTheme\(\) \{[\s\S]*?if \(name === selectedThemeName\(\)\) deleteSelectionPath = ImagePickerModel\.replacementSelectionPath\(imageArray, selectedIndex, filterText\)/.test(imagePickerQml) &&
/selectedImage = deleteSelectionPath \|\| currentPath\(\) \|\| currentThemePreview\(\)\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml),
'image picker retains its place by selecting the previous theme after a deletion'
)
function retentionContext(paths, selected, filter) {
const ctx = deleteContext(paths[selected], paths.map(filePath => picker.nameForPath(filePath)), true)
ctx.imageArray = paths.map(filePath => ({ filePath }))
ctx.selectedIndex = selected
ctx.filterText = filter
ctx.deleteSelectionPath = ''
return ctx
}
const trio = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png']
let retention = retentionContext(trio, 1, '')
retention.requestDeleteSelectedTheme()
retention.confirmDeleteTheme()
assertEqual(retention.deleteSelectionPath, '/p/alpha.png', 'confirming a deletion remembers the theme before it')
retention = retentionContext(trio, 0, '')
retention.requestDeleteSelectedTheme()
retention.confirmDeleteTheme()
assertEqual(retention.deleteSelectionPath, '/p/bravo.png', 'confirming the first theme remembers the one after it')
retention = retentionContext(trio, 1, '')
retention.requestDeleteSelectedTheme()
retention.currentPath = () => '/p/charlie.png'
retention.confirmDeleteTheme()
assertEqual(retention.deleteSelectionPath, '', 'no landing is remembered when the selection moved off the deleted theme')
assertDeepEqual(retention.deleteThemeProc.command, ['omarchy-theme-remove', 'bravo'], 'the confirmed theme is still the one removed')
assert(
/id: stockThemesProc\s*command: \["find", root\.omarchyPath \+ "\/themes", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"\]/.test(imagePickerQml) &&
/root\.stockThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) &&
/canDeleteTheme\(selectedThemeName\(\), extraThemeNames, stockThemeNames\)/.test(imagePickerQml),
'image picker knows stock theme names so overrides stay undeletable'
)
assert(
/function refreshThemeRows\(\) \{\s*if \(themeRowsProc\.running\) \{ themeRowsProc\.queued = true; return \}/.test(imagePickerQml) &&
/function refreshExtraThemes\(\) \{\s*if \(extraThemesProc\.running\) \{ extraThemesProc\.queued = true; return \}/.test(imagePickerQml) &&
(imagePickerQml.match(/property bool queued: false/g) || []).length === 2 &&
(imagePickerQml.match(/onExited: \{\s*if \(queued\) \{\s*queued = false\s*running = true\s*\}\s*\}/g) || []).length === 2,
'theme row and extra theme refreshes queue behind a running refresh'
)
assert(
/function applySelected\(\) \{\s*if \(themeMode && \(deleteThemeProc\.running \|\| awaitingDeleteRefresh\)\) return/.test(imagePickerQml) &&
/function canDeleteSelectedTheme\(\) \{\s*return themeMode && !deleteThemeProc\.running && !awaitingDeleteRefresh && stockThemesKnown && ImagePickerModel\.canDeleteTheme/.test(imagePickerQml) &&
/function requestDeleteSelectedTheme\(\) \{[\s\S]*?deleteConfirm\.selectedIndex = 1\s*deleteConfirmOpen = true/.test(imagePickerQml) &&
/function openSelector\([^)]*\) \{\s*deleteConfirmOpen = false\s*pendingDeleteTheme = ""\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml),
'image picker cannot apply or re-ask mid-removal and resets delete state per open'
)
deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true)
deletion.stockThemeNames = ['nord']
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme override')
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
deletion.stockThemeNames = ['nord']
deletion.deleteThemeProc.running = true
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does not ask while a removal is running')
const refreshFn = imagePickerQml.match(/function refreshThemeRows\(\) \{[\s\S]*?\n \}/)[0]
const refreshCtx = { themeRowsProc: { running: true, queued: false } }
require('vm').runInNewContext(refreshFn, refreshCtx)
refreshCtx.refreshThemeRows()
assert(refreshCtx.themeRowsProc.queued && refreshCtx.themeRowsProc.running, 'a refresh asked mid-refresh queues behind it')
refreshCtx.themeRowsProc.running = false
refreshCtx.themeRowsProc.queued = false
refreshCtx.refreshThemeRows()
assert(refreshCtx.themeRowsProc.running, 'a refresh asked while idle starts at once')
const updateFn = imagePickerQml.match(/function updateThemeRows\(rows\) \{[\s\S]*?\n \}/)[0]
const updateCtx = {
themeRows: 'old', themeOpenPending: false, themeMode: true, opened: true,
deleteSelectionPath: '/p/alpha.png', selectedImage: '', imageRows: '', loaded: null,
currentPath() { return '/p/bravo.png' },
currentThemePreview() { return '' },
openThemeRows() {},
loadRows(rows, reveal) { updateCtx.loaded = [rows, reveal] }
}
require('vm').runInNewContext(updateFn, updateCtx)
updateCtx.updateThemeRows('new-rows')
assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'a row refresh after deletion selects the remembered previous theme')
assertEqual(updateCtx.deleteSelectionPath, '', 'the remembered landing is spent by the refresh using it')
assertDeepEqual(updateCtx.loaded, ['new-rows', false], 'the refresh reloads rows without reopening the picker')
updateCtx.themeRows = 'new-rows'
updateCtx.loaded = null
updateCtx.updateThemeRows('new-rows')
assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'an unchanged refresh leaves the selection alone')
assert(
/property bool stockThemesKnown: false/.test(imagePickerQml) &&
/id: stockThemesProc[\s\S]*?onExited: function\(exitCode\) \{ if \(exitCode === 0\) root\.stockThemesKnown = true \}/.test(imagePickerQml),
'deletion stays disabled until the stock listing finishes successfully'
)
deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true)
deletion.stockThemeNames = []
deletion.stockThemesKnown = false
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does nothing before stock themes are known')
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
deletion.stockThemeNames = ['nord']
deletion.awaitingDeleteRefresh = true
deletion.requestDeleteSelectedTheme()
assert(!deletion.deleteConfirmOpen, 'Delete does not ask while the post-delete refresh is pending')
const applyFn = imagePickerQml.match(/function applySelected\(\) \{[\s\S]*?\n \}/)[0]
function applyContext(awaiting) {
const ctx = {
themeMode: true, opened: true, awaitingDeleteRefresh: awaiting,
deleteThemeProc: { running: false },
currentPath() { return '/p/fjord.jpg' },
nameForPath: picker.nameForPath,
Util: { execArgv(argv) { ctx.applied = argv } }
}
ctx.root = ctx
require('vm').runInNewContext(applyFn, ctx)
return ctx
}
let application = applyContext(true)
application.applySelected()
assert(application.applied === undefined && application.opened, 'Enter cannot apply a deleted theme before rows reload')
application = applyContext(false)
application.applySelected()
assertDeepEqual(application.applied, ['omarchy-theme-set', 'fjord'], 'Enter applies the selected theme once no deletion is pending')
updateCtx.awaitingDeleteRefresh = true
updateCtx.deleteSelectionPath = '/p/alpha.png'
updateCtx.themeRows = 'new-rows'
updateCtx.updateThemeRows('after-delete-rows')
assertEqual(updateCtx.awaitingDeleteRefresh, false, 'the post-delete refresh loading rows releases the apply guard')
JS