refactor(renderer): split SettingsFormControls below max-lines limit (#13747)

This commit is contained in:
Neil
2026-08-10 23:37:40 -07:00
committed by GitHub
parent c1e75477f3
commit fdaafc93dd
4 changed files with 545 additions and 533 deletions
-1
View File
@@ -232,7 +232,6 @@ inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/settings/SettingsFormControls.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
@@ -0,0 +1,333 @@
import type React from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
import { Input } from '../ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'
import { ScrollArea } from '../ui/scroll-area'
import { filterFontSuggestions, getRenderedFontSuggestions } from './settings-form-option-filter'
import { translate } from '@/i18n/i18n'
type FontAutocompleteProps = {
value: string
suggestions: string[]
onChange: (value: string) => void
placeholder?: string
onRequestSuggestions?: () => void
/** Fires with whichever option the user is currently highlighting in the
* dropdown (via mouse hover or keyboard arrow), or null when nothing is
* highlighted / the dropdown is closed. Lets a consumer show a live
* preview of the font without committing the selection. */
onPreviewFontFamily?: (font: string | null) => void
}
export function FontAutocomplete({
value,
suggestions,
onChange,
placeholder = 'SF Mono',
onRequestSuggestions,
onPreviewFontFamily
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
const [prevValue, setPrevValue] = useState(value)
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [isFilteringQuery, setIsFilteringQuery] = useState(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
const previewFontFamilyRef = useRef(onPreviewFontFamily)
const listboxId = useId()
useEffect(() => {
previewFontFamilyRef.current = onPreviewFontFamily
}, [onPreviewFontFamily])
const setRootNode = useCallback((element: HTMLDivElement | null): void => {
rootRef.current = element
if (!element) {
// Why: settings search can unmount this control while a hover preview is
// active; the consumer must not keep rendering that transient font.
previewFontFamilyRef.current?.(null)
}
}, [])
if (value !== prevValue) {
setPrevValue(value)
setQuery(value)
if (value !== query) {
setIsFilteringQuery(false)
}
}
const requestSuggestions = useCallback((): void => {
onRequestSuggestions?.()
}, [onRequestSuggestions])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
requestSuggestions()
}
if (!nextOpen) {
setIsFilteringQuery(false)
}
}
const normalizedQuery = query.trim().toLowerCase()
const normalizedValue = value.trim().toLowerCase()
const filteredSuggestions = useMemo(
() => filterFontSuggestions(suggestions, query),
[suggestions, query]
)
// Why: the committed font fills the input, but opening the chooser should
// still reveal every installed font instead of only fonts sharing that name.
const visibleSuggestions =
!isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions
const renderedSuggestions = useMemo(
() => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex),
[visibleSuggestions, highlightedIndex]
)
// Why: sync the highlighted index during render rather than via useEffect so
// the correct item is highlighted on the very first paint after open/filter
// changes — useEffect would leave one render with the stale index visible.
const [prevVisibleSuggestions, setPrevVisibleSuggestions] = useState(visibleSuggestions)
const [prevOpen, setPrevOpen] = useState(open)
const [prevHighlightedValue, setPrevHighlightedValue] = useState(value)
if (
visibleSuggestions !== prevVisibleSuggestions ||
open !== prevOpen ||
value !== prevHighlightedValue
) {
setPrevVisibleSuggestions(visibleSuggestions)
setPrevOpen(open)
setPrevHighlightedValue(value)
if (!open || visibleSuggestions.length === 0) {
setHighlightedIndex(-1)
} else {
const selectedIndex = visibleSuggestions.indexOf(value)
setHighlightedIndex(Math.max(selectedIndex, 0))
}
}
// Why: notify the consumer of the currently-highlighted font so it can
// render a live preview. Closing the dropdown or moving past all options
// clears the preview back to the committed value.
useEffect(() => {
if (!onPreviewFontFamily) {
return
}
if (!open || highlightedIndex < 0) {
onPreviewFontFamily(null)
return
}
onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null)
}, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open])
const commitValue = (nextValue: string): void => {
setQuery(nextValue)
setIsFilteringQuery(false)
onChange(nextValue)
setOpen(false)
}
const focusInput = (): void => {
inputRef.current?.focus()
}
const popoverAvailableHeightStyle = {
// Why: tailwind-merge rewrites this arbitrary max-height class on the
// ScrollArea root, so keep the Radix available-height clamp as inline style.
maxHeight: 'var(--radix-popover-content-available-height)'
} as React.CSSProperties
return (
<div ref={setRootNode} className="relative max-w-sm">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div className="relative">
<Input
ref={inputRef}
value={query}
onChange={(e) => {
const next = e.target.value
requestSuggestions()
setQuery(next)
setIsFilteringQuery(true)
onChange(next)
setOpen(true)
}}
onFocus={() => {
requestSuggestions()
setIsFilteringQuery(false)
setOpen(true)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (open) {
e.preventDefault()
setOpen(false)
setIsFilteringQuery(false)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1)
)
}
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setOpen(true)
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0)
)
}
return
}
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
const highlightedFont = visibleSuggestions[highlightedIndex]
if (highlightedFont) {
e.preventDefault()
commitValue(highlightedFont)
}
}
}}
placeholder={placeholder}
className="pr-18"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && highlightedIndex >= 0
? `${listboxId}-option-${highlightedIndex}`
: undefined
}
/>
<div className="absolute inset-y-0 right-2 flex items-center gap-1">
{query ? (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuery('')
setIsFilteringQuery(false)
onChange('')
setOpen(true)
focusInput()
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate(
'auto.components.settings.SettingsFormControls.a4ff6143f8',
'Clear font selection'
)}
title={translate(
'auto.components.settings.SettingsFormControls.74bcecd5ec',
'Clear'
)}
>
<CircleX className="size-3.5" />
</button>
) : null}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const nextOpen = !open
setOpen(nextOpen)
if (!nextOpen) {
setIsFilteringQuery(false)
}
if (nextOpen) {
requestSuggestions()
focusInput()
}
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate(
'auto.components.settings.SettingsFormControls.c766f8ac75',
'Toggle font suggestions'
)}
title={translate(
'auto.components.settings.SettingsFormControls.b55371ea18',
'Fonts'
)}
>
<ChevronsUpDown className="size-3.5" />
</button>
</div>
</div>
</PopoverAnchor>
{/* Why: portal the dropdown outside the settings section — an in-flow
absolute panel makes the highlighted option's scrollIntoView scroll
the whole settings pane, pushing the section content out of view. */}
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)]"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
// Why: the input and its clear/toggle buttons are the anchor, not
// the content, so Radix would otherwise dismiss on every click there.
if (rootRef.current?.contains(e.target as Node)) {
e.preventDefault()
}
}}
>
<ScrollArea
className={renderedSuggestions.length > 8 ? 'h-64' : undefined}
style={popoverAvailableHeightStyle}
viewportProps={{ style: popoverAvailableHeightStyle }}
>
<div id={listboxId} role="listbox" className="p-1">
{visibleSuggestions.length > 0 ? (
renderedSuggestions.map(({ font, sourceIndex }) => (
<button
key={font}
type="button"
id={`${listboxId}-option-${sourceIndex}`}
role="option"
aria-selected={sourceIndex === highlightedIndex}
ref={(element) => {
if (element && sourceIndex === highlightedIndex) {
element.scrollIntoView({ block: 'nearest' })
}
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedIndex(sourceIndex)}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
sourceIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60'
}`}
>
<span className="truncate">{font}</span>
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
</button>
))
) : (
<div className="px-3 py-3 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.42a4d15a30',
'No matching fonts.'
)}
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
)
}
@@ -1,26 +1,16 @@
/* eslint-disable max-lines -- Why: these small settings form primitives and controls
co-locate shared layout and keyboard interaction logic, which keeps the settings
panel wiring simple even though the file exceeds the default line limit. */
import type React from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { ScrollArea } from '../ui/scroll-area'
import { useState } from 'react'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { Switch } from '../ui/switch'
import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme'
import { MAX_THEME_RESULTS } from './SettingsConstants'
import {
filterFontSuggestions,
filterTerminalThemeOptions,
getRenderedFontSuggestions,
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
import { normalizeColor } from '@/lib/terminal-theme'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
export { FontAutocomplete } from './FontAutocomplete'
export { ThemePicker } from './TerminalThemePicker'
type SettingsSwitchProps = {
checked: boolean
onChange: () => void
@@ -261,19 +251,6 @@ export function SettingsSubsectionHeader({
)
}
type ThemePickerProps = {
label: string
description: string
selectedTheme: string
themeOptions: TerminalThemeOption[]
query: string
onQueryChange: (value: string) => void
onSelectTheme: (theme: string) => void
/** Bumps when themes are imported; scrolls the Imported group into view and
* briefly highlights it so freshly-imported themes are easy to find. */
importedHighlightSignal?: number
}
type ColorFieldProps = {
label: string
description: string
@@ -294,200 +271,6 @@ type NumberFieldProps = {
suffix?: string
}
type FontAutocompleteProps = {
value: string
suggestions: string[]
onChange: (value: string) => void
placeholder?: string
onRequestSuggestions?: () => void
/** Fires with whichever option the user is currently highlighting in the
* dropdown (via mouse hover or keyboard arrow), or null when nothing is
* highlighted / the dropdown is closed. Lets a consumer show a live
* preview of the font without committing the selection. */
onPreviewFontFamily?: (font: string | null) => void
}
export function ThemePicker({
label,
description,
selectedTheme,
themeOptions,
query,
onQueryChange,
onSelectTheme,
importedHighlightSignal
}: ThemePickerProps): React.JSX.Element {
const importedGroupRef = useRef<HTMLDivElement | null>(null)
const [highlightImported, setHighlightImported] = useState(false)
// Why: imported themes render below the built-in list inside a fixed-height
// scroll area, so after an import they sit off-screen. On each import signal,
// scroll the Imported group into view and flash a highlight so it's easy to spot.
useEffect(() => {
if (!importedHighlightSignal) {
return
}
importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
setHighlightImported(true)
const timer = setTimeout(() => setHighlightImported(false), 2000)
return () => clearTimeout(timer)
}, [importedHighlightSignal])
const themeQuery = query.trim()
const shouldShowThemeQueryLabel =
themeQuery.length > 0 && !isSettingsFormOptionQueryTooLarge(themeQuery)
const matchingThemes = filterTerminalThemeOptions(themeOptions, query)
const selectedThemeLabel =
themeOptions.find((option) => option.value === selectedTheme)?.label ?? selectedTheme
const groupedThemes = [
{
label: translate('auto.components.settings.SettingsFormControls.builtin_themes', 'Built-in'),
themes: matchingThemes
.filter((theme) => theme.group === 'built-in')
.slice(0, MAX_THEME_RESULTS)
},
{
label: translate('auto.components.settings.SettingsFormControls.imported_themes', 'Imported'),
themes: matchingThemes
.filter((theme) => theme.group === 'imported')
.slice(0, MAX_THEME_RESULTS)
}
].filter((group) => group.themes.length > 0)
const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0)
return (
<div className="space-y-3">
<div className="space-y-1">
<Label>{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={translate(
'auto.components.settings.SettingsFormControls.search_terminal_themes',
'Search terminal themes'
)}
/>
<div className="rounded-lg border border-border/50">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
<span>
{translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '}
{selectedThemeLabel}
</span>
<span>
{translate('auto.components.settings.SettingsFormControls.4e11f87ca6', 'Showing')}{' '}
{visibleThemeCount}
{shouldShowThemeQueryLabel
? translate(
'auto.components.settings.SettingsFormControls.c822571b2e',
' matching "{{value0}}"',
{ value0: themeQuery }
)
: translate(
'auto.components.settings.SettingsFormControls.cb330ef7f8',
' of {{value0}}',
{ value0: themeOptions.length }
)}
</span>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 p-2">
{groupedThemes.map((group) => {
const isImported =
group.label ===
translate(
'auto.components.settings.SettingsFormControls.imported_themes',
'Imported'
)
return (
<div
key={group.label}
ref={isImported ? importedGroupRef : undefined}
className={cn(
'space-y-1 rounded-md transition-colors duration-500',
isImported && highlightImported && 'bg-accent/40 ring-1 ring-accent'
)}
>
<p className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
</p>
{group.themes.map((theme) => (
<button
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',
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">
{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 === 'imported' &&
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}
{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>
)
})}
{visibleThemeCount === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.ceefb9d7f1',
'No themes found.'
)}
</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
)
}
export function ColorField({
label,
description,
@@ -596,313 +379,3 @@ export function NumberField({
/>
)
}
export function FontAutocomplete({
value,
suggestions,
onChange,
placeholder = 'SF Mono',
onRequestSuggestions,
onPreviewFontFamily
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
const [prevValue, setPrevValue] = useState(value)
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [isFilteringQuery, setIsFilteringQuery] = useState(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
const previewFontFamilyRef = useRef(onPreviewFontFamily)
const listboxId = useId()
previewFontFamilyRef.current = onPreviewFontFamily
const setRootNode = useCallback((element: HTMLDivElement | null): void => {
rootRef.current = element
if (!element) {
// Why: settings search can unmount this control while a hover preview is
// active; the consumer must not keep rendering that transient font.
previewFontFamilyRef.current?.(null)
}
}, [])
if (value !== prevValue) {
setPrevValue(value)
setQuery(value)
if (value !== query) {
setIsFilteringQuery(false)
}
}
const requestSuggestions = useCallback((): void => {
onRequestSuggestions?.()
}, [onRequestSuggestions])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
requestSuggestions()
}
if (!nextOpen) {
setIsFilteringQuery(false)
}
}
const normalizedQuery = query.trim().toLowerCase()
const normalizedValue = value.trim().toLowerCase()
const filteredSuggestions = useMemo(
() => filterFontSuggestions(suggestions, query),
[suggestions, query]
)
// Why: the committed font fills the input, but opening the chooser should
// still reveal every installed font instead of only fonts sharing that name.
const visibleSuggestions =
!isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions
const renderedSuggestions = useMemo(
() => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex),
[visibleSuggestions, highlightedIndex]
)
// Why: sync the highlighted index during render rather than via useEffect so
// the correct item is highlighted on the very first paint after open/filter
// changes — useEffect would leave one render with the stale index visible.
const [prevVisibleSuggestions, setPrevVisibleSuggestions] = useState(visibleSuggestions)
const [prevOpen, setPrevOpen] = useState(open)
const [prevHighlightedValue, setPrevHighlightedValue] = useState(value)
if (
visibleSuggestions !== prevVisibleSuggestions ||
open !== prevOpen ||
value !== prevHighlightedValue
) {
setPrevVisibleSuggestions(visibleSuggestions)
setPrevOpen(open)
setPrevHighlightedValue(value)
if (!open || visibleSuggestions.length === 0) {
setHighlightedIndex(-1)
} else {
const selectedIndex = visibleSuggestions.indexOf(value)
setHighlightedIndex(Math.max(selectedIndex, 0))
}
}
// Why: notify the consumer of the currently-highlighted font so it can
// render a live preview. Closing the dropdown or moving past all options
// clears the preview back to the committed value.
useEffect(() => {
if (!onPreviewFontFamily) {
return
}
if (!open || highlightedIndex < 0) {
onPreviewFontFamily(null)
return
}
onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null)
}, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open])
const commitValue = (nextValue: string): void => {
setQuery(nextValue)
setIsFilteringQuery(false)
onChange(nextValue)
setOpen(false)
}
const focusInput = (): void => {
inputRef.current?.focus()
}
const popoverAvailableHeightStyle = {
// Why: tailwind-merge rewrites this arbitrary max-height class on the
// ScrollArea root, so keep the Radix available-height clamp as inline style.
maxHeight: 'var(--radix-popover-content-available-height)'
} as React.CSSProperties
return (
<div ref={setRootNode} className="relative max-w-sm">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div className="relative">
<Input
ref={inputRef}
value={query}
onChange={(e) => {
const next = e.target.value
requestSuggestions()
setQuery(next)
setIsFilteringQuery(true)
onChange(next)
setOpen(true)
}}
onFocus={() => {
requestSuggestions()
setIsFilteringQuery(false)
setOpen(true)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (open) {
e.preventDefault()
setOpen(false)
setIsFilteringQuery(false)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1)
)
}
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setOpen(true)
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0)
)
}
return
}
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
const highlightedFont = visibleSuggestions[highlightedIndex]
if (highlightedFont) {
e.preventDefault()
commitValue(highlightedFont)
}
}
}}
placeholder={placeholder}
className="pr-18"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && highlightedIndex >= 0
? `${listboxId}-option-${highlightedIndex}`
: undefined
}
/>
<div className="absolute inset-y-0 right-2 flex items-center gap-1">
{query ? (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuery('')
setIsFilteringQuery(false)
onChange('')
setOpen(true)
focusInput()
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate(
'auto.components.settings.SettingsFormControls.a4ff6143f8',
'Clear font selection'
)}
title={translate(
'auto.components.settings.SettingsFormControls.74bcecd5ec',
'Clear'
)}
>
<CircleX className="size-3.5" />
</button>
) : null}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const nextOpen = !open
setOpen(nextOpen)
if (!nextOpen) {
setIsFilteringQuery(false)
}
if (nextOpen) {
requestSuggestions()
focusInput()
}
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate(
'auto.components.settings.SettingsFormControls.c766f8ac75',
'Toggle font suggestions'
)}
title={translate(
'auto.components.settings.SettingsFormControls.b55371ea18',
'Fonts'
)}
>
<ChevronsUpDown className="size-3.5" />
</button>
</div>
</div>
</PopoverAnchor>
{/* Why: portal the dropdown outside the settings section — an in-flow
absolute panel makes the highlighted option's scrollIntoView scroll
the whole settings pane, pushing the section content out of view. */}
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)]"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
// Why: the input and its clear/toggle buttons are the anchor, not
// the content, so Radix would otherwise dismiss on every click there.
if (rootRef.current?.contains(e.target as Node)) {
e.preventDefault()
}
}}
>
<ScrollArea
className={renderedSuggestions.length > 8 ? 'h-64' : undefined}
style={popoverAvailableHeightStyle}
viewportProps={{ style: popoverAvailableHeightStyle }}
>
<div id={listboxId} role="listbox" className="p-1">
{visibleSuggestions.length > 0 ? (
renderedSuggestions.map(({ font, sourceIndex }) => (
<button
key={font}
type="button"
id={`${listboxId}-option-${sourceIndex}`}
role="option"
aria-selected={sourceIndex === highlightedIndex}
ref={(element) => {
if (element && sourceIndex === highlightedIndex) {
element.scrollIntoView({ block: 'nearest' })
}
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedIndex(sourceIndex)}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
sourceIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60'
}`}
>
<span className="truncate">{font}</span>
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
</button>
))
) : (
<div className="px-3 py-3 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.42a4d15a30',
'No matching fonts.'
)}
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,207 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { ScrollArea } from '../ui/scroll-area'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { MAX_THEME_RESULTS } from './SettingsConstants'
import {
filterTerminalThemeOptions,
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { TerminalThemeOption } from '@/lib/terminal-theme'
type ThemePickerProps = {
label: string
description: string
selectedTheme: string
themeOptions: TerminalThemeOption[]
query: string
onQueryChange: (value: string) => void
onSelectTheme: (theme: string) => void
/** Bumps when themes are imported; scrolls the Imported group into view and
* briefly highlights it so freshly-imported themes are easy to find. */
importedHighlightSignal?: number
}
export function ThemePicker({
label,
description,
selectedTheme,
themeOptions,
query,
onQueryChange,
onSelectTheme,
importedHighlightSignal
}: ThemePickerProps): React.JSX.Element {
const importedGroupRef = useRef<HTMLDivElement | null>(null)
const [highlightImported, setHighlightImported] = useState(false)
// Why: imported themes render below the built-in list inside a fixed-height
// scroll area, so after an import they sit off-screen. On each import signal,
// scroll the Imported group into view and flash a highlight so it's easy to spot.
useEffect(() => {
if (!importedHighlightSignal) {
return
}
importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
setHighlightImported(true)
const timer = setTimeout(() => setHighlightImported(false), 2000)
return () => clearTimeout(timer)
}, [importedHighlightSignal])
const themeQuery = query.trim()
const shouldShowThemeQueryLabel =
themeQuery.length > 0 && !isSettingsFormOptionQueryTooLarge(themeQuery)
const matchingThemes = filterTerminalThemeOptions(themeOptions, query)
const selectedThemeLabel =
themeOptions.find((option) => option.value === selectedTheme)?.label ?? selectedTheme
const groupedThemes = [
{
label: translate('auto.components.settings.SettingsFormControls.builtin_themes', 'Built-in'),
themes: matchingThemes
.filter((theme) => theme.group === 'built-in')
.slice(0, MAX_THEME_RESULTS)
},
{
label: translate('auto.components.settings.SettingsFormControls.imported_themes', 'Imported'),
themes: matchingThemes
.filter((theme) => theme.group === 'imported')
.slice(0, MAX_THEME_RESULTS)
}
].filter((group) => group.themes.length > 0)
const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0)
return (
<div className="space-y-3">
<div className="space-y-1">
<Label>{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={translate(
'auto.components.settings.SettingsFormControls.search_terminal_themes',
'Search terminal themes'
)}
/>
<div className="rounded-lg border border-border/50">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
<span>
{translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '}
{selectedThemeLabel}
</span>
<span>
{translate('auto.components.settings.SettingsFormControls.4e11f87ca6', 'Showing')}{' '}
{visibleThemeCount}
{shouldShowThemeQueryLabel
? translate(
'auto.components.settings.SettingsFormControls.c822571b2e',
' matching "{{value0}}"',
{ value0: themeQuery }
)
: translate(
'auto.components.settings.SettingsFormControls.cb330ef7f8',
' of {{value0}}',
{ value0: themeOptions.length }
)}
</span>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 p-2">
{groupedThemes.map((group) => {
const isImported =
group.label ===
translate(
'auto.components.settings.SettingsFormControls.imported_themes',
'Imported'
)
return (
<div
key={group.label}
ref={isImported ? importedGroupRef : undefined}
className={cn(
'space-y-1 rounded-md transition-colors duration-500',
isImported && highlightImported && 'bg-accent/40 ring-1 ring-accent'
)}
>
<p className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
</p>
{group.themes.map((theme) => (
<button
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',
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">
{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 === 'imported' &&
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}
{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>
)
})}
{visibleThemeCount === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.ceefb9d7f1',
'No themes found.'
)}
</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
)
}