* fix: remove react-compiler memo cache, restore classical JSX so theme preview actually previews * added themepicker test
262 lines
8.0 KiB
TypeScript
262 lines
8.0 KiB
TypeScript
import { feature } from 'bun:bundle';
|
|
import type { StructuredPatchHunk } from 'diff';
|
|
import * as React from 'react';
|
|
import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'
|
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
import { Box, Text, usePreviewTheme, useTheme, useThemeSetting } from '../ink.js';
|
|
import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js';
|
|
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
|
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
|
import { useAppState, useSetAppState } from '../state/AppState.js';
|
|
import type { AppState } from '../state/AppStateStore.js';
|
|
import { gracefulShutdown } from '../utils/gracefulShutdown.js';
|
|
import { updateSettingsForSource } from '../utils/settings/settings.js';
|
|
import type { ThemeSetting } from '../utils/theme.js';
|
|
import { Select } from './CustomSelect/index.js';
|
|
import { Byline } from './design-system/Byline.js';
|
|
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
|
|
import { getColorModuleUnavailableReason, getSyntaxTheme } from './StructuredDiff/colorDiff.js';
|
|
import { StructuredDiff } from './StructuredDiff.js';
|
|
|
|
type StructuredDiffComponent = React.ComponentType<{
|
|
patch: StructuredPatchHunk
|
|
dim: boolean
|
|
filePath: string
|
|
firstLine: string | null
|
|
width: number
|
|
skipHighlighting?: boolean
|
|
}>
|
|
const StructuredDiffView = StructuredDiff as StructuredDiffComponent
|
|
|
|
export type ThemePickerProps = {
|
|
onThemeSelect: (setting: ThemeSetting) => void;
|
|
showIntroText?: boolean;
|
|
helpText?: string;
|
|
showHelpTextBelow?: boolean;
|
|
hideEscToCancel?: boolean;
|
|
/** Skip exit handling when running in a context that already has it (e.g., onboarding) */
|
|
skipExitHandling?: boolean;
|
|
/** Called when the user cancels (presses Escape). If skipExitHandling is true and this is provided, it will be called instead of just saving the preview. */
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
const DEMO_PATCH: StructuredPatchHunk = {
|
|
oldStart: 1,
|
|
newStart: 1,
|
|
oldLines: 3,
|
|
newLines: 3,
|
|
lines: [
|
|
' function greet() {',
|
|
'- console.log("Hello, World!");',
|
|
'+ console.log("Hello, Claude!");',
|
|
' }',
|
|
],
|
|
}
|
|
|
|
/**
|
|
* Theme chooser with live preview. Implemented without react-compiler `_c` memo
|
|
* caches so preview/subtree reconciliation cannot stick on stale element refs when
|
|
* `setPreviewTheme` updates the resolved palette.
|
|
*/
|
|
export function ThemePicker({
|
|
onThemeSelect,
|
|
showIntroText = false,
|
|
helpText = '',
|
|
showHelpTextBelow = false,
|
|
hideEscToCancel = false,
|
|
skipExitHandling = false,
|
|
onCancel: onCancelProp,
|
|
}: ThemePickerProps) {
|
|
const [theme] = useTheme();
|
|
const themeSetting = useThemeSetting();
|
|
const { columns } = useTerminalSize();
|
|
const colorModuleUnavailableReason = React.useMemo(
|
|
() => getColorModuleUnavailableReason(),
|
|
[],
|
|
)
|
|
const syntaxTheme =
|
|
colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null
|
|
const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme()
|
|
const syntaxHighlightingDisabled = useAppState(
|
|
(s: AppState) => s.settings.syntaxHighlightingDisabled ?? false
|
|
);
|
|
const setAppState = useSetAppState();
|
|
useRegisterKeybindingContext("ThemePicker", true);
|
|
const syntaxToggleShortcut = useShortcutDisplay("theme:toggleSyntaxHighlighting", "ThemePicker", "ctrl+t");
|
|
|
|
const toggleSyntax = React.useCallback(() => {
|
|
if (colorModuleUnavailableReason === null) {
|
|
const newValue = !syntaxHighlightingDisabled
|
|
updateSettingsForSource("userSettings", {
|
|
syntaxHighlightingDisabled: newValue
|
|
});
|
|
setAppState(prev => ({
|
|
...prev,
|
|
settings: {
|
|
...prev.settings,
|
|
syntaxHighlightingDisabled: newValue
|
|
}
|
|
}));
|
|
}
|
|
}, [
|
|
colorModuleUnavailableReason,
|
|
syntaxHighlightingDisabled,
|
|
setAppState,
|
|
])
|
|
|
|
useKeybinding("theme:toggleSyntaxHighlighting", toggleSyntax, {
|
|
context: "ThemePicker",
|
|
})
|
|
|
|
const exitState = useExitOnCtrlCDWithKeybindings(
|
|
skipExitHandling ? () => {} : undefined,
|
|
)
|
|
|
|
const themeOptions = React.useMemo(
|
|
() => [
|
|
...(feature("AUTO_THEME")
|
|
? [{ label: "Auto (match terminal)", value: "auto" as const }]
|
|
: []), {
|
|
label: "Dark mode",
|
|
value: "dark" as const
|
|
}, {
|
|
label: "Light mode",
|
|
value: "light" as const
|
|
}, {
|
|
label: "Dark mode (colorblind-friendly)",
|
|
value: "dark-daltonized" as const,
|
|
}, {
|
|
label: "Light mode (colorblind-friendly)",
|
|
value: "light-daltonized" as const,
|
|
}, {
|
|
label: "Dark mode (ANSI colors only)",
|
|
value: "dark-ansi" as const
|
|
}, {
|
|
label: "Light mode (ANSI colors only)",
|
|
value: "light-ansi" as const
|
|
},],
|
|
[],
|
|
)
|
|
|
|
const handleRowFocus = React.useCallback(
|
|
(setting: ThemeSetting) => {
|
|
setPreviewTheme(setting)
|
|
},
|
|
[setPreviewTheme],
|
|
)
|
|
|
|
const handleSelect = React.useCallback(
|
|
(setting: ThemeSetting) => {
|
|
savePreview()
|
|
onThemeSelect(setting)
|
|
},
|
|
[savePreview, onThemeSelect],
|
|
)
|
|
|
|
const handleCancel = React.useCallback(() => {
|
|
cancelPreview()
|
|
if (skipExitHandling) {
|
|
onCancelProp?.()
|
|
} else {
|
|
void gracefulShutdown(0)
|
|
}
|
|
}, [cancelPreview, onCancelProp, skipExitHandling])
|
|
|
|
const syntaxHint =
|
|
colorModuleUnavailableReason === 'env'
|
|
? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})`
|
|
: syntaxHighlightingDisabled
|
|
? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
|
|
: syntaxTheme
|
|
? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''} (${syntaxToggleShortcut} to disable)`
|
|
: `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
|
|
|
|
const header = showIntroText ? (
|
|
<Text>{"Let's get started."}</Text>
|
|
) : (
|
|
<Text bold color="permission">
|
|
Theme
|
|
</Text>
|
|
)
|
|
|
|
const introBlock = (
|
|
<Box flexDirection="column">
|
|
<Text bold>Choose the text style that looks best with your terminal</Text>
|
|
{helpText && !showHelpTextBelow ? (
|
|
<Text dimColor>{helpText}</Text>
|
|
) : null}
|
|
</Box>
|
|
)
|
|
|
|
const content = (
|
|
<Box flexDirection="column" gap={1}>
|
|
<Box flexDirection="column" gap={1}>
|
|
{header}
|
|
{introBlock}
|
|
<Select
|
|
options={themeOptions}
|
|
onFocus={handleRowFocus}
|
|
onChange={handleSelect}
|
|
onCancel={handleCancel}
|
|
visibleOptionCount={themeOptions.length}
|
|
defaultValue={themeSetting}
|
|
defaultFocusValue={themeSetting}
|
|
/>
|
|
</Box>
|
|
<Box flexDirection="column" width="100%">
|
|
<Box
|
|
key={theme}
|
|
flexDirection="column"
|
|
borderTop
|
|
borderBottom
|
|
borderLeft={false}
|
|
borderRight={false}
|
|
borderStyle="dashed"
|
|
borderColor="subtle"
|
|
>
|
|
<StructuredDiffView
|
|
patch={DEMO_PATCH}
|
|
dim={false}
|
|
filePath="demo.js"
|
|
firstLine={null}
|
|
width={columns}
|
|
/>
|
|
</Box>
|
|
<Text dimColor>
|
|
{' '}
|
|
{syntaxHint}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
|
|
if (!showIntroText) {
|
|
return (
|
|
<>
|
|
<Box flexDirection="column">{content}</Box>
|
|
{showHelpTextBelow && helpText ? (
|
|
<Box marginLeft={3}>
|
|
<Text dimColor>{helpText}</Text>
|
|
</Box>
|
|
) : null}
|
|
{!hideEscToCancel ? (
|
|
<Box marginTop={1}>
|
|
<Text dimColor italic>
|
|
{exitState.pending ? (
|
|
<>Press {exitState.keyName} again to exit</>
|
|
) : (
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="Enter" action="select" />
|
|
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
|
|
</Byline>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return content
|
|
}
|