Show Terminal and Window settings by default in Appearance pane (#10628)

Terminal and Window & Sidebar sections now expand alongside Interface by
default so users don't miss advanced settings. Sections remain independently
collapsible and each can be force-open on deep-link navigation without
collapsing siblings. Search disables toggles to prevent unexpected collapse
when query clears. Remove unused "ghostty" translation key; product name
stays untranslated for search consistency.
This commit is contained in:
Jinjing 2026-07-25 17:42:34 -07:00 committed by GitHub
parent 468f5b77b5
commit 912c2495fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 176 additions and 34 deletions

View File

@ -28,7 +28,7 @@ export function GhosttyDiscoveryRow({
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
{translate(
'auto.components.onboarding.ThemeStep.2c3aa538f8',
'Looking for a Ghostty config...'
'Looking for a Ghostty config'
)}
</div>
)

View File

@ -167,6 +167,42 @@ async function renderAppearancePane(
return container
}
async function rerenderAppearancePane(
settings: GlobalSettings = getDefaultSettings('/tmp')
): Promise<void> {
const root = mountedRoots.at(-1)
if (!root) {
throw new Error('expected a mounted AppearancePane root')
}
await act(async () => {
root.render(
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<AppearancePane
settings={settings}
updateSettings={vi.fn()}
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
</TooltipProvider>
</I18nextProvider>
)
})
}
function appearanceSectionToggle(
container: HTMLElement,
sectionId: 'interface' | 'terminal' | 'window'
): HTMLButtonElement | undefined {
return Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).find(
(button) => button.getAttribute('aria-controls') === `appearance-section-${sectionId}`
)
}
describe('AppearancePane', () => {
afterEach(async () => {
await act(async () => {
@ -182,6 +218,7 @@ describe('AppearancePane', () => {
mocks.state.availableStatusBarToggles = []
mocks.state.appPlatform = 'linux'
mocks.state.settingsSearchQuery = 'automations'
mocks.state.appearanceAccordionDeepLink = null
mocks.state.usagePercentageDisplay = 'used'
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
@ -481,7 +518,7 @@ describe('AppearancePane', () => {
expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity')
})
it('collapses sibling sections so only the Interface section is expanded by default', async () => {
it('expands Interface, Terminal, and Window & Sidebar by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@ -489,7 +526,80 @@ describe('AppearancePane', () => {
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(expanded).toHaveLength(1)
expect(expanded[0]?.textContent).toContain('Interface')
expect(expanded).toHaveLength(3)
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Interface')
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Terminal')
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Window & Sidebar')
})
it('lets each appearance section collapse independently', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const terminalToggle = appearanceSectionToggle(container, 'terminal')
expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
await act(async () => {
terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(terminalToggle?.getAttribute('aria-expanded')).toBe('false')
const stillExpanded = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(stillExpanded).toHaveLength(2)
expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain('Interface')
expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain(
'Window & Sidebar'
)
})
it('re-opens a collapsed section for appearance deep links without collapsing siblings', async () => {
mocks.state.settingsSearchQuery = ''
mocks.state.appearanceAccordionDeepLink = null
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const windowToggle = appearanceSectionToggle(container, 'window')
const terminalToggle = appearanceSectionToggle(container, 'terminal')
expect(windowToggle?.getAttribute('aria-expanded')).toBe('true')
await act(async () => {
windowToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(windowToggle?.getAttribute('aria-expanded')).toBe('false')
expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
mocks.state.appearanceAccordionDeepLink = 'window'
await rerenderAppearancePane()
expect(appearanceSectionToggle(container, 'window')?.getAttribute('aria-expanded')).toBe('true')
expect(appearanceSectionToggle(container, 'terminal')?.getAttribute('aria-expanded')).toBe(
'true'
)
expect(mocks.state.clearAppearanceAccordionDeepLink).toHaveBeenCalled()
})
it('disables section toggles while searching so clearing search does not surprise-collapse', async () => {
mocks.state.settingsSearchQuery = 'terminal'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const terminalToggle = appearanceSectionToggle(container, 'terminal')
expect(terminalToggle?.disabled).toBe(true)
expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
await act(async () => {
terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
mocks.state.settingsSearchQuery = ''
await rerenderAppearancePane()
const afterClear = appearanceSectionToggle(container, 'terminal')
expect(afterClear?.disabled).toBe(false)
expect(afterClear?.getAttribute('aria-expanded')).toBe('true')
})
})

View File

@ -55,6 +55,12 @@ type AppearancePaneProps = {
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
const ALL_APPEARANCE_SECTIONS = [
'interface',
'terminal',
'window'
] as const satisfies readonly AppearanceSectionKey[]
function resolveThemeSummary(theme: GlobalSettings['theme']): string {
if (theme === 'system') {
return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
@ -88,20 +94,29 @@ export function AppearancePane({
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
'interface'
// Why: Terminal / Window settings were too easy to miss when only Interface
// started open; keep sections independently collapsible but expanded by default.
const [openSections, setOpenSections] = useState<ReadonlySet<AppearanceSectionKey>>(
() => new Set(ALL_APPEARANCE_SECTIONS)
)
// Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar;
// expand that accordion before Settings scrolls so the row is actually visible.
// expand that section before Settings scrolls so the row is actually visible.
useLayoutEffect(() => {
if (!appearanceAccordionDeepLink) {
return
}
setManuallyOpenSection(appearanceAccordionDeepLink)
setOpenSections((current) => {
if (current.has(appearanceAccordionDeepLink)) {
return current
}
const next = new Set(current)
next.add(appearanceAccordionDeepLink)
return next
})
clearAppearanceAccordionDeepLink()
// Why: accordion expand is layout-synchronous; scroll on the next frame so
// the target has non-zero height when Settings (or this fallback) scrolls.
// Why: expand is layout-synchronous; scroll on the next frame so the target
// has non-zero height when Settings (or this fallback) scrolls.
const frameId = requestAnimationFrame(() => {
document
.getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)
@ -167,8 +182,8 @@ export function AppearancePane({
const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries())
// While searching, force-open every section that contains a match so its
// controls (including advanced ones) are revealed; otherwise the accordion
// shows exactly one manually-chosen section.
// controls (including advanced ones) are revealed; otherwise use the user's
// independent open/closed state (all expanded by default).
function isSectionOpen(key: AppearanceSectionKey): boolean {
if (isSearching) {
return key === 'interface'
@ -177,11 +192,19 @@ export function AppearancePane({
? terminalMatches
: windowMatches
}
return manuallyOpenSection === key
return openSections.has(key)
}
function toggleSection(key: AppearanceSectionKey): void {
setManuallyOpenSection((current) => (current === key ? null : key))
setOpenSections((current) => {
const next = new Set(current)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${
@ -203,6 +226,7 @@ export function AppearancePane({
summary={interfaceSummary}
open={isSectionOpen('interface')}
onToggle={() => toggleSection('interface')}
toggleDisabled={isSearching}
>
<AppearanceInterfaceSection
settings={settings}
@ -231,6 +255,7 @@ export function AppearancePane({
summary={terminalSummary}
open={isSectionOpen('terminal')}
onToggle={() => toggleSection('terminal')}
toggleDisabled={isSearching}
>
<TerminalAppearanceSection
settings={settings}
@ -253,6 +278,7 @@ export function AppearancePane({
summary={windowSidebarSummary}
open={isSectionOpen('window')}
onToggle={() => toggleSection('window')}
toggleDisabled={isSearching}
>
<AppearanceWindowSidebarSection
settings={settings}

View File

@ -3,7 +3,7 @@ import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
type AppearanceSectionProps = {
/** Stable id used for the accordion toggle + aria wiring. */
/** Stable id used for the section toggle + aria wiring. */
id: string
icon: React.ReactNode
title: React.ReactNode
@ -11,12 +11,15 @@ type AppearanceSectionProps = {
summary: React.ReactNode
open: boolean
onToggle: () => void
/** Why: search force-opens matching sections; disable collapse so toggles
* do not silently rewrite open-state that only applies after search clears. */
toggleDisabled?: boolean
children: React.ReactNode
}
/** Compact summary row that expands its section inline. The parent owns the
* open state so opening one row can collapse the previously open one
* (accordion behavior) and search can force a section open. */
/** Compact summary row that expands its section inline. The parent owns open
* state so sections can stay independently collapsible and search can force
* a section open. */
export function AppearanceSection({
id,
icon,
@ -24,6 +27,7 @@ export function AppearanceSection({
summary,
open,
onToggle,
toggleDisabled = false,
children
}: AppearanceSectionProps): React.JSX.Element {
const contentId = `appearance-section-${id}`
@ -39,7 +43,8 @@ export function AppearanceSection({
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
disabled={toggleDisabled}
className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:bg-transparent"
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-secondary text-foreground [&_svg]:size-4">
{icon}

View File

@ -645,7 +645,7 @@ function Settings(): React.JSX.Element {
}
pendingNavSectionRef.current = paneSectionId
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
// Why: force Appearance's collapsed status-bar accordion open before scrolling so the row is visible.
// Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible.
if (settingsNavigationTarget.pane === 'appearance') {
const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId)
if (accordion) {
@ -974,7 +974,7 @@ function Settings(): React.JSX.Element {
const scrollTargetId = pendingScrollTargetRef.current
const pendingNavSectionId = pendingNavSectionRef.current
// Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching accordion.
// Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching section.
if (
scrollTargetId &&
pendingNavSectionId &&

View File

@ -5,12 +5,12 @@ import { translateSearchKeyword } from './settings-search-keywords'
/** Stable Settings deep-link / scroll target for the Used/Remaining control. */
export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display'
/** Appearance accordion keys that can be force-opened for nested deep links. */
/** Appearance section keys that can be force-opened for nested deep links. */
export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window'
/**
* Map a Settings subsection id to the Appearance accordion that must be open
* before the row is visible (collapsed accordion content is not user-visible).
* Map a Settings subsection id to the Appearance section that must be open
* before the row is visible (collapsed section content is not user-visible).
*/
export function resolveAppearanceAccordionDeepLink(
sectionId: string | undefined

View File

@ -62,7 +62,9 @@ export const getTerminalMacOptionSearchEntries = createLocalizedCatalog(() => [
'international'
),
...translateSearchKeyword('auto.components.settings.terminal.search.fae142a354', 'readline'),
...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty')
// Why: product name stays untranslated so search matches "Ghostty".
'Ghostty',
'ghostty'
]
}
])
@ -99,7 +101,9 @@ export const getTerminalGhosttyImportSearchEntries = createLocalizedCatalog(() =
'One-time import of supported Ghostty terminal settings.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'),
// Why: product name stays untranslated so search matches "Ghostty".
'Ghostty',
'ghostty',
...translateSearchKeyword('auto.components.settings.terminal.search.fd752b3cac', 'import'),
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('auto.components.settings.terminal.search.2ade3ea490', 'config'),

View File

@ -85,7 +85,9 @@ export const getTerminalPaneInteractionSearchEntries = createLocalizedCatalog(()
...translateSearchKeyword('auto.components.settings.terminal.search.ea364ce6e4', 'mouse'),
...translateSearchKeyword('auto.components.settings.terminal.search.d1fa00a9cb', 'hover'),
...translateSearchKeyword('auto.components.settings.terminal.search.846a7a1204', 'pane'),
...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'),
// Why: product name stays untranslated so search matches "Ghostty".
'Ghostty',
'ghostty',
...translateSearchKeyword('auto.components.settings.terminal.search.f036794286', 'active')
]
},

View File

@ -8642,7 +8642,6 @@
"10f9fb6fea": "settings",
"2ade3ea490": "config",
"fd752b3cac": "import",
"82b63d07fe": "ghostty",
"73e9422f19": "One-time import of supported Ghostty terminal settings.",
"a979df0083": "Import from Ghostty",
"warp_import": {

View File

@ -8582,7 +8582,6 @@
"10f9fb6fea": "ajustes",
"2ade3ea490": "configuración",
"fd752b3cac": "importar",
"82b63d07fe": "ghostty",
"73e9422f19": "Importación única de ajustes de terminal compatibles de Ghostty.",
"a979df0083": "Importar desde Ghostty",
"4cec42dbf7": "internacional",

View File

@ -8604,7 +8604,6 @@
"10f9fb6fea": "設定",
"2ade3ea490": "構成",
"fd752b3cac": "インポート",
"82b63d07fe": "ghostty",
"73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。",
"a979df0083": "Ghostty からインポート",
"warp_import": {

View File

@ -8567,7 +8567,6 @@
"10f9fb6fea": "설정",
"2ade3ea490": "구성",
"fd752b3cac": "가져오기",
"82b63d07fe": "ghostty",
"73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.",
"a979df0083": "Ghostty에서 가져오기",
"4cec42dbf7": "국제",

View File

@ -6870,7 +6870,7 @@
"4aae5db258": "字体粗细",
"f04b17a50e": "新窗格和实时更新的默认终端字体系列。",
"a408266e67": "字体家族",
"855a76343a": "从幽灵导入",
"855a76343a": "从 Ghostty 导入",
"711e589f18": "新窗格和实时更新的默认终端排版。",
"048aac8a64": "终端排版",
"4415beb958": "已禁用",
@ -8567,9 +8567,8 @@
"10f9fb6fea": "设置",
"2ade3ea490": "配置",
"fd752b3cac": "导入",
"82b63d07fe": "Ghostty",
"73e9422f19": "一次性导入受支持的 Ghostty 终端设置。",
"a979df0083": "从幽灵导入",
"a979df0083": "从 Ghostty 导入",
"4cec42dbf7": "国际",
"b495dc6a9f": "吉斯",
"d8d6f7a3c5": "麦科斯",