From 7e56cc65157c8fe55b3ee81441543545f09ee405 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Mon, 8 Sep 2025 11:43:58 -0700 Subject: [PATCH] Move scope settings to new dialog (#7836) --- .../src/ui/components/ThemeDialog.test.tsx | 98 ++++++++++ .../cli/src/ui/components/ThemeDialog.tsx | 181 +++++++----------- .../__snapshots__/ThemeDialog.test.tsx.snap | 38 ++++ .../ui/components/shared/ScopeSelector.tsx | 52 +++++ 4 files changed, 261 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/ui/components/ThemeDialog.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/shared/ScopeSelector.tsx diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx new file mode 100644 index 0000000000..f2899e94a2 --- /dev/null +++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ThemeDialog } from './ThemeDialog.js'; +import { LoadedSettings } from '../../config/settings.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; +import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js'; +import { act } from 'react'; + +const createMockSettings = ( + userSettings = {}, + workspaceSettings = {}, + systemSettings = {}, +): LoadedSettings => + new LoadedSettings( + { + settings: { ui: { customThemes: {} }, ...systemSettings }, + path: '/system/settings.json', + }, + { + settings: {}, + path: '/system/system-defaults.json', + }, + { + settings: { + ui: { customThemes: {} }, + ...userSettings, + }, + path: '/user/settings.json', + }, + { + settings: { + ui: { customThemes: {} }, + ...workspaceSettings, + }, + path: '/workspace/settings.json', + }, + true, + new Set(), + ); + +describe('ThemeDialog Snapshots', () => { + const baseProps = { + onSelect: vi.fn(), + onHighlight: vi.fn(), + availableTerminalHeight: 40, + terminalWidth: 120, + }; + + beforeEach(() => { + // Reset theme manager to a known state + themeManager.setActiveTheme(DEFAULT_THEME.name); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render correctly in theme selection mode', () => { + const settings = createMockSettings(); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render correctly in scope selector mode', async () => { + const settings = createMockSettings(); + const { lastFrame, stdin } = render( + + + + + , + ); + + // Press Tab to switch to scope selector mode + act(() => { + stdin.write('\t'); + }); + + // Need to wait for the state update to propagate + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index f4729f624f..21fc78161d 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -14,11 +14,9 @@ import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - getScopeItems, - getScopeMessageForSetting, -} from '../../utils/dialogScopeUtils.js'; +import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { ScopeSelector } from './shared/ScopeSelector.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ @@ -73,18 +71,14 @@ export function ThemeDialog({ themeTypeDisplay: 'Custom', })), ]; - const [selectInputKey, setSelectInputKey] = useState(Date.now()); // Find the index of the selected theme, but only if it exists in the list - const selectedThemeName = settings.merged.ui?.theme || DEFAULT_THEME.name; const initialThemeIndex = themeItems.findIndex( - (item) => item.value === selectedThemeName, + (item) => item.value === highlightedThemeName, ); // If not found, fall back to the first theme const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0; - const scopeItems = getScopeItems(); - const handleThemeSelect = useCallback( (themeName: string) => { onSelect(themeName, selectedScope); @@ -99,25 +93,21 @@ export function ThemeDialog({ const handleScopeHighlight = useCallback((scope: SettingScope) => { setSelectedScope(scope); - setSelectInputKey(Date.now()); }, []); const handleScopeSelect = useCallback( (scope: SettingScope) => { - handleScopeHighlight(scope); - setFocusedSection('theme'); // Reset focus to theme section + onSelect(highlightedThemeName, scope); }, - [handleScopeHighlight], + [onSelect, highlightedThemeName], ); - const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>( - 'theme', - ); + const [mode, setMode] = useState<'theme' | 'scope'>('theme'); useKeypress( (key) => { if (key.name === 'tab') { - setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme')); + setMode((prev) => (prev === 'theme' ? 'scope' : 'theme')); } if (key.name === 'escape') { onSelect(undefined, selectedScope); @@ -152,20 +142,13 @@ export function ThemeDialog({ const DIALOG_PADDING = 2; const selectThemeHeight = themeItems.length + 1; - const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin. - const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1; const TAB_TO_SELECT_HEIGHT = 2; availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; availableTerminalHeight -= 2; // Top and bottom borders. availableTerminalHeight -= TAB_TO_SELECT_HEIGHT; - let totalLeftHandSideHeight = - DIALOG_PADDING + - selectThemeHeight + - SCOPE_SELECTION_HEIGHT + - SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO; + let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight; - let showScopeSelection = true; let includePadding = true; // Remove content from the LHS that can be omitted if it exceeds the available height. @@ -174,15 +157,6 @@ export function ThemeDialog({ totalLeftHandSideHeight -= DIALOG_PADDING; } - if (totalLeftHandSideHeight > availableTerminalHeight) { - // First, try hiding the scope selection - totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT; - showScopeSelection = false; - } - - // Don't focus the scope selection if it is hidden due to height constraints. - const currentFocusedSection = !showScopeSelection ? 'theme' : focusedSection; - // Vertical space taken by elements other than the two code blocks in the preview pane. // Includes "Preview" title, borders, and margin between blocks. const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8; @@ -217,94 +191,85 @@ export function ThemeDialog({ paddingRight={1} width="100%" > - - {/* Left Column: Selection */} - - - {currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '} - {otherScopeModifiedMessage} - - + {mode === 'theme' ? ( + + {/* Left Column: Selection */} + + + {mode === 'theme' ? '> ' : ' '}Select Theme{' '} + {otherScopeModifiedMessage} + + + - {/* Scope Selection */} - {showScopeSelection && ( - - - {currentFocusedSection === 'scope' ? '> ' : ' '}Apply To - - - - )} - - - {/* Right Column: Preview */} - - Preview - {/* Get the Theme object for the highlighted theme, fall back to default if not found */} - {(() => { - const previewTheme = - themeManager.getTheme( - highlightedThemeName || DEFAULT_THEME.name, - ) || DEFAULT_THEME; - return ( - - {colorizeCode( - `# function + {/* Right Column: Preview */} + + Preview + {/* Get the Theme object for the highlighted theme, fall back to default if not found */} + {(() => { + const previewTheme = + themeManager.getTheme( + highlightedThemeName || DEFAULT_THEME.name, + ) || DEFAULT_THEME; + return ( + + {colorizeCode( + `# function def fibonacci(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a`, - 'python', - codeBlockHeight, - colorizeCodeWidth, - )} - - + - - ); - })()} + availableTerminalHeight={diffHeight} + terminalWidth={colorizeCodeWidth} + theme={previewTheme} + /> + + ); + })()} + - + ) : ( + + )} - (Use Enter to select - {showScopeSelection ? ', Tab to change focus' : ''}) + (Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '} + {mode === 'theme' ? 'configure scope' : 'select theme'}) diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap new file mode 100644 index 0000000000..b205bba4a5 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Apply To │ +│ ● 1. User Settings │ +│ 2. Workspace Settings │ +│ 3. System Settings │ +│ │ +│ (Use Enter to apply scope, Tab to select theme) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`ThemeDialog Snapshots > should render correctly in theme selection mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Theme Preview │ +│ ▲ ┌─────────────────────────────────────────────────┐ │ +│ 1. ANSI Dark │ │ │ +│ 2. Atom One Dark │ 1 # function │ │ +│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ +│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │ +│ 5. Dracula Dark │ 4 for _ in range(n): │ │ +│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ +│ 7. Shades Of Purple Dark │ 6 return a │ │ +│ 8. ANSI Light Light │ │ │ +│ 9. Ayu Light Light │ 1 - print("Hello, " + name) │ │ +│ 10. Default Light Light │ 1 + print(f"Hello, {name}!") │ │ +│ 11. GitHub Light Light │ │ │ +│ 12. Google Code Light └─────────────────────────────────────────────────┘ │ +│ ▼ │ +│ │ +│ (Use Enter to select, Tab to configure scope) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx new file mode 100644 index 0000000000..8066d8c9ee --- /dev/null +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { SettingScope } from '../../../config/settings.js'; +import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; +import { RadioButtonSelect } from './RadioButtonSelect.js'; + +interface ScopeSelectorProps { + /** Callback function when a scope is selected */ + onSelect: (scope: SettingScope) => void; + /** Callback function when a scope is highlighted */ + onHighlight: (scope: SettingScope) => void; + /** Whether the component is focused */ + isFocused: boolean; + /** The initial scope to select */ + initialScope: SettingScope; +} + +export function ScopeSelector({ + onSelect, + onHighlight, + isFocused, + initialScope, +}: ScopeSelectorProps): React.JSX.Element { + const scopeItems = getScopeItems(); + + const initialIndex = scopeItems.findIndex( + (item) => item.value === initialScope, + ); + const safeInitialIndex = initialIndex >= 0 ? initialIndex : 0; + + return ( + + + {isFocused ? '> ' : ' '}Apply To + + + + ); +}