From adb2d2d2cd959945ec0c641d1932ebb425e66a75 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:31:45 -0800 Subject: [PATCH] feat(settings): delete imported terminal themes from the theme picker (#37356) * feat(settings): delete imported terminal themes from the theme picker * fix(settings): theme delete reads latest settings, undo keeps later choices, focus stays in picker * fix(settings): serialize theme deletes and keep later edits on undo * fix(settings): theme delete button visibility on a wrapper, not a Button restyle --- .../settings/TerminalThemePicker.tsx | 143 +++++++++++------- .../settings/TerminalThemeSections.tsx | 47 +++++- src/renderer/src/i18n/locales/en.json | 8 +- src/shared/terminal-theme-selection.test.ts | 64 ++++++++ src/shared/terminal-theme-selection.ts | 67 +++++++- 5 files changed, 274 insertions(+), 55 deletions(-) diff --git a/src/renderer/src/components/settings/TerminalThemePicker.tsx b/src/renderer/src/components/settings/TerminalThemePicker.tsx index c1c6ffe9db2..090cfd15d9d 100644 --- a/src/renderer/src/components/settings/TerminalThemePicker.tsx +++ b/src/renderer/src/components/settings/TerminalThemePicker.tsx @@ -1,8 +1,11 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' +import { Trash2 } from 'lucide-react' import { ScrollArea } from '../ui/scroll-area' +import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { MAX_THEME_RESULTS } from './SettingsConstants' import { filterTerminalThemeOptions, @@ -21,6 +24,7 @@ type ThemePickerProps = { query: string onQueryChange: (value: string) => void onSelectTheme: (theme: string) => void + onDeleteImportedTheme?: (theme: TerminalThemeOption) => void /** After import, reveal and briefly highlight the Imported group. */ importedHighlightSignal?: number } @@ -33,9 +37,11 @@ export function ThemePicker({ query, onQueryChange, onSelectTheme, + onDeleteImportedTheme, importedHighlightSignal }: ThemePickerProps): React.JSX.Element { const importedGroupRef = useRef(null) + const searchInputRef = useRef(null) const [highlightImported, setHighlightImported] = useState(false) // Imported themes sit below the fixed-height built-in list; reveal them after import. @@ -85,6 +91,7 @@ export function ThemePicker({

{description}

onQueryChange(e.target.value)} placeholder={translate( @@ -136,68 +143,102 @@ export function ThemePicker({ {group.label}

{group.themes.map((theme) => ( - + {theme.group === 'imported' && onDeleteImportedTheme ? ( + + + + + + + {translate( + 'auto.components.settings.SettingsFormControls.delete_theme', + 'Delete theme' + )} + + ) : null} - {selectedTheme === theme.value ? ( - - {translate( - 'auto.components.settings.SettingsFormControls.9119fb2268', - 'Current' - )} - - ) : null} - + ))} ) diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 8309fafdc39..b4ab272b5c1 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -1,7 +1,14 @@ import { usePluginTerminalThemes } from '@/store/plugin-themes' import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes' import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { parseCustomTerminalThemeSelection } from '../../../../shared/terminal-custom-themes' +import { + terminalCustomThemeRemoval, + type TerminalCustomThemeRemoval +} from '../../../../shared/terminal-theme-selection' import { hasTerminalColorOverrides, TERMINAL_COLOR_OVERRIDES_SETTING, @@ -23,7 +30,8 @@ import { DEFAULT_TERMINAL_THEME_DARK, DEFAULT_TERMINAL_THEME_LIGHT, getAvailableTerminalThemeOptions, - resolveEffectiveTerminalAppearance + resolveEffectiveTerminalAppearance, + type TerminalThemeOption } from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -38,6 +46,17 @@ export function resetTerminalThemeTargetMemoryForTests(): void { lastEditedTerminalThemeTarget = null } +// Why a queue: the store applies a write only once it is acknowledged, so each delete or undo must +// read the list the previous one saved or a quick second delete would revive the first. +let customThemeWrites: Promise = Promise.resolve() + +function queueCustomThemeWrite(build: (settings: GlobalSettings) => Partial): void { + customThemeWrites = customThemeWrites.then(() => { + const { settings, updateSettings } = useAppStore.getState() + return settings ? updateSettings(build(settings)) : undefined + }) +} + function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { const trimmed = themeName.trim() return trimmed.length > 0 && trimmed !== defaultThemeName @@ -109,6 +128,31 @@ export function TerminalThemeCatalogSection({ // Light mode that matches dark paints the dark slot, so its overrides are the ones the preview shows. const advancedContent = renderAdvancedContent?.(lightModeMatchesDark ? 'dark' : target) const selectedTheme = isLightTarget ? settings.terminalThemeLight : settings.terminalThemeDark + const deleteImportedTheme = (theme: TerminalThemeOption): void => { + const themeId = parseCustomTerminalThemeSelection(theme.value) + if (!themeId) { + return + } + let removal: TerminalCustomThemeRemoval | null = null + queueCustomThemeWrite((current) => { + removal = terminalCustomThemeRemoval(current, themeId) + return removal.updates + }) + toast( + translate( + 'auto.components.settings.TerminalThemeSections.deleted_imported_theme', + 'Deleted "{{value0}}"', + { value0: theme.label } + ), + { + action: { + label: translate('auto.components.settings.TerminalThemeSections.undo', 'Undo'), + onClick: () => queueCustomThemeWrite((current) => removal?.undo(current) ?? {}) + } + } + ) + } + const pickerTitle = isLightTarget ? translate('auto.components.settings.TerminalThemeSections.8273bc75d7', 'Light Theme') : translate('auto.components.settings.TerminalThemeSections.9499ad1dc4', 'Dark Theme') @@ -273,6 +317,7 @@ export function TerminalThemeCatalogSection({ ) ) }} + onDeleteImportedTheme={deleteImportedTheme} importedHighlightSignal={importedHighlightSignal} /> diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3c59a806ece..98bd27e9397 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9071,7 +9071,9 @@ "cb330ef7f8": " of {{value0}}", "c822571b2e": " matching \"{{value0}}\"", "plugin_themes": "Plugins", - "from_plugin": "From {{value0}}" + "from_plugin": "From {{value0}}", + "delete_imported_theme": "Delete {{value0}}", + "delete_theme": "Delete theme" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -9467,7 +9469,9 @@ "match_dark_mode": "Match dark mode", "match_dark_mode_description": "Share the dark terminal theme and divider color in light mode.", "light_preview_description": "Shows the effective light terminal appearance.", - "dark_preview_description": "Shows the effective dark terminal appearance." + "dark_preview_description": "Shows the effective dark terminal appearance.", + "deleted_imported_theme": "Deleted \"{{value0}}\"", + "undo": "Undo" }, "TerminalWindowSection": { "1705318506": "ANSI magenta color", diff --git a/src/shared/terminal-theme-selection.test.ts b/src/shared/terminal-theme-selection.test.ts index bf4add78fbc..6b14e40c56c 100644 --- a/src/shared/terminal-theme-selection.test.ts +++ b/src/shared/terminal-theme-selection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveConfiguredTerminalColors, resolveTerminalPalette, + terminalCustomThemeRemoval, withImportedTerminalColorOverrides } from './terminal-theme-selection' import { TERMINAL_THEME_CATALOG } from './terminal-themes' @@ -135,3 +136,66 @@ describe('plugin terminal palettes', () => { ).toEqual({ foreground: '#e8e0ff', background: '#1b1430' }) }) }) + +describe('deleting an imported terminal theme (#32138)', () => { + const imported = { + id: 'warp:dusk', + name: 'Dusk', + source: 'warp' as const, + mode: 'dark' as const, + terminal: { foreground: '#eeeeee', background: '#101020', red: '#ff5555' }, + importedAt: '2026-01-01T00:00:00.000Z' + } + const kept = { ...imported, id: 'warp:dawn', name: 'Dawn' } + const settings = { + ...base, + terminalThemeDark: 'custom:warp:dusk', + terminalThemeLight: 'custom:warp:dusk', + terminalCustomThemes: [imported, kept], + terminalColorOverrides: { background: '#000000' }, + terminalColorOverridesLight: { background: '#ffffff' } + } + + it('moves every slot showing it back to that slot default, and undo puts it all back', () => { + const { updates, undo } = terminalCustomThemeRemoval(settings, 'warp:dusk') + expect(updates).toEqual({ + terminalCustomThemes: [kept], + terminalThemeDark: 'Ghostty Default Style Dark', + terminalColorOverrides: undefined, + terminalThemeLight: 'Builtin Tango Light', + terminalColorOverridesLight: undefined + }) + const afterDelete = { ...settings, ...updates, terminalCustomThemes: [kept] } + expect(undo(afterDelete)).toEqual({ + terminalCustomThemes: [kept, imported], + terminalThemeDark: 'custom:warp:dusk', + terminalColorOverrides: { background: '#000000' }, + terminalThemeLight: 'custom:warp:dusk', + terminalColorOverridesLight: { background: '#ffffff' } + }) + }) + + it('undo keeps what the user changed after the delete', () => { + const { updates, undo } = terminalCustomThemeRemoval(settings, 'warp:dusk') + const afterDelete = { ...settings, ...updates, terminalCustomThemes: [kept] } + const restored = undo({ + ...afterDelete, + terminalThemeDark: 'Tokyo Night', + terminalColorOverridesLight: { cursor: '#ff00ff' } + }) + expect(restored).toEqual({ terminalCustomThemes: [kept, imported] }) + const reimported = { ...imported, name: 'Dusk v2' } + expect(undo({ ...afterDelete, terminalCustomThemes: [kept, reimported] })).not.toHaveProperty( + 'terminalCustomThemes' + ) + }) + + it('leaves slots showing another theme untouched', () => { + const { updates } = terminalCustomThemeRemoval( + { ...settings, terminalThemeLight: 'custom:warp:dawn' }, + 'warp:dusk' + ) + expect(updates).not.toHaveProperty('terminalThemeLight') + expect(updates).not.toHaveProperty('terminalColorOverridesLight') + }) +}) diff --git a/src/shared/terminal-theme-selection.ts b/src/shared/terminal-theme-selection.ts index 4984f2403a3..9a41f336016 100644 --- a/src/shared/terminal-theme-selection.ts +++ b/src/shared/terminal-theme-selection.ts @@ -2,13 +2,16 @@ import type { ITheme } from '@xterm/xterm' import type { GlobalSettings } from './global-settings-types' import { isPluginContentSwitchedOff } from './plugins/plugin-system-settings' import { + hasTerminalColorOverrides, TERMINAL_COLOR_OVERRIDES_SETTING, terminalColorOverridesForSlot, withTerminalColorOverrides, - type TerminalColorOverrideSettings + type TerminalColorOverrideSettings, + type TerminalThemeSlot } from './terminal-color-overrides' import { hasUsableTerminalThemeColors, + makeCustomTerminalThemeSelection, normalizeTerminalColorOverrides, normalizeTerminalCustomThemes, parseCustomTerminalThemeSelection, @@ -118,6 +121,68 @@ export function resolveTerminalPalette( } } +type TerminalCustomThemeRemovalSettings = Pick< + GlobalSettings, + 'terminalCustomThemes' | 'terminalThemeDark' | 'terminalThemeLight' +> & + TerminalColorOverrideSettings + +export type TerminalCustomThemeRemoval = { + updates: Partial + /** Puts the theme back unless re-imported since, and back into slots nobody changed since. */ + undo: (current: TerminalCustomThemeRemovalSettings) => Partial +} + +const SLOT_THEME_SETTING = { + dark: 'terminalThemeDark', + light: 'terminalThemeLight' +} as const satisfies Record +const SLOT_DEFAULT_THEME: Record = { + dark: DEFAULT_TERMINAL_THEME_DARK, + light: DEFAULT_TERMINAL_THEME_LIGHT +} + +/** Deleting an imported theme moves each slot showing it to that slot's default, dropping the slot's overrides like any theme change. */ +export function terminalCustomThemeRemoval( + settings: TerminalCustomThemeRemovalSettings, + themeId: string +): TerminalCustomThemeRemoval { + const themes = normalizeTerminalCustomThemes(settings.terminalCustomThemes) + const removed = themes.find(({ id }) => id === themeId) + const selection = makeCustomTerminalThemeSelection(themeId) + const slots = (['dark', 'light'] as const).filter( + (slot) => settings[SLOT_THEME_SETTING[slot]] === selection + ) + const updates: Partial = { + terminalCustomThemes: themes.filter(({ id }) => id !== themeId) + } + for (const slot of slots) { + updates[SLOT_THEME_SETTING[slot]] = SLOT_DEFAULT_THEME[slot] + updates[TERMINAL_COLOR_OVERRIDES_SETTING[slot]] = undefined + } + return { + updates, + undo: (current) => { + const restored: Partial = {} + const currentThemes = normalizeTerminalCustomThemes(current.terminalCustomThemes) + if (removed && !currentThemes.some(({ id }) => id === themeId)) { + restored.terminalCustomThemes = normalizeTerminalCustomThemes([...currentThemes, removed]) + } + for (const slot of slots) { + if ( + current[SLOT_THEME_SETTING[slot]] === SLOT_DEFAULT_THEME[slot] && + !hasTerminalColorOverrides(current, slot) + ) { + restored[SLOT_THEME_SETTING[slot]] = selection + restored[TERMINAL_COLOR_OVERRIDES_SETTING[slot]] = + settings[TERMINAL_COLOR_OVERRIDES_SETTING[slot]] + } + } + return restored + } + } +} + /** An imported palette (Ghostty) merges into the slot painting the terminal once the import applies, so it shows. */ export function withImportedTerminalColorOverrides( settings: TerminalThemeSelectionSettings & TerminalColorOverrideSettings,