Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,36 +1,92 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@superset/ui/select";
import { cn } from "@superset/ui/utils";
import { HiCheck } from "react-icons/hi2";
import { darkTheme, lightTheme } from "shared/themes";
import { useThemeStore } from "renderer/stores";
import { builtInThemes, type Theme } from "shared/themes";

interface SystemThemeCardProps {
isSelected: boolean;
onSelect: () => void;
}

function ThemeMappingSelect({
label,
value,
themes,
onChange,
}: {
label: string;
value: string;
themes: Theme[];
onChange: (themeId: string) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<Select value={value} onValueChange={onChange}>
<SelectTrigger size="sm" className="h-7 text-xs min-w-0 max-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{theme.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

export function SystemThemeCard({
isSelected,
onSelect,
}: SystemThemeCardProps) {
const darkTerminal = darkTheme.terminal;
const lightTerminal = lightTheme.terminal;
const customThemes = useThemeStore((state) => state.customThemes);
const systemDarkThemeId = useThemeStore((state) => state.systemDarkThemeId);
const systemLightThemeId = useThemeStore((state) => state.systemLightThemeId);
const setSystemThemeMapping = useThemeStore(
(state) => state.setSystemThemeMapping,
);

const allThemes = [...builtInThemes, ...customThemes];
const darkThemes = allThemes.filter((t) => t.type === "dark");
const lightThemes = allThemes.filter((t) => t.type === "light");

const darkPreviewTheme =
allThemes.find((t) => t.id === systemDarkThemeId) ?? darkThemes[0];
const lightPreviewTheme =
allThemes.find((t) => t.id === systemLightThemeId) ?? lightThemes[0];

const darkTerminal = darkPreviewTheme?.terminal;
const lightTerminal = lightPreviewTheme?.terminal;

if (!darkTerminal || !lightTerminal) {
return null;
}

return (
<button
type="button"
onClick={onSelect}
<div
className={cn(
"relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left",
isSelected
? "border-primary ring-2 ring-primary/20"
: "border-border hover:border-muted-foreground/50",
)}
>
{/* Theme Preview - Split view */}
<div className="h-28 flex overflow-hidden">
{/* Theme Preview - Split view (clickable) */}
<button
type="button"
onClick={onSelect}
className="h-28 flex overflow-hidden cursor-pointer"
>
{/* Dark half */}
<div
className="flex-1 p-3 flex flex-col justify-between"
Expand Down Expand Up @@ -110,10 +166,14 @@ export function SystemThemeCard({
)}
</div>
</div>
</div>
</button>

{/* Theme Info */}
<div className="p-3 bg-card border-t flex items-center justify-between">
<button
type="button"
onClick={onSelect}
className="p-3 bg-card border-t flex items-center justify-between cursor-pointer"
>
<div>
<div className="text-sm font-medium">System</div>
<div className="text-xs text-muted-foreground">
Expand All @@ -125,7 +185,25 @@ export function SystemThemeCard({
<HiCheck className="h-3 w-3 text-primary-foreground" />
</div>
)}
</div>
</button>
</button>

{/* Theme mapping selectors (only shown when selected) */}
{isSelected && (
<div className="px-3 pb-3 bg-card space-y-2">
<ThemeMappingSelect
label="Dark"
value={systemDarkThemeId}
themes={darkThemes}
onChange={(id) => setSystemThemeMapping("dark", id)}
/>
<ThemeMappingSelect
label="Light"
value={systemLightThemeId}
themes={lightThemes}
onChange={(id) => setSystemThemeMapping("light", id)}
/>
</div>
)}
</div>
);
}
153 changes: 153 additions & 0 deletions apps/desktop/src/renderer/stores/theme/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";

// Mock matchMedia before importing the store
const mockMatchMedia = mock(() => ({
matches: true, // default: OS prefers dark
addEventListener: mock(() => {}),
removeEventListener: mock(() => {}),
}));

// @ts-expect-error - mocking global
globalThis.window = {
matchMedia: mockMatchMedia,
};

// Mock localStorage
const mockStorage = new Map<string, string>();
// @ts-expect-error - mocking global
globalThis.localStorage = {
getItem: (key: string) => mockStorage.get(key) ?? null,
setItem: (key: string, value: string) => mockStorage.set(key, value),
removeItem: (key: string) => mockStorage.delete(key),
clear: () => mockStorage.clear(),
};

// Mock document for applyUIColors / updateThemeClass
// @ts-expect-error - mocking global
globalThis.document = {
documentElement: {
style: { setProperty: mock(() => {}) },
classList: {
add: mock(() => {}),
remove: mock(() => {}),
},
},
};

// Mock the trpc-storage module to use a simple in-memory storage
mock.module("../../lib/trpc-storage", () => ({
trpcThemeStorage: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
}));

const { useThemeStore, SYSTEM_THEME_ID } = await import("./store");

function setOsPrefersDark(prefersDark: boolean) {
mockMatchMedia.mockReturnValue({
matches: prefersDark,
addEventListener: mock(() => {}),
removeEventListener: mock(() => {}),
});
}

describe("theme store - system theme mapping", () => {
beforeEach(() => {
mockStorage.clear();
setOsPrefersDark(true);

// Reset store to defaults
useThemeStore.setState({
activeThemeId: "dark",
systemDarkThemeId: "dark",
systemLightThemeId: "light",
customThemes: [],
activeTheme: null,
terminalTheme: null,
});
});

it("has correct default system theme mappings", () => {
const state = useThemeStore.getState();
expect(state.systemDarkThemeId).toBe("dark");
expect(state.systemLightThemeId).toBe("light");
});

it("resolves system theme to dark mapping when OS prefers dark", () => {
setOsPrefersDark(true);
useThemeStore.getState().setTheme(SYSTEM_THEME_ID);

const state = useThemeStore.getState();
expect(state.activeThemeId).toBe(SYSTEM_THEME_ID);
expect(state.activeTheme?.id).toBe("dark");
});

it("resolves system theme to light mapping when OS prefers light", () => {
setOsPrefersDark(false);
useThemeStore.getState().setTheme(SYSTEM_THEME_ID);

const state = useThemeStore.getState();
expect(state.activeThemeId).toBe(SYSTEM_THEME_ID);
expect(state.activeTheme?.id).toBe("light");
});

it("resolves system theme to custom dark mapping", () => {
// Add a custom dark theme (monokai is built-in and dark)
setOsPrefersDark(true);

// Set monokai as the dark mapping
useThemeStore.getState().setSystemThemeMapping("dark", "monokai");
expect(useThemeStore.getState().systemDarkThemeId).toBe("monokai");

// Select system theme
useThemeStore.getState().setTheme(SYSTEM_THEME_ID);

const state = useThemeStore.getState();
expect(state.activeThemeId).toBe(SYSTEM_THEME_ID);
expect(state.activeTheme?.id).toBe("monokai");
});

it("setSystemThemeMapping re-applies theme when system is active", () => {
setOsPrefersDark(true);

// First select system theme (resolves to "dark")
useThemeStore.getState().setTheme(SYSTEM_THEME_ID);
expect(useThemeStore.getState().activeTheme?.id).toBe("dark");

// Change dark mapping to monokai — should re-apply immediately
useThemeStore.getState().setSystemThemeMapping("dark", "monokai");
expect(useThemeStore.getState().activeTheme?.id).toBe("monokai");
});

it("setSystemThemeMapping does not re-apply when system is not active", () => {
// Set a specific theme (not system)
useThemeStore.getState().setTheme("dark");
expect(useThemeStore.getState().activeTheme?.id).toBe("dark");

// Change mapping — should NOT change active theme
useThemeStore.getState().setSystemThemeMapping("dark", "monokai");
expect(useThemeStore.getState().systemDarkThemeId).toBe("monokai");
expect(useThemeStore.getState().activeTheme?.id).toBe("dark");
});

it("removeCustomTheme resets system mapping to built-in", () => {
const customTheme = {
id: "catppuccin",
name: "Catppuccin",
type: "dark" as const,
ui: {} as never,
};
useThemeStore.getState().upsertCustomThemes([customTheme]);
useThemeStore.getState().setSystemThemeMapping("dark", "catppuccin");

expect(useThemeStore.getState().systemDarkThemeId).toBe("catppuccin");

// Remove the custom theme
useThemeStore.getState().removeCustomTheme("catppuccin");

// Should reset to built-in "dark"
expect(useThemeStore.getState().systemDarkThemeId).toBe("dark");
});
});
Loading