diff --git a/.changeset/fresh-themes-refactor.md b/.changeset/fresh-themes-refactor.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/fresh-themes-refactor.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d53a9f600..55348a9cd 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -79,6 +79,7 @@ import { useExtensionDialogController } from "./hooks/useExtensionDialogControll import { useExtensionNotifications } from "./hooks/useExtensionNotifications"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; +import { useThemeSelectorController } from "./hooks/useThemeSelectorController"; import { useTerminalReview, type AgentNoteGeometrySnapshot, @@ -139,15 +140,9 @@ import { verifyWorkspaceWriteTarget } from "./lib/workspaceWriteGuard"; import { openSelectedFileInEditor } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; -import { availableThemes, resolveTheme, withTransparentSurfaces } from "./themes"; type FocusArea = "files" | "filter" | "note"; type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string }; -type ThemeSelectorState = { - open: boolean; - selectedIndex: number; - previewThemeId: string | null; -}; const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; @@ -297,17 +292,26 @@ export function App({ const cancelCopySelectionRef = useRef<(() => void) | null>(null); const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0); const [transientNoticeText, setTransientNoticeText] = useState(null); + const transientTimerRef = useRef | null>(null); + /** Show a short-lived status-bar notice and replace any pending notice timer. */ + const showTransientNotice = useCallback((text: string, durationMs = 3000) => { + if (transientTimerRef.current !== null) { + clearTimeout(transientTimerRef.current); + } + setTransientNoticeText(text); + transientTimerRef.current = setTimeout(() => { + transientTimerRef.current = null; + setTransientNoticeText((current) => (current === text ? null : current)); + }, durationMs); + }, []); + useEffect(() => { + return () => { + if (transientTimerRef.current !== null) { + clearTimeout(transientTimerRef.current); + } + }; + }, []); const [layoutMode, setLayoutMode] = useState(bootstrap.initialMode); - const [themeId, setThemeId] = useState( - () => - resolveTheme( - bootstrap.initialTheme, - bootstrap.initialThemeMode ?? renderer.themeMode, - bootstrap.customThemes, - ).id, - ); - // Soft reloads replace bootstrap without re-running startup terminal theme detection. - const [detectedThemeMode] = useState(() => bootstrap.initialThemeMode); const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true); const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false); const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false); @@ -319,11 +323,6 @@ export function App({ }>({ id: 0, alignment: "center" }); const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true); const [showMenuBar, setShowMenuBar] = useState(bootstrap.initialShowMenuBar ?? true); - const [themeSelectorState, setThemeSelectorState] = useState({ - open: false, - selectedIndex: 0, - previewThemeId: null, - }); const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); const [forceSidebarOpen, setForceSidebarOpen] = useState( () => !pagerMode && bootstrap.initialSidebar === true, @@ -392,33 +391,26 @@ export function App({ const offeredTrustRepoRootsRef = useRef>(new Set()); const extensionTrustPromptOpen = extensionTrustPromptRoot !== null; - const themeOptions = useMemo( - () => availableThemes(bootstrap.customThemes), - [bootstrap.customThemes], - ); - const effectiveThemeId = themeSelectorState.previewThemeId ?? themeId; - const baseTheme = useMemo( - () => resolveTheme(effectiveThemeId, detectedThemeMode ?? null, bootstrap.customThemes), - [effectiveThemeId, detectedThemeMode, bootstrap.customThemes], - ); - const activeTheme = useMemo( - () => - bootstrap.input.options.transparentBackground - ? withTransparentSurfaces(baseTheme) - : baseTheme, - [baseTheme, bootstrap.input.options.transparentBackground], - ); - - const themeSelectorItems = useMemo( - () => - themeOptions.map((theme) => ({ - id: theme.id, - label: theme.label, - description: theme.id === activeTheme.id ? "active" : "", - active: theme.id === activeTheme.id, - })), - [activeTheme.id, themeOptions], - ); + const { + activeTheme, + baseTheme, + themeId, + themeSelectorItems, + themeSelectorOpen, + themeSelectorSelectedIndex, + acceptThemeSelector, + acceptThemeSelectorItem, + closeThemeSelector, + moveThemeSelector, + openThemeSelector, + previewThemeSelectorItem, + } = useThemeSelectorController({ + customThemes: bootstrap.customThemes, + initialTheme: bootstrap.initialTheme, + initialThemeMode: bootstrap.initialThemeMode ?? renderer.themeMode, + onTransientNotice: showTransientNotice, + transparentBackground: bootstrap.input.options.transparentBackground ?? false, + }); const currentViewPreferences = useMemo( () => ({ mode: layoutMode, @@ -1483,30 +1475,6 @@ export function App({ setCopyDecorations((current) => !current); }; - // Show a short-lived status-bar message. Used to surface clipboard-copy outcomes that would - // otherwise be invisible to the user (OSC52 unsupported, etc.). - // Track the timer so we can clear it on unmount and avoid React state updates after unmount. - const transientTimerRef = useRef | null>(null); - const showTransientNotice = useCallback((text: string, durationMs = 3000) => { - if (transientTimerRef.current !== null) { - clearTimeout(transientTimerRef.current); - } - setTransientNoticeText(text); - transientTimerRef.current = setTimeout(() => { - transientTimerRef.current = null; - setTransientNoticeText((current) => (current === text ? null : current)); - }, durationMs); - }, []); - - // Clear any pending transient-notice timer on unmount to avoid state updates after unmount. - useEffect(() => { - return () => { - if (transientTimerRef.current !== null) { - clearTimeout(transientTimerRef.current); - } - }; - }, []); - /** Toggle whether diff code rows wrap instead of truncating to one terminal row. */ const toggleLineWrap = () => { // Capture the pre-toggle viewport position synchronously so DiffPane can restore the same @@ -1524,83 +1492,6 @@ export function App({ reportedThemeIdRef.current = themeId; }, [extensions, themeId]); - /** Switch the active theme. */ - const selectTheme = useCallback( - (nextThemeId: string) => { - const nextTheme = themeOptions.find((theme) => theme.id === nextThemeId); - setThemeId(nextThemeId); - showTransientNotice(`Theme: ${nextTheme?.label ?? nextThemeId}`); - }, - [showTransientNotice, themeOptions], - ); - - /** Open the keyboard-driven theme selector with the current theme highlighted. */ - const openThemeSelector = useCallback(() => { - const currentIndex = themeSelectorItems.findIndex((item) => item.id === activeTheme.id); - setThemeSelectorState({ - open: true, - selectedIndex: Math.max(0, currentIndex), - previewThemeId: null, - }); - }, [activeTheme.id, themeSelectorItems]); - - const closeThemeSelector = useCallback(() => { - // Dropping the preview id reverts all previewed colors in the same state transition. - setThemeSelectorState((current) => ({ ...current, open: false, previewThemeId: null })); - }, []); - - const moveThemeSelector = useCallback( - (delta: number) => { - setThemeSelectorState((current) => { - if (themeSelectorItems.length === 0) { - return { ...current, selectedIndex: 0, previewThemeId: null }; - } - - const nextIndex = - (current.selectedIndex + delta + themeSelectorItems.length) % themeSelectorItems.length; - const item = themeSelectorItems[nextIndex]!; - return { ...current, selectedIndex: nextIndex, previewThemeId: item.id }; - }); - }, - [themeSelectorItems], - ); - - /** Preview the theme under the pointer without committing it. */ - const previewThemeSelectorItem = useCallback( - (index: number) => { - const item = themeSelectorItems[index]; - if (!item) { - return; - } - - setThemeSelectorState((current) => ({ - ...current, - selectedIndex: index, - previewThemeId: item.id, - })); - }, - [themeSelectorItems], - ); - - /** Commit one theme and close the selector. */ - const acceptThemeSelectorItem = useCallback( - (index: number) => { - const item = themeSelectorItems[index]; - if (!item) { - return; - } - - selectTheme(item.id); - // Close without a preview id; the committed theme id now supplies the same effective theme. - setThemeSelectorState((current) => ({ ...current, open: false, previewThemeId: null })); - }, - [selectTheme, themeSelectorItems], - ); - - const acceptThemeSelector = useCallback(() => { - acceptThemeSelectorItem(themeSelectorState.selectedIndex); - }, [acceptThemeSelectorItem, themeSelectorState.selectedIndex]); - /** Toggle only the active files pane without changing extension pane visibility. */ const toggleFilesPane = () => { const filesPaneKey = resolvePaneSlotKey({ @@ -2164,7 +2055,7 @@ export function App({ showHelp, switchMenu, toggleFocusArea, - themeSelectorOpen: themeSelectorState.open, + themeSelectorOpen, }); /** Start a mouse drag for one resizable pane. */ @@ -2604,11 +2495,11 @@ export function App({ ) : null} - {themeSelectorState.open ? ( + {themeSelectorOpen ? ( { } }); + test("theme events report only explicit acceptance, not previews or catalog projection", async () => { + const custom = { + id: "session-custom", + label: "Session custom", + base: "github-dark-default", + accent: "#8877cc", + }; + const bootstrap = createSingleFileBootstrap(); + const extensions = createEmptyExtensionLoadResult(process.cwd()); + const themeEvents: string[] = []; + extensions.registry.eventHandlers.theme_changed.push({ + extensionId: "theme-probe", + handler: ({ themeId }) => { + themeEvents.push(themeId); + }, + }); + bootstrap.initialTheme = custom.id; + bootstrap.customThemes = [custom]; + bootstrap.extensions = extensions; + let replaceCustomThemes!: (themes: AppBootstrap["customThemes"]) => void; + + function ThemeEventProbe() { + const [currentBootstrap, setCurrentBootstrap] = useState(bootstrap); + replaceCustomThemes = (themes) => + setCurrentBootstrap((current) => ({ ...current, customThemes: themes })); + return ( + () => {}} + onReloadSession={async () => { + throw new Error("Theme event test does not reload the session."); + }} + onWorkspaceWriteCompleted={() => {}} + runWorkspaceWrite={async (write) => { + await write(); + return true; + }} + /> + ); + } + + const setup = await testRender(, { width: 240, height: 24 }); + try { + await flush(setup); + expect(themeEvents).toEqual([]); + + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (frame) => frame.includes("Theme selector")); + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + expect(themeEvents).toEqual([]); + + await act(async () => { + await setup.mockInput.pressEscape(); + }); + await waitForFrame(setup, (frame) => !frame.includes("Theme selector")); + expect(themeEvents).toEqual([]); + + await act(async () => replaceCustomThemes([])); + await flush(setup); + expect(themeEvents).toEqual([]); + + await act(async () => replaceCustomThemes([custom])); + await flush(setup); + expect(themeEvents).toEqual([]); + + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (frame) => frame.includes("Theme selector")); + await act(async () => { + await setup.mockInput.pressArrow("down"); + await setup.mockInput.pressEnter(); + }); + await flush(setup); + + expect(themeEvents).toEqual([availableThemes([custom])[0]!.id]); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("keyboard shortcut can wrap long lines in the app", async () => { const setup = await testRender(, { width: 140, diff --git a/src/ui/hooks/useThemeSelectorController.test.tsx b/src/ui/hooks/useThemeSelectorController.test.tsx new file mode 100644 index 000000000..f69dc8e3e --- /dev/null +++ b/src/ui/hooks/useThemeSelectorController.test.tsx @@ -0,0 +1,353 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, useState } from "react"; +import type { NamedCustomThemeConfig } from "../../extension-api/types"; +import { availableThemes, TRANSPARENT_BACKGROUND } from "../themes"; +import { + useThemeSelectorController, + type UseThemeSelectorControllerOptions, +} from "./useThemeSelectorController"; + +type ThemeSelectorController = ReturnType; + +/** Mount the controller with replaceable bootstrap-like inputs. */ +async function renderThemeSelectorController(initial: UseThemeSelectorControllerOptions) { + let controller!: ThemeSelectorController; + let replaceOptions!: (options: UseThemeSelectorControllerOptions) => void; + + function Probe() { + const [options, setOptions] = useState(initial); + replaceOptions = setOptions; + controller = useThemeSelectorController(options); + return null; + } + + const setup = await testRender(, { width: 80, height: 24 }); + await act(async () => { + await setup.renderOnce(); + }); + + return { + get controller() { + return controller; + }, + replaceOptions, + setup, + }; +} + +/** Destroy a hook test renderer inside React's update boundary. */ +async function destroyController(setup: Awaited>) { + await act(async () => { + setup.renderer.destroy(); + }); +} + +/** Build a small custom theme suitable for identity and palette replacement tests. */ +function customTheme( + id: string, + label: string, + accent: string, + base = "github-dark-default", +): NamedCustomThemeConfig { + return { id, label, accent, base }; +} + +const noNotice = () => {}; + +describe("useThemeSelectorController", () => { + test("resolves auto initialization from the detected light or dark terminal mode", async () => { + const light = await renderThemeSelectorController({ + initialTheme: "auto", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }); + const dark = await renderThemeSelectorController({ + initialTheme: "auto", + initialThemeMode: "dark", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + expect(light.controller.themeId).toBe("github-light-default"); + expect(light.controller.baseTheme.appearance).toBe("light"); + expect(dark.controller.themeId).toBe("github-dark-default"); + expect(dark.controller.baseTheme.appearance).toBe("dark"); + } finally { + await destroyController(light.setup); + await destroyController(dark.setup); + } + }); + + test("opens on the committed theme and wraps keyboard preview movement", async () => { + const firstThemeId = availableThemes()[0]!.id; + const harness = await renderThemeSelectorController({ + initialTheme: firstThemeId, + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + await act(async () => harness.controller.openThemeSelector()); + expect(harness.controller.themeSelectorOpen).toBe(true); + expect( + harness.controller.themeSelectorItems[harness.controller.themeSelectorSelectedIndex]?.id, + ).toBe(firstThemeId); + + await act(async () => harness.controller.moveThemeSelector(-1)); + expect(harness.controller.themeSelectorSelectedIndex).toBe( + harness.controller.themeSelectorItems.length - 1, + ); + expect(harness.controller.themeId).toBe(firstThemeId); + expect(harness.controller.baseTheme.id).toBe( + harness.controller.themeSelectorItems.at(-1)!.id, + ); + expect(harness.controller.themeSelectorItems[0]?.active).toBe(false); + expect(harness.controller.themeSelectorItems.at(-1)?.active).toBe(true); + + await act(async () => harness.controller.moveThemeSelector(1)); + expect(harness.controller.themeSelectorSelectedIndex).toBe(0); + expect(harness.controller.baseTheme.id).toBe(firstThemeId); + } finally { + await destroyController(harness.setup); + } + }); + + test("pointer preview stays transient, cancel restores, and invalid item indexes are safe", async () => { + const harness = await renderThemeSelectorController({ + initialTheme: "github-dark-default", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + await act(async () => harness.controller.openThemeSelector()); + const previewIndex = 2; + const previewId = harness.controller.themeSelectorItems[previewIndex]!.id; + await act(async () => harness.controller.previewThemeSelectorItem(previewIndex)); + expect(harness.controller.baseTheme.id).toBe(previewId); + expect(harness.controller.themeId).toBe("github-dark-default"); + expect(harness.controller.themeSelectorItems[previewIndex]?.active).toBe(true); + + await act(async () => { + harness.controller.previewThemeSelectorItem(-1); + harness.controller.acceptThemeSelectorItem(Number.MAX_SAFE_INTEGER); + }); + expect(harness.controller.baseTheme.id).toBe(previewId); + expect(harness.controller.themeSelectorOpen).toBe(true); + + await act(async () => harness.controller.closeThemeSelector()); + expect(harness.controller.themeSelectorOpen).toBe(false); + expect(harness.controller.baseTheme.id).toBe("github-dark-default"); + expect(harness.controller.themeId).toBe("github-dark-default"); + } finally { + await destroyController(harness.setup); + } + }); + + test("pointer and keyboard acceptance commit atomically and preserve notices", async () => { + const notices: string[] = []; + const harness = await renderThemeSelectorController({ + initialTheme: "github-dark-default", + onTransientNotice: (notice) => notices.push(notice), + transparentBackground: false, + }); + + try { + await act(async () => harness.controller.openThemeSelector()); + const pointerIndex = 2; + const pointerItem = harness.controller.themeSelectorItems[pointerIndex]!; + await act(async () => harness.controller.previewThemeSelectorItem(pointerIndex)); + await act(async () => harness.controller.acceptThemeSelectorItem(pointerIndex)); + expect(harness.controller.themeSelectorOpen).toBe(false); + expect(harness.controller.themeId).toBe(pointerItem.id); + expect(harness.controller.baseTheme.id).toBe(pointerItem.id); + expect(notices).toEqual([`Theme: ${pointerItem.label}`]); + + await act(async () => harness.controller.openThemeSelector()); + await act(async () => harness.controller.moveThemeSelector(1)); + const keyboardItem = + harness.controller.themeSelectorItems[harness.controller.themeSelectorSelectedIndex]!; + await act(async () => harness.controller.acceptThemeSelector()); + expect(harness.controller.themeSelectorOpen).toBe(false); + expect(harness.controller.themeId).toBe(keyboardItem.id); + expect(harness.controller.baseTheme.id).toBe(keyboardItem.id); + expect(notices.at(-1)).toBe(`Theme: ${keyboardItem.label}`); + } finally { + await destroyController(harness.setup); + } + }); + + test("accepts movement queued in the same React batch", async () => { + const notices: string[] = []; + const harness = await renderThemeSelectorController({ + initialTheme: "github-dark-default", + onTransientNotice: (notice) => notices.push(notice), + transparentBackground: false, + }); + + try { + await act(async () => harness.controller.openThemeSelector()); + const initialIndex = harness.controller.themeSelectorSelectedIndex; + const nextIndex = (initialIndex + 1) % harness.controller.themeSelectorItems.length; + const nextItem = harness.controller.themeSelectorItems[nextIndex]!; + + await act(async () => { + harness.controller.moveThemeSelector(1); + harness.controller.acceptThemeSelector(); + }); + + expect(harness.controller.themeSelectorOpen).toBe(false); + expect(harness.controller.themeId).toBe(nextItem.id); + expect(harness.controller.baseTheme.id).toBe(nextItem.id); + expect(notices).toEqual([`Theme: ${nextItem.label}`]); + } finally { + await destroyController(harness.setup); + } + }); + + test("transparent backgrounds only project the resolved base-theme surfaces", async () => { + const custom = customTheme("team-dark", "Team Dark", "#8877cc"); + const harness = await renderThemeSelectorController({ + customThemes: [custom], + initialTheme: custom.id, + onTransientNotice: noNotice, + transparentBackground: true, + }); + + try { + expect(harness.controller.themeId).toBe(custom.id); + expect(harness.controller.baseTheme.id).toBe(custom.id); + expect(harness.controller.baseTheme.background).not.toBe(TRANSPARENT_BACKGROUND); + expect(harness.controller.activeTheme.background).toBe(TRANSPARENT_BACKGROUND); + expect(harness.controller.activeTheme.addedBg).toBe(harness.controller.baseTheme.addedBg); + } finally { + await destroyController(harness.setup); + } + }); + + test("catalog replacement re-resolves palettes while preserving valid selected identities", async () => { + const alpha = customTheme("alpha-theme", "Alpha", "#112233"); + const beta = customTheme("beta-theme", "Beta", "#445566"); + const harness = await renderThemeSelectorController({ + customThemes: [alpha, beta], + initialTheme: alpha.id, + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + await act(async () => harness.controller.openThemeSelector()); + const betaIndex = harness.controller.themeSelectorItems.findIndex( + (item) => item.id === beta.id, + ); + await act(async () => harness.controller.previewThemeSelectorItem(betaIndex)); + + const nextAlpha = customTheme(alpha.id, "Alpha updated", "#778899"); + const nextBeta = customTheme(beta.id, "Beta updated", "#aabbcc"); + await act(async () => + harness.replaceOptions({ + customThemes: [nextBeta, nextAlpha], + initialTheme: "github-light-default", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + + expect(harness.controller.themeId).toBe(alpha.id); + expect( + harness.controller.themeSelectorItems[harness.controller.themeSelectorSelectedIndex]?.id, + ).toBe(beta.id); + expect(harness.controller.baseTheme.id).toBe(beta.id); + expect(harness.controller.baseTheme.accent).toBe("#aabbcc"); + + await act(async () => + harness.replaceOptions({ + customThemes: [nextAlpha], + initialTheme: "github-light-default", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + expect(harness.controller.themeId).toBe(alpha.id); + expect(harness.controller.baseTheme.id).toBe(alpha.id); + expect(harness.controller.themeSelectorSelectedIndex).toBeGreaterThanOrEqual(0); + expect(harness.controller.themeSelectorSelectedIndex).toBeLessThan( + harness.controller.themeSelectorItems.length, + ); + + await act(async () => + harness.replaceOptions({ + customThemes: [], + initialTheme: "github-light-default", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + expect(harness.controller.themeId).toBe(alpha.id); + expect(harness.controller.baseTheme.id).toBe("github-dark-default"); + expect(harness.controller.themeSelectorSelectedIndex).toBeGreaterThanOrEqual(0); + expect(harness.controller.themeSelectorSelectedIndex).toBeLessThan( + harness.controller.themeSelectorItems.length, + ); + + await act(async () => + harness.replaceOptions({ + customThemes: [nextAlpha], + initialTheme: "github-light-default", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + expect(harness.controller.themeId).toBe(alpha.id); + expect(harness.controller.baseTheme.id).toBe(alpha.id); + expect(harness.controller.baseTheme.accent).toBe("#778899"); + } finally { + await destroyController(harness.setup); + } + }); + + test("soft bootstrap replacement preserves detected mode and the in-session choice", async () => { + const harness = await renderThemeSelectorController({ + initialTheme: "auto", + initialThemeMode: "dark", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + await act(async () => + harness.replaceOptions({ + initialTheme: "auto", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + expect(harness.controller.themeId).toBe("github-dark-default"); + + const draculaIndex = harness.controller.themeSelectorItems.findIndex( + (item) => item.id === "dracula", + ); + await act(async () => harness.controller.acceptThemeSelectorItem(draculaIndex)); + await act(async () => + harness.replaceOptions({ + initialTheme: "github-light-default", + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }), + ); + expect(harness.controller.themeId).toBe("dracula"); + expect(harness.controller.baseTheme.id).toBe("dracula"); + } finally { + await destroyController(harness.setup); + } + }); +}); diff --git a/src/ui/hooks/useThemeSelectorController.ts b/src/ui/hooks/useThemeSelectorController.ts new file mode 100644 index 000000000..7db6223da --- /dev/null +++ b/src/ui/hooks/useThemeSelectorController.ts @@ -0,0 +1,214 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TerminalThemeMode } from "../../core/theme/detection"; +import type { NamedCustomThemeConfig } from "../../extension-api/types"; +import type { ThemeSelectorItem } from "../components/chrome/ThemeSelectorDialog"; +import { availableThemes, resolveTheme, withTransparentSurfaces } from "../themes"; + +interface ThemeSelectorControllerState { + committedThemeId: string; + open: boolean; + previewThemeId: string | null; + selectedThemeId: string | null; +} + +export interface UseThemeSelectorControllerOptions { + customThemes?: readonly NamedCustomThemeConfig[]; + initialTheme?: string; + initialThemeMode?: TerminalThemeMode | null; + onTransientNotice: (text: string) => void; + transparentBackground: boolean; +} + +/** Drive theme resolution, committed selection, and transient selector previews. */ +export function useThemeSelectorController({ + customThemes, + initialTheme, + initialThemeMode, + onTransientNotice, + transparentBackground, +}: UseThemeSelectorControllerOptions) { + // Startup detection is launch state. Soft bootstrap reloads may replace the + // incoming record, but they must not reinterpret an in-session theme choice. + const [detectedThemeMode] = useState(initialThemeMode); + const [state, setState] = useState(() => ({ + committedThemeId: resolveTheme(initialTheme, initialThemeMode ?? null, customThemes).id, + open: false, + previewThemeId: null, + selectedThemeId: null, + })); + + const themeOptions = useMemo(() => availableThemes(customThemes), [customThemes]); + const committedTheme = useMemo( + () => resolveTheme(state.committedThemeId, detectedThemeMode ?? null, customThemes), + [customThemes, detectedThemeMode, state.committedThemeId], + ); + const committedIndex = themeOptions.findIndex((theme) => theme.id === committedTheme.id); + const storedSelectedIndex = themeOptions.findIndex((theme) => theme.id === state.selectedThemeId); + const selectedIndex = + storedSelectedIndex >= 0 ? storedSelectedIndex : committedIndex >= 0 ? committedIndex : 0; + const selectedThemeId = themeOptions[selectedIndex]?.id ?? null; + const selectedThemeIdRef = useRef(selectedThemeId); + selectedThemeIdRef.current = selectedThemeId; + + const previewThemeId = themeOptions.some((theme) => theme.id === state.previewThemeId) + ? state.previewThemeId + : null; + const baseTheme = useMemo( + () => + previewThemeId + ? resolveTheme(previewThemeId, detectedThemeMode ?? null, customThemes) + : committedTheme, + [committedTheme, customThemes, detectedThemeMode, previewThemeId], + ); + const activeTheme = useMemo( + () => (transparentBackground ? withTransparentSurfaces(baseTheme) : baseTheme), + [baseTheme, transparentBackground], + ); + const items = useMemo( + () => + themeOptions.map((theme) => ({ + id: theme.id, + label: theme.label, + description: theme.id === baseTheme.id ? "active" : "", + active: theme.id === baseTheme.id, + })), + [baseTheme.id, themeOptions], + ); + + // Catalog replacement can remove a selected or previewed custom theme. Clear + // those transient identities while retaining the raw committed preference. + useEffect(() => { + setState((current) => { + const availableIds = new Set(themeOptions.map((theme) => theme.id)); + const nextSelectedThemeId = + current.selectedThemeId !== null && availableIds.has(current.selectedThemeId) + ? current.selectedThemeId + : selectedThemeId; + const nextPreviewThemeId = + current.previewThemeId !== null && availableIds.has(current.previewThemeId) + ? current.previewThemeId + : null; + + if ( + current.selectedThemeId === nextSelectedThemeId && + current.previewThemeId === nextPreviewThemeId + ) { + return current; + } + + return { + ...current, + previewThemeId: nextPreviewThemeId, + selectedThemeId: nextSelectedThemeId, + }; + }); + }, [selectedThemeId, themeOptions]); + + /** Open the selector on the resolved committed theme without starting a preview. */ + const openThemeSelector = useCallback(() => { + selectedThemeIdRef.current = committedTheme.id; + setState((current) => ({ + ...current, + open: true, + previewThemeId: null, + selectedThemeId: committedTheme.id, + })); + }, [committedTheme.id]); + + /** Cancel the selector and restore the committed theme projection. */ + const closeThemeSelector = useCallback(() => { + setState((current) => ({ ...current, open: false, previewThemeId: null })); + }, []); + + /** Move the selector with wraparound and preview the resulting identity. */ + const moveThemeSelector = useCallback( + (delta: number) => { + if (themeOptions.length === 0) { + selectedThemeIdRef.current = null; + setState((current) => ({ + ...current, + previewThemeId: null, + selectedThemeId: null, + })); + return; + } + + const currentIndex = themeOptions.findIndex( + (theme) => theme.id === selectedThemeIdRef.current, + ); + const anchorIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + (((anchorIndex + delta) % themeOptions.length) + themeOptions.length) % themeOptions.length; + const nextThemeId = themeOptions[nextIndex]!.id; + selectedThemeIdRef.current = nextThemeId; + setState((current) => ({ + ...current, + previewThemeId: nextThemeId, + selectedThemeId: nextThemeId, + })); + }, + [themeOptions], + ); + + /** Preview one pointer-highlighted item without changing the committed identity. */ + const previewThemeSelectorItem = useCallback( + (index: number) => { + const item = themeOptions[index]; + if (!item) return; + + selectedThemeIdRef.current = item.id; + setState((current) => ({ + ...current, + previewThemeId: item.id, + selectedThemeId: item.id, + })); + }, + [themeOptions], + ); + + /** Commit one validated item and clear its preview without an intermediate theme. */ + const commitThemeSelectorItem = useCallback( + (item: (typeof themeOptions)[number]) => { + selectedThemeIdRef.current = item.id; + setState((current) => ({ + ...current, + committedThemeId: item.id, + open: false, + previewThemeId: null, + selectedThemeId: item.id, + })); + onTransientNotice(`Theme: ${item.label}`); + }, + [onTransientNotice], + ); + + /** Commit one pointer-selected item when its current catalog entry is valid. */ + const acceptThemeSelectorItem = useCallback( + (index: number) => { + const item = themeOptions[index]; + if (item) commitThemeSelectorItem(item); + }, + [commitThemeSelectorItem, themeOptions], + ); + + /** Commit the latest highlighted identity, including movement batched before acceptance. */ + const acceptThemeSelector = useCallback(() => { + const item = themeOptions.find((theme) => theme.id === selectedThemeIdRef.current); + if (item) commitThemeSelectorItem(item); + }, [commitThemeSelectorItem, themeOptions]); + + return { + activeTheme, + baseTheme, + themeId: state.committedThemeId, + themeSelectorItems: items, + themeSelectorOpen: state.open, + themeSelectorSelectedIndex: selectedIndex, + acceptThemeSelector, + acceptThemeSelectorItem, + closeThemeSelector, + moveThemeSelector, + openThemeSelector, + previewThemeSelectorItem, + }; +}