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 ? (
+
+
+
+ {
+ onDeleteImportedTheme(theme)
+ // The row unmounts with focus inside it; keep keyboard users in the picker.
+ searchInputRef.current?.focus()
+ }}
+ aria-label={translate(
+ 'auto.components.settings.SettingsFormControls.delete_imported_theme',
+ 'Delete {{value0}}',
+ { value0: theme.label }
+ )}
+ >
+
+
+
+
+ {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,