From 486e59f3dc692259c0e9e587829471015f6c28b7 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Fri, 17 Jul 2026 11:30:37 +1000 Subject: [PATCH 1/8] Add Markdown preview theme controls --- src-tauri/src/lib.rs | 32 ++++++++++++++++ src/App.test.tsx | 52 +++++++++++++++++++++++++- src/App.tsx | 44 +++++++++++++++++++++- src/MarkdownPreview.css | 72 ++++++++++++++++++++++++++++++++++++ src/MarkdownPreview.test.tsx | 40 ++++++++++++++++++++ src/MarkdownPreview.tsx | 36 +++++++++++++++++- src/tauri.test.ts | 13 +++++++ src/tauri.ts | 14 +++++++ 8 files changed, 299 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03f0b67..9ad76fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,8 @@ struct PersistedViewSettings { diff_view_mode: String, #[serde(default = "default_theme_preference")] theme_preference: String, + #[serde(default = "default_markdown_preview_theme_preference")] + markdown_preview_theme_preference: String, // Font stack for the editor + diff panes only; UI chrome always uses Space Grotesk. #[serde(default = "default_code_font")] code_font: String, @@ -247,6 +249,7 @@ impl Default for PersistedViewSettings { recent_relative_threshold: default_recent_relative_threshold(), diff_view_mode: default_diff_view_mode(), theme_preference: default_theme_preference(), + markdown_preview_theme_preference: default_markdown_preview_theme_preference(), code_font: default_code_font(), auto_fetch_seconds: default_auto_fetch_seconds(), feature_flags: BTreeMap::new(), @@ -410,6 +413,10 @@ fn default_theme_preference() -> String { DEFAULT_THEME_PREFERENCE.to_string() } +fn default_markdown_preview_theme_preference() -> String { + "auto".to_string() +} + fn default_code_font() -> String { DEFAULT_CODE_FONT.to_string() } @@ -471,6 +478,9 @@ fn sanitize_view_settings(mut settings: PersistedViewSettings) -> PersistedViewS if !KNOWN_THEME_PREFERENCES.contains(&settings.theme_preference.as_str()) { settings.theme_preference = default_theme_preference(); } + if !["auto", "light", "dark"].contains(&settings.markdown_preview_theme_preference.as_str()) { + settings.markdown_preview_theme_preference = default_markdown_preview_theme_preference(); + } if !KNOWN_CODE_FONTS.contains(&settings.code_font.as_str()) { settings.code_font = default_code_font(); } @@ -3670,6 +3680,7 @@ mod tests { recent_relative_threshold: "twoDays".to_string(), diff_view_mode: "sideBySide".to_string(), theme_preference: "dark".to_string(), + markdown_preview_theme_preference: "light".to_string(), code_font: "system-mono".to_string(), auto_fetch_seconds: 120, feature_flags: BTreeMap::new(), @@ -3707,6 +3718,7 @@ mod tests { assert_eq!(loaded.view.recent_relative_threshold, "twoDays"); assert_eq!(loaded.view.diff_view_mode, "sideBySide"); assert_eq!(loaded.view.theme_preference, "dark"); + assert_eq!(loaded.view.markdown_preview_theme_preference, "light"); assert_eq!(loaded.view.code_font, "system-mono"); assert_eq!(loaded.view.auto_fetch_seconds, 120); assert_eq!(loaded.workspaces.len(), 1); @@ -3802,6 +3814,26 @@ mod tests { assert_eq!(sanitized.code_font, "system-mono"); } + #[test] + fn markdown_preview_theme_defaults_and_rejects_unknown_values() { + assert_eq!( + PersistedViewSettings::default().markdown_preview_theme_preference, + "auto" + ); + + let sanitized = sanitize_view_settings(PersistedViewSettings { + markdown_preview_theme_preference: "sepia".to_string(), + ..PersistedViewSettings::default() + }); + assert_eq!(sanitized.markdown_preview_theme_preference, "auto"); + + let sanitized = sanitize_view_settings(PersistedViewSettings { + markdown_preview_theme_preference: "dark".to_string(), + ..PersistedViewSettings::default() + }); + assert_eq!(sanitized.markdown_preview_theme_preference, "dark"); + } + #[test] fn auto_fetch_seconds_defaults_and_clamps() { assert_eq!( diff --git a/src/App.test.tsx b/src/App.test.tsx index 18e2105..4013be3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1225,7 +1225,7 @@ describe("App shell interactions", () => { render(); expect(await treeButton("README.md")).toBeInTheDocument(); - await openSettingsDialog(); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); selectSettingsTab("Preview Features"); const toggle = await screen.findByLabelText("Context menus"); @@ -1331,6 +1331,56 @@ describe("App shell interactions", () => { expect(screen.queryByRole("heading", { name: "Unsaved" })).not.toBeInTheDocument(); }); + it("resolves auto preview theme inline and persists direct light/dark choices", async () => { + tauriMocks.getUiState.mockResolvedValue({ + view: { + showDotfiles: false, + showGeneratedInternal: false, + themePreference: "dark", + markdownPreviewThemePreference: "auto", + featureFlags: { markdownPreview: true }, + }, + workspace: { expandedFolders: [], openFiles: [] }, + }); + render(); + + fireEvent.click(await treeButton("README.md")); + fireEvent.click( + await screen.findByRole("button", { name: "Show Markdown preview" }), + ); + + const preview = await screen.findByRole("region", { name: "Preview README.md" }); + expect(preview).toHaveAttribute("data-preview-theme", "dark"); + expect( + screen.getByRole("button", { name: "Use dark Markdown preview theme" }), + ).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click( + screen.getByRole("button", { name: "Use light Markdown preview theme" }), + ); + expect(preview).toHaveAttribute("data-preview-theme", "light"); + await waitFor(() => + expect(tauriMocks.updateUiState).toHaveBeenLastCalledWith( + expect.objectContaining({ markdownPreviewThemePreference: "light" }), + expect.anything(), + ), + ); + + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectSettingsTab("Preview Features"); + const setting = await screen.findByRole("combobox", { + name: "Markdown preview theme", + }); + expect(setting).toHaveValue("light"); + fireEvent.change(setting, { target: { value: "auto" } }); + await waitFor(() => + expect(tauriMocks.updateUiState).toHaveBeenLastCalledWith( + expect.objectContaining({ markdownPreviewThemePreference: "auto" }), + expect.anything(), + ), + ); + }); + it("shows the Git category and persists a setting toggle", async () => { (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; render(); diff --git a/src/App.tsx b/src/App.tsx index fab7e5c..c80bd29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -143,6 +143,7 @@ import { revealInFileManager, sanitizeCodeFont, sanitizeDiffViewMode, + sanitizeMarkdownPreviewThemePreference, sanitizeThemePreference, searchIndexedFiles, searchFiles, @@ -158,9 +159,11 @@ import { writeFile, defaultCodeFont, defaultDiffViewMode, + defaultMarkdownPreviewThemePreference, defaultThemePreference, type CodeFont, type DiffViewMode, + type MarkdownPreviewThemePreference, type ThemePreference, type OpenLaunchRequest, type PersistedUiSnapshot, @@ -656,6 +659,8 @@ export default function App() { const [autoFetchSeconds, setAutoFetchSeconds] = useState(defaultAutoFetchSeconds); const [featureFlags, setFeatureFlags] = useState({}); const [markdownPreviewVisible, setMarkdownPreviewVisible] = useState(false); + const [markdownPreviewThemePreference, setMarkdownPreviewThemePreference] = + useState(defaultMarkdownPreviewThemePreference); const [prefersDark, setPrefersDark] = useState(systemPrefersDark); const [themePreference, setThemePreference] = useState(defaultThemePreference); @@ -773,6 +778,10 @@ export default function App() { // the editor/diff panes so every surface resolves the theme the same way. const effectiveDark = themePreference === "system" ? prefersDark : themePreference === "dark"; + const markdownPreviewDark = + markdownPreviewThemePreference === "auto" + ? effectiveDark + : markdownPreviewThemePreference === "dark"; // Live merge state, read straight off the polled Git status so the conflict UI // updates on its own as the user resolves files (rather than freezing on the // one-shot sync result). Defaults tolerate an older status shape / test mock. @@ -1238,6 +1247,11 @@ export default function App() { ); setDiffViewMode(sanitizeDiffViewMode(snapshot.view.diffViewMode)); setThemePreference(sanitizeThemePreference(snapshot.view.themePreference)); + setMarkdownPreviewThemePreference( + sanitizeMarkdownPreviewThemePreference( + snapshot.view.markdownPreviewThemePreference, + ), + ); setCodeFont(sanitizeCodeFont(snapshot.view.codeFont)); setAutoFetchSeconds(sanitizeAutoFetchSeconds(snapshot.view.autoFetchSeconds)); setFeatureFlags(sanitizeFeatureFlagOverrides(snapshot.view.featureFlags)); @@ -2591,6 +2605,7 @@ export default function App() { recentRelativeThreshold, diffViewMode, themePreference, + markdownPreviewThemePreference, codeFont, autoFetchSeconds, featureFlags, @@ -2643,6 +2658,7 @@ export default function App() { recentRelativeThreshold, diffViewMode, themePreference, + markdownPreviewThemePreference, codeFont, autoFetchSeconds, featureFlags, @@ -6083,8 +6099,12 @@ export default function App() { markdownPreviewAvailable ? ( { + setMarkdownPreviewThemePreference(dark ? "dark" : "light"); + setStatus(`Markdown preview set to ${dark ? "dark" : "light"}`); + }} path={activeFile.path} visible={markdownPreviewVisible} > @@ -7136,6 +7156,28 @@ export default function App() { ); })} + ) : null} diff --git a/src/MarkdownPreview.css b/src/MarkdownPreview.css index 8faabaa..cab370d 100644 --- a/src/MarkdownPreview.css +++ b/src/MarkdownPreview.css @@ -1,3 +1,75 @@ +.markdown-preview[data-preview-theme="light"] { + color-scheme: light; + --bg: var(--slate-50); + --surface: var(--slate-0); + --surface-2: var(--slate-100); + --border: var(--slate-150); + --border-strong: var(--slate-200); + --text: var(--slate-900); + --text-muted: var(--slate-500); + --accent: var(--current-700); + --accent-soft: var(--current-50); + --code-bg: var(--slate-100); + --danger: var(--rose-600); + --ring: var(--current-600); + --editor-bg: var(--bg); +} + +.markdown-preview[data-preview-theme="dark"] { + color-scheme: dark; + --bg: var(--slate-950); + --surface: #111a2e; + --surface-2: var(--slate-850); + --border: #1e293b; + --border-strong: #263250; + --text: #e8eef6; + --text-muted: var(--slate-400); + --accent: var(--current-400); + --accent-soft: #0c3444; + --code-bg: var(--slate-850); + --danger: var(--rose-500); + --ring: var(--current-400); + --editor-bg: var(--bg); +} + +.markdown-preview__theme-toggle { + position: sticky; + z-index: 1; + top: 12px; + display: flex; + width: max-content; + margin: -12px 0 -24px auto; + padding: 3px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 92%, transparent); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.markdown-preview__theme-toggle button { + display: grid; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + place-items: center; +} + +.markdown-preview__theme-toggle button[aria-pressed="true"] { + background: var(--accent-soft); + color: var(--accent); +} + +.markdown-preview__theme-toggle button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + .markdown-preview-split__handle:hover, .markdown-preview-split__handle:focus-visible { background: var(--accent-soft); diff --git a/src/MarkdownPreview.test.tsx b/src/MarkdownPreview.test.tsx index 7b03482..ca8b852 100644 --- a/src/MarkdownPreview.test.tsx +++ b/src/MarkdownPreview.test.tsx @@ -61,6 +61,46 @@ describe("MarkdownPreview", () => { expect(screen.getByLabelText("Editor")).toBe(editor); }); + it("shows the resolved theme and switches directly to the other theme", () => { + const onDarkChange = vi.fn(); + const { rerender } = render( + +