From 20be9ff646c93c40de1289b3d784f9ded6d71c71 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:03:00 +0700 Subject: [PATCH] fix(settings): stop a theme picked earlier staying previewed in the Settings window (#2343) The theme row only re-previewed a staged theme that differed from the saved one, and only when the list closed, so re-picking the saved theme quickly or pressing Reset left the earlier pick painted in the Settings window. It now shows its staged theme, or the saved one, whenever that changes and whenever the list closes. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- .../settings/__tests__/themePreview.test.tsx | 82 +++++++++++++++++++ src/features/settings/pages/behavior.tsx | 10 ++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/features/settings/__tests__/themePreview.test.tsx diff --git a/src/features/settings/__tests__/themePreview.test.tsx b/src/features/settings/__tests__/themePreview.test.tsx new file mode 100644 index 000000000..38f04e116 --- /dev/null +++ b/src/features/settings/__tests__/themePreview.test.tsx @@ -0,0 +1,82 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { applyThemeAttributes, currentThemeId, useThemeOverride } from "@/workbench/theme"; +import { THEME_PREVIEW_DELAY } from "@/workbench/quickSwitch"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, resetPage } from "../model/staging"; +import { PageContext } from "../components/context"; +import { BEHAVIOR_PAGES } from "../pages/behavior"; + +const root = document.documentElement; +const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null]; + +function renderThemeRow() { + const page = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!; + const option = page.sections!.flatMap((s) => s.options).find((o) => o.kind === "custom" && o.label === "Theme"); + if (option?.kind !== "custom") throw new Error("no Theme row"); + const Row = option.component; + return render( + + + , + ); +} + +const pick = (label: string) => { + fireEvent.click(screen.getByRole("button", { name: "Theme" })); + fireEvent.click(within(screen.getByRole("listbox")).getByText(label)); +}; + +beforeEach(() => { + vi.useFakeTimers(); + useThemeOverride.setState({ theme: null }); + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); + applyThemeAttributes(currentThemeId()); + openSettings("appearance"); +}); + +afterEach(() => { + vi.useRealTimers(); + useThemeOverride.setState({ theme: null }); + applyThemeAttributes(currentThemeId()); +}); + +describe("Settings › Appearance › Theme preview", () => { + it("shows a picked theme in this window until Apply", () => { + renderThemeRow(); + pick("Solarized Dark"); + expect(shown()).toEqual(["dark", "solarized-dark"]); + }); + + it("goes back to the selected theme when it is picked again before the hover preview shows", () => { + renderThemeRow(); + expect(shown()).toEqual(["light", null]); + pick("Solarized Dark"); + expect(shown()).toEqual(["dark", "solarized-dark"]); + // Clicked quicker than the hover delay: the list itself never previewed anything. + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY / 2)); + pick("Islands Light"); + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY)); + expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light"); + expect(shown()).toEqual(["light", null]); + }); + + it("goes back to the selected theme after hovering it long enough", () => { + renderThemeRow(); + pick("Solarized Dark"); + fireEvent.click(screen.getByRole("button", { name: "Theme" })); + fireEvent.pointerEnter(within(screen.getByRole("listbox")).getByText("Islands Light")); + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY)); + fireEvent.click(within(screen.getByRole("listbox")).getByText("Islands Light")); + expect(shown()).toEqual(["light", null]); + }); + + it("goes back to the selected theme when the page is reset", () => { + renderThemeRow(); + pick("Solarized Dark"); + act(() => resetPage("appearance")); + expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light"); + expect(shown()).toEqual(["light", null]); + }); +}); diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 23a4ff5be..f7a88d0a0 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -53,6 +53,9 @@ function Indented({ children }: { children: ReactNode }) { // --------------------------------------------------------------------------- // Appearance +/** Shows the theme picked in Settings in this window until Apply, or the selected one again. */ +const showPicked = (id: ConcreteTheme) => previewTheme(id !== currentThemeChoice() ? id : null); + function ThemeRow() { const [theme, setTheme] = useSetting("appearance.theme"); const [sync, setSync] = useSetting("appearance.syncWithOs"); @@ -72,11 +75,16 @@ function ThemeRow() { }, [], ); + // The staged theme can also change without the list, e.g. by Reset. + useEffect(() => { + if (previewed.current) showPicked(shown); + }, [shown]); const preview = (id: ConcreteTheme | null) => { previewed.current = true; if (id !== null) return picker.preview(id); + // The picker takes back only its own hover preview, not a theme picked here earlier. picker.restore(); - if (shown !== currentThemeChoice()) previewTheme(shown); + showPicked(shown); }; return (