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
This commit is contained in:
Neil
2026-11-02 00:31:45 -08:00
committed by GitHub
parent c7195a8d50
commit adb2d2d2cd
5 changed files with 274 additions and 55 deletions
@@ -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<HTMLDivElement | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(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({
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Input
ref={searchInputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={translate(
@@ -136,68 +143,102 @@ export function ThemePicker({
{group.label}
</p>
{group.themes.map((theme) => (
<button
<div
key={theme.value}
onClick={() => onSelectTheme(theme.value)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
'group/theme-row flex items-center rounded-md transition-colors',
selectedTheme === theme.value
? 'bg-accent font-medium text-accent-foreground'
: 'hover:bg-accent'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{theme.label}</span>
{theme.sourceLabel ? (
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{theme.group === 'plugin'
? translate(
'auto.components.settings.SettingsFormControls.from_plugin',
'From {{value0}}',
{ value0: theme.sourceLabel }
)
: translate(
'auto.components.settings.SettingsFormControls.imported_from',
'Imported from {{value0}}',
{ value0: theme.sourceLabel }
)}
{theme.mode && theme.mode !== 'unknown' ? ` · ${theme.mode}` : ''}
<button
onClick={() => onSelectTheme(theme.value)}
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm"
>
<span className="min-w-0 flex-1">
<span className="block truncate">{theme.label}</span>
{theme.sourceLabel ? (
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{theme.group === 'plugin'
? translate(
'auto.components.settings.SettingsFormControls.from_plugin',
'From {{value0}}',
{ value0: theme.sourceLabel }
)
: translate(
'auto.components.settings.SettingsFormControls.imported_from',
'Imported from {{value0}}',
{ value0: theme.sourceLabel }
)}
{theme.mode && theme.mode !== 'unknown' ? ` · ${theme.mode}` : ''}
</span>
) : null}
</span>
{/* Why: hide swatches on the current row so the color grid
doesn't shift left to make room for the "Current" label. */}
{theme.group !== 'built-in' &&
theme.previewTheme &&
selectedTheme !== theme.value ? (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
{[
theme.previewTheme.black,
theme.previewTheme.red,
theme.previewTheme.green,
theme.previewTheme.yellow,
theme.previewTheme.blue,
theme.previewTheme.magenta,
theme.previewTheme.cyan,
theme.previewTheme.white
].map((color, index) => (
<span
key={index}
className="h-3 w-2"
style={{ backgroundColor: color ?? 'transparent' }}
/>
))}
</span>
) : null}
</span>
{/* Why: hide swatches on the current row so the color grid
doesn't shift left to make room for the "Current" label. */}
{theme.group !== 'built-in' &&
theme.previewTheme &&
selectedTheme !== theme.value ? (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
{[
theme.previewTheme.black,
theme.previewTheme.red,
theme.previewTheme.green,
theme.previewTheme.yellow,
theme.previewTheme.blue,
theme.previewTheme.magenta,
theme.previewTheme.cyan,
theme.previewTheme.white
].map((color, index) => (
<span
key={index}
className="h-3 w-2"
style={{ backgroundColor: color ?? 'transparent' }}
/>
))}
{selectedTheme === theme.value ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
{translate(
'auto.components.settings.SettingsFormControls.9119fb2268',
'Current'
)}
</span>
) : null}
</button>
{theme.group === 'imported' && onDeleteImportedTheme ? (
<span className="mr-1.5 flex shrink-0 text-muted-foreground opacity-0 group-focus-within/theme-row:opacity-100 group-hover/theme-row:opacity-100 [@media(hover:none)]:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
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 }
)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.SettingsFormControls.delete_theme',
'Delete theme'
)}
</TooltipContent>
</Tooltip>
</span>
) : null}
{selectedTheme === theme.value ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
{translate(
'auto.components.settings.SettingsFormControls.9119fb2268',
'Current'
)}
</span>
) : null}
</button>
</div>
))}
</div>
)
@@ -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<void> = Promise.resolve()
function queueCustomThemeWrite(build: (settings: GlobalSettings) => Partial<GlobalSettings>): 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}
/>
</SearchableSetting>
+6 -2
View File
@@ -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",
@@ -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')
})
})
+66 -1
View File
@@ -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<GlobalSettings>
/** Puts the theme back unless re-imported since, and back into slots nobody changed since. */
undo: (current: TerminalCustomThemeRemovalSettings) => Partial<GlobalSettings>
}
const SLOT_THEME_SETTING = {
dark: 'terminalThemeDark',
light: 'terminalThemeLight'
} as const satisfies Record<TerminalThemeSlot, keyof GlobalSettings>
const SLOT_DEFAULT_THEME: Record<TerminalThemeSlot, string> = {
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<GlobalSettings> = {
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<GlobalSettings> = {}
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,