mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user