From 988f44ea1a16250785eee1c73cbaf558080589db Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 13:18:28 +0200 Subject: [PATCH] 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. --- shell/plugins/image-picker/ImagePicker.qml | 144 ++++++++++- .../plugins/image-picker/ImagePickerModel.js | 38 +++ test/shell.d/image-picker-test.sh | 226 ++++++++++++++++++ 3 files changed, 403 insertions(+), 5 deletions(-) diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index e3947e78..f9280f2e 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -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() + } } } diff --git a/shell/plugins/image-picker/ImagePickerModel.js b/shell/plugins/image-picker/ImagePickerModel.js index 86c4b07a..f04da5ae 100644 --- a/shell/plugins/image-picker/ImagePickerModel.js +++ b/shell/plugins/image-picker/ImagePickerModel.js @@ -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, diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index 3867c651..3d1e402a 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -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