fix: update theme preview on focus change (#562)

Treat default select focus as initial state so /theme and first-run previews follow keyboard navigation again.

Co-authored-by: anandh8x <test@example.com>
This commit is contained in:
Anandan
2026-04-10 19:25:15 +05:30
committed by GitHub
parent 68c296833d
commit 692471850f
3 changed files with 161 additions and 107 deletions

View File

@@ -285,7 +285,7 @@ export function Select(t0) {
onChange, onChange,
onCancel, onCancel,
onFocus, onFocus,
focusValue: defaultFocusValue defaultFocusValue,
}; };
$[7] = defaultFocusValue; $[7] = defaultFocusValue;
$[8] = defaultValue; $[8] = defaultValue;

View File

@@ -35,6 +35,11 @@ export type UseSelectStateProps<T> = {
*/ */
onFocus?: (value: T) => void onFocus?: (value: T) => void
/**
* Initial value to focus when the component mounts.
*/
defaultFocusValue?: T
/** /**
* Value to focus * Value to focus
*/ */
@@ -131,6 +136,7 @@ export function useSelectState<T>({
onChange, onChange,
onCancel, onCancel,
onFocus, onFocus,
defaultFocusValue,
focusValue, focusValue,
}: UseSelectStateProps<T>): SelectState<T> { }: UseSelectStateProps<T>): SelectState<T> {
const [value, setValue] = useState<T | undefined>(defaultValue) const [value, setValue] = useState<T | undefined>(defaultValue)
@@ -138,7 +144,7 @@ export function useSelectState<T>({
const navigation = useSelectNavigation<T>({ const navigation = useSelectNavigation<T>({
visibleOptionCount, visibleOptionCount,
options, options,
initialFocusValue: undefined, initialFocusValue: defaultFocusValue,
onFocus, onFocus,
focusValue, focusValue,
}) })

View File

@@ -1,113 +1,161 @@
import { describe, expect, it, mock } from 'bun:test' import { PassThrough } from 'node:stream'
// We can't fully render ThemePicker due to complex dependencies import { afterEach, expect, mock, test } from 'bun:test'
// But we can test the theme options generation logic import React from 'react'
describe('ThemePicker', () => { import stripAnsi from 'strip-ansi'
describe('theme options', () => {
it('generates correct theme options without AUTO_THEME feature flag', () => {
// Since we can't easily mock bun:bundle, test the options structure
// The real test would require integration testing
const expectedOptions = [
{ label: "Dark mode", value: "dark" },
{ label: "Light mode", value: "light" },
{ label: "Dark mode (colorblind-friendly)", value: "dark-daltonized" },
{ label: "Light mode (colorblind-friendly)", value: "light-daltonized" },
{ label: "Dark mode (ANSI colors only)", value: "dark-ansi" },
{ label: "Light mode (ANSI colors only)", value: "light-ansi" },
]
expect(expectedOptions.length).toBe(6)
})
it('includes auto theme when AUTO_THEME feature is enabled', () => { import { createRoot, Text, useTheme } from '../ink.js'
// Test the structure when auto is present import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
const optionsWithAuto = [ import { AppStateProvider } from '../state/AppState.js'
{ label: "Auto (match terminal)", value: "auto" }, import { ThemeProvider } from './design-system/ThemeProvider.js'
{ label: "Dark mode", value: "dark" },
] mock.module('./StructuredDiff.js', () => ({
expect(optionsWithAuto[0].value).toBe('auto') StructuredDiff: function StructuredDiffPreview(): React.ReactNode {
}) const [theme] = useTheme()
return <Text>{`Preview theme: ${theme}`}</Text>
},
}))
mock.module('./StructuredDiff/colorDiff.js', () => ({
getColorModuleUnavailableReason: () => 'env',
getSyntaxTheme: () => null,
}))
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
}) })
describe('handleRowFocus callback', () => { return {
it('setPreviewTheme is called with theme setting', () => { stdout,
const setPreviewTheme = mock() stdin,
const handleRowFocus = (setting: string) => setPreviewTheme(setting) getOutput: () => output,
}
handleRowFocus('dark') }
expect(setPreviewTheme).toHaveBeenCalledWith('dark')
}) async function waitForCondition(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for ThemePicker test condition')
}
async function waitForFrame(
getOutput: () => string,
predicate: (frame: string) => boolean,
): Promise<string> {
let frame = ''
await waitForCondition(() => {
frame = stripAnsi(extractLastFrame(getOutput()))
return predicate(frame)
}) })
describe('handleSelect callback', () => { return frame
it('calls savePreview and onThemeSelect', () => { }
const savePreview = mock()
const onThemeSelect = mock()
const handleSelect = (setting: string) => {
savePreview()
onThemeSelect(setting)
}
handleSelect('light')
expect(savePreview).toHaveBeenCalled()
expect(onThemeSelect).toHaveBeenCalledWith('light')
})
})
describe('handleCancel callback', () => { afterEach(() => {
it('calls cancelPreview and gracefulShutdown when not skipExitHandling', () => { mock.restore()
const cancelPreview = mock() })
const gracefulShutdown = mock()
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => { test('updates the preview when keyboard focus moves to another theme', async () => {
cancelPreview() const { ThemePicker } = await import('./ThemePicker.js')
if (skipExitHandling) { const { stdout, stdin, getOutput } = createTestStreams()
onCancelProp?.() const root = await createRoot({
} else { stdout: stdout as unknown as NodeJS.WriteStream,
gracefulShutdown(0) stdin: stdin as unknown as NodeJS.ReadStream,
} patchConsole: false,
} })
handleCancel(false) root.render(
expect(cancelPreview).toHaveBeenCalled() <AppStateProvider>
expect(gracefulShutdown).toHaveBeenCalledWith(0) <KeybindingSetup>
}) <ThemeProvider initialState="dark">
<ThemePicker onThemeSelect={() => {}} />
it('calls onCancelProp when skipExitHandling is true', () => { </ThemeProvider>
const cancelPreview = mock() </KeybindingSetup>
const onCancelProp = mock() </AppStateProvider>,
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => { )
cancelPreview()
if (skipExitHandling) { try {
onCancelProp?.() const initialFrame = await waitForFrame(
} getOutput,
} frame => frame.includes('Preview theme: dark'),
)
handleCancel(true, onCancelProp) expect(initialFrame).toContain('Preview theme: dark')
expect(cancelPreview).toHaveBeenCalled()
expect(onCancelProp).toHaveBeenCalled() stdin.write('j')
})
}) const updatedFrame = await waitForFrame(
getOutput,
describe('syntax hint logic', () => { frame => frame.includes('Preview theme: light'),
it('shows disabled hint when syntax highlighting is disabled', () => { )
const syntaxHighlightingDisabled = true expect(updatedFrame).toContain('Preview theme: light')
const syntaxToggleShortcut = 'Ctrl+T' } finally {
root.unmount()
const hint = syntaxHighlightingDisabled stdin.end()
? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` stdout.end()
: `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)` await Bun.sleep(0)
}
expect(hint).toContain('disabled')
})
it('shows enabled hint when syntax highlighting is active', () => {
const syntaxHighlightingDisabled = false
const syntaxToggleShortcut = 'Ctrl+T'
const hint = !syntaxHighlightingDisabled
? `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
: `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
expect(hint).toContain('enabled')
})
})
}) })