From 912c2495fa4ddd9fecc650b0640d59feba6c99bd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:42:34 -0700 Subject: [PATCH] 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. --- .../onboarding/GhosttyDiscoveryRow.tsx | 2 +- .../settings/AppearancePane.test.tsx | 116 +++++++++++++++++- .../components/settings/AppearancePane.tsx | 46 +++++-- .../components/settings/AppearanceSection.tsx | 15 ++- .../src/components/settings/Settings.tsx | 4 +- .../appearance-usage-percentage-search.ts | 6 +- .../terminal-advanced-platform-search.ts | 8 +- .../terminal-pane-appearance-search.ts | 4 +- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 5 +- 13 files changed, 176 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx index 9a24547251e..a9e44f218b6 100644 --- a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx +++ b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx @@ -28,7 +28,7 @@ export function GhosttyDiscoveryRow({ {translate( 'auto.components.onboarding.ThemeStep.2c3aa538f8', - 'Looking for a Ghostty config...' + 'Looking for a Ghostty config…' )} ) diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 0550e9704a6..fb1395a8bce 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -167,6 +167,42 @@ async function renderAppearancePane( return container } +async function rerenderAppearancePane( + settings: GlobalSettings = getDefaultSettings('/tmp') +): Promise { + const root = mountedRoots.at(-1) + if (!root) { + throw new Error('expected a mounted AppearancePane root') + } + await act(async () => { + root.render( + + + + + + ) + }) +} + +function appearanceSectionToggle( + container: HTMLElement, + sectionId: 'interface' | 'terminal' | 'window' +): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('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('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('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') }) }) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 58954d32565..6e73d5aafdf 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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( - '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>( + () => 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} > toggleSection('terminal')} + toggleDisabled={isSearching} > toggleSection('window')} + toggleDisabled={isSearching} > 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" > {icon} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index ac0578fa38b..65b98097cca 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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 && diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts index b7bbc49218b..5745bfa325b 100644 --- a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -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 diff --git a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts index e686b51d37c..a944ae12f65 100644 --- a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts +++ b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts @@ -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'), diff --git a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts index d77c5e68432..becfb796bee 100644 --- a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts +++ b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts @@ -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') ] }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 71aef99a990..f73ec351319 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 38bd42096b2..7bd23476609 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7d4a14ba7a2..e45b172ace4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8604,7 +8604,6 @@ "10f9fb6fea": "設定", "2ade3ea490": "構成", "fd752b3cac": "インポート", - "82b63d07fe": "ghostty", "73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。", "a979df0083": "Ghostty からインポート", "warp_import": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f4a063ce1a4..d032b5bdb5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8567,7 +8567,6 @@ "10f9fb6fea": "설정", "2ade3ea490": "구성", "fd752b3cac": "가져오기", - "82b63d07fe": "ghostty", "73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.", "a979df0083": "Ghostty에서 가져오기", "4cec42dbf7": "국제", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8d65c5be1e..cd83fea80a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "麦科斯",