feat: vault-native theming system with live reload (#154)

* feat: add theming system design file with 3 frames

Design frames for Settings > Appearance panel, Command Palette
theme switching, and live theme editing flow visualization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Rust backend for vault-native theming system

Theme files live in _themes/*.json with colors, typography, and spacing
sections. Vault-level settings (.laputa/settings.json) store the active
theme. Built-in themes (default, dark, minimal) are seeded on vault
creation. All 6 Tauri commands registered: list_themes, get_theme,
get_vault_settings, save_vault_settings, set_active_theme, create_theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add frontend theme engine, settings UI, and command palette integration

Wire up useThemeManager hook to load themes via Tauri IPC, apply CSS
custom properties to :root, and support switching/creating themes.
Add Appearance section to Settings panel with color swatches and theme
cards. Register theme switch and create commands in the command registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use .to_string() instead of format! for string literal (clippy)

* style: cargo fmt on theme.rs

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-28 23:05:14 +01:00
committed by GitHub
parent 06e42b182e
commit cae821ef4c
15 changed files with 1183 additions and 53 deletions

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,7 @@ pub mod mcp;
pub mod menu;
pub mod search;
pub mod settings;
pub mod theme;
pub mod vault;
use std::borrow::Cow;
@@ -19,6 +20,7 @@ use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
use vault::{RenameResult, VaultEntry};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
@@ -289,6 +291,42 @@ async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
fn set_active_theme(vault_path: String, theme_id: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, &theme_id)
}
#[tauri::command]
fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -443,7 +481,13 @@ pub fn run() {
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,
register_mcp_tools
register_mcp_tools,
list_themes,
get_theme,
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

441
src-tauri/src/theme.rs Normal file
View File

@@ -0,0 +1,441 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// A theme file parsed from _themes/*.json in the vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeFile {
/// Filename stem (e.g. "default" for _themes/default.json)
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub colors: HashMap<String, String>,
#[serde(default)]
pub typography: HashMap<String, String>,
#[serde(default)]
pub spacing: HashMap<String, String>,
}
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultSettings {
#[serde(default)]
pub theme: Option<String>,
}
/// List all theme files in _themes/ directory of the vault.
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
let entries =
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match parse_theme_file(&path) {
Ok(theme) => themes.push(theme),
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
}
}
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(themes)
}
/// Parse a single theme JSON file.
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "Invalid theme filename".to_string())?;
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
theme.id = id;
Ok(theme)
}
/// Read vault-level settings from .laputa/settings.json.
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
if !settings_path.exists() {
return Ok(VaultSettings::default());
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
}
/// Save vault-level settings to .laputa/settings.json.
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
let laputa_dir = Path::new(vault_path).join(".laputa");
fs::create_dir_all(&laputa_dir)
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
fs::write(laputa_dir.join("settings.json"), json)
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings.
pub fn set_active_theme(vault_path: &str, theme_id: &str) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = Some(theme_id.to_string());
save_vault_settings(vault_path, settings)
}
/// Read a single theme file by ID from the vault's _themes/ directory.
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
let path = Path::new(vault_path)
.join("_themes")
.join(format!("{theme_id}.json"));
if !path.exists() {
return Err(format!("Theme not found: {theme_id}"));
}
parse_theme_file(&path)
}
/// Create a new theme file by copying the active theme (or default).
/// Returns the ID of the new theme.
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_id(&themes_dir, "untitled");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
let content = if source_path.exists() {
let mut theme: serde_json::Value = serde_json::from_str(
&fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read source theme: {e}"))?,
)
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
if let Some(obj) = theme.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String("Untitled Theme".to_string()),
);
}
serde_json::to_string_pretty(&theme)
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
} else {
default_theme_json("Untitled Theme")
};
fs::write(themes_dir.join(format!("{new_id}.json")), content)
.map_err(|e| format!("Failed to write new theme: {e}"))?;
Ok(new_id)
}
/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...).
fn find_available_id(dir: &Path, base: &str) -> String {
if !dir.join(format!("{base}.json")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.json")).exists() {
return candidate;
}
}
unreachable!()
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"description": "Custom theme",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"sidebar-background": "#F7F6F3",
"accent": "#155DFF",
"muted": "#787774",
"border": "#E9E9E7"
},
"typography": {
"font-family": "system-ui",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "240px"
}
}))
.unwrap()
}
/// Content for the built-in default (light) theme.
pub const DEFAULT_THEME: &str = r##"{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in dark theme.
pub const DARK_THEME: &str = r##"{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in minimal theme.
pub const MINIMAL_THEME: &str = r##"{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}"##;
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_list_themes_returns_sorted_list() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].id, "default");
}
#[test]
fn test_list_themes_empty_when_no_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("empty-vault");
fs::create_dir_all(&vault).unwrap();
let themes = list_themes(vault.to_str().unwrap()).unwrap();
assert!(themes.is_empty());
}
#[test]
fn test_get_theme_by_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let theme = get_theme(&vault, "default").unwrap();
assert_eq!(theme.name, "Default");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_get_theme_not_found() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let result = get_theme(&vault, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_vault_settings_roundtrip() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
// Default settings have no theme
let settings = get_vault_settings(vp).unwrap();
assert!(settings.theme.is_none());
// Set and read back
set_active_theme(vp, "dark").unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
}
#[test]
fn test_vault_settings_creates_laputa_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join(".laputa").exists());
save_vault_settings(
vp,
VaultSettings {
theme: Some("light".into()),
},
)
.unwrap();
assert!(vault.join(".laputa").join("settings.json").exists());
}
#[test]
fn test_create_theme_copies_source() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let new_id = create_theme(&vault, Some("default")).unwrap();
assert_eq!(new_id, "untitled");
let theme = get_theme(&vault, &new_id).unwrap();
assert_eq!(theme.name, "Untitled Theme");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_create_theme_increments_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let id1 = create_theme(&vault, None).unwrap();
assert_eq!(id1, "untitled");
let id2 = create_theme(&vault, None).unwrap();
assert_eq!(id2, "untitled-2");
}
#[test]
fn test_parse_all_builtin_themes() {
for (name, content) in [
("default", DEFAULT_THEME),
("dark", DARK_THEME),
("minimal", MINIMAL_THEME),
] {
let theme: ThemeFile = serde_json::from_str(content)
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
assert!(!theme.name.is_empty(), "{name} theme should have a name");
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
}
}
#[test]
fn test_list_themes_ignores_non_json_files() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2); // only default and dark
}
#[test]
fn test_list_themes_skips_malformed_json() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2); // broken.json is skipped
}
}

View File

@@ -394,6 +394,17 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
// Seed built-in themes
let themes_dir = vault_dir.join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME)
.map_err(|e| format!("Failed to write default theme: {e}"))?;
fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME)
.map_err(|e| format!("Failed to write dark theme: {e}"))?;
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
crate::git::init_repo(target_path)?;
Ok(vault_dir
@@ -566,6 +577,20 @@ mod tests {
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_create_getting_started_vault_seeds_themes() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("theme-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
assert!(vault_path.join("_themes/default.json").exists());
assert!(vault_path.join("_themes/dark.json").exists());
assert!(vault_path.join("_themes/minimal.json").exists());
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
}
#[test]
fn test_create_getting_started_vault_no_untracked_files() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -59,21 +59,24 @@ const mockAllContent: Record<string, string> = {
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
}
const mockCommandResults: Record<string, unknown> = {
list_vault: mockEntries,
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
get_file_history: [],
get_settings: { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null },
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Laputa',
list_themes: [],
get_vault_settings: { theme: null },
}
vi.mock('./mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(async (cmd: string) => {
if (cmd === 'list_vault') return mockEntries
if (cmd === 'get_all_content') return mockAllContent
if (cmd === 'get_modified_files') return []
if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || ''
if (cmd === 'get_file_history') return []
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null }
if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }
if (cmd === 'save_settings') return null
if (cmd === 'check_vault_exists') return true
if (cmd === 'get_default_vault_path') return '/Users/mock/Documents/Laputa'
return null
}),
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
}))

View File

@@ -30,6 +30,7 @@ import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useZoom } from './hooks/useZoom'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { UpdateBanner } from './components/UpdateBanner'
import { setApiKey } from './utils/ai-chat'
import { extractOutgoingLinks } from './utils/wikilinks'
@@ -122,6 +123,7 @@ function App() {
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath)
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
useMcpRegistration(resolvedPath, setToastMessage)
@@ -283,6 +285,8 @@ function App() {
onSelectNote: notes.handleSelectNote,
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme, onCreateTheme: () => themeManager.createTheme(),
})
const { status: updateStatus, actions: updateActions } = useUpdater()
@@ -387,7 +391,7 @@ function App() {
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
<GitHubVaultModal
open={dialogs.showGitHubVault}
githubToken={settings.github_token}

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
// Mock the tauri/mock-tauri calls used by GitHubSection
const mockInvokeFn = vi.fn()
@@ -35,6 +36,15 @@ const populatedSettings: Settings = {
auto_pull_interval_minutes: 5,
}
const mockThemeManager: ThemeManager = {
themes: [],
activeThemeId: null,
activeTheme: null,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue('untitled'),
reloadThemes: vi.fn(),
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
@@ -45,14 +55,14 @@ describe('SettingsPanel', () => {
it('renders nothing when not open', () => {
const { container } = render(
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(container.innerHTML).toBe('')
})
it('renders modal when open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('AI Provider Keys')).toBeInTheDocument()
@@ -61,7 +71,7 @@ describe('SettingsPanel', () => {
it('shows three key fields with labels', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByText('Anthropic')).toBeInTheDocument()
expect(screen.getByText('OpenAI')).toBeInTheDocument()
@@ -70,7 +80,7 @@ describe('SettingsPanel', () => {
it('populates fields from settings', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
@@ -83,7 +93,7 @@ describe('SettingsPanel', () => {
it('calls onSave with trimmed keys on save', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const anthropicInput = screen.getByTestId('settings-key-anthropic')
fireEvent.change(anthropicInput, { target: { value: ' sk-ant-test ' } })
@@ -103,7 +113,7 @@ describe('SettingsPanel', () => {
it('converts empty/whitespace keys to null', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
// Clear the anthropic key field
const anthropicInput = screen.getByTestId('settings-key-anthropic')
@@ -123,7 +133,7 @@ describe('SettingsPanel', () => {
it('calls onClose when Cancel is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalled()
@@ -131,7 +141,7 @@ describe('SettingsPanel', () => {
it('calls onClose when close button is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTitle('Close settings'))
expect(onClose).toHaveBeenCalled()
@@ -139,7 +149,7 @@ describe('SettingsPanel', () => {
it('calls onClose on Escape key', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
@@ -147,7 +157,7 @@ describe('SettingsPanel', () => {
it('saves on Cmd+Enter', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const anthropicInput = screen.getByTestId('settings-key-anthropic')
fireEvent.change(anthropicInput, { target: { value: 'sk-ant-test' } })
@@ -165,7 +175,7 @@ describe('SettingsPanel', () => {
it('calls onClose when clicking backdrop', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('settings-panel'))
expect(onClose).toHaveBeenCalled()
@@ -173,7 +183,7 @@ describe('SettingsPanel', () => {
it('clears a key field when X button is clicked', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const clearBtn = screen.getByTestId('clear-anthropic')
fireEvent.click(clearBtn)
@@ -184,14 +194,14 @@ describe('SettingsPanel', () => {
it('shows keyboard shortcut hint in footer', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
})
it('resets fields when reopened with different settings', () => {
const { rerender } = render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
// Verify initial state
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
@@ -199,11 +209,11 @@ describe('SettingsPanel', () => {
// Close and reopen with different settings
rerender(
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const newSettings: Settings = { ...emptySettings, anthropic_key: 'new-key' }
rerender(
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const updatedInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
expect(updatedInput.value).toBe('new-key')
@@ -212,7 +222,7 @@ describe('SettingsPanel', () => {
describe('GitHub OAuth section', () => {
it('shows Login with GitHub button when not connected', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByTestId('github-login')).toBeInTheDocument()
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
@@ -220,7 +230,7 @@ describe('SettingsPanel', () => {
it('does not show GitHub token input field', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument()
@@ -233,7 +243,7 @@ describe('SettingsPanel', () => {
github_username: 'lucaong',
}
render(
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByTestId('github-connected')).toBeInTheDocument()
expect(screen.getByText('lucaong')).toBeInTheDocument()
@@ -248,7 +258,7 @@ describe('SettingsPanel', () => {
github_username: 'lucaong',
}
render(
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('github-disconnect'))
@@ -277,7 +287,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -308,7 +318,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -329,7 +339,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -342,7 +352,7 @@ describe('SettingsPanel', () => {
it('shows GitHub section description about connecting', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
})
@@ -357,7 +367,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -379,7 +389,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
)
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement

View File

@@ -1,13 +1,15 @@
import { useState, useRef, useCallback } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { Settings } from '../types'
import type { Settings, ThemeFile } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
interface SettingsPanelProps {
open: boolean
settings: Settings
onSave: (settings: Settings) => void
onClose: () => void
themeManager: ThemeManager
}
@@ -113,12 +115,12 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
// --- Settings Panel ---
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
export function SettingsPanel({ open, settings, onSave, onClose, themeManager }: SettingsPanelProps) {
if (!open) return null
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} themeManager={themeManager} />
}
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<SettingsPanelProps, 'open'>) {
const [anthropicKey, setAnthropicKey] = useState(settings.anthropic_key ?? '')
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
@@ -183,6 +185,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
themeManager={themeManager}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
</div>
@@ -216,6 +219,7 @@ interface SettingsBodyProps {
onGitHubConnected: (token: string, username: string) => void
onGitHubDisconnect: () => void
pullInterval: number; setPullInterval: (v: number) => void
themeManager: ThemeManager
}
function SettingsBody(props: SettingsBodyProps) {
@@ -274,10 +278,81 @@ function SettingsBody(props: SettingsBodyProps) {
<option value={30}>30</option>
</select>
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
<AppearanceSection themeManager={props.themeManager} />
</div>
)
}
// --- Appearance Section ---
function ColorSwatch({ color }: { color: string }) {
return (
<div
style={{ width: 14, height: 14, borderRadius: 3, background: color, border: '1px solid var(--border)', flexShrink: 0 }}
/>
)
}
function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) {
const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted']
return (
<button
className="border rounded cursor-pointer text-left"
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', width: '100%',
background: active ? 'var(--accent)' : 'transparent',
borderColor: active ? 'var(--primary)' : 'var(--border)',
}}
onClick={onSelect}
type="button"
data-testid={`theme-card-${theme.id}`}
>
<div style={{ display: 'flex', gap: 3 }}>
{swatchColors.map(key => theme.colors[key] && <ColorSwatch key={key} color={theme.colors[key]} />)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{theme.name}</div>
{theme.description && (
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{theme.description}</div>
)}
</div>
{active && <Check size={14} weight="bold" style={{ color: 'var(--primary)', flexShrink: 0 }} />}
</button>
)
}
function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
const { themes, activeThemeId, switchTheme, createTheme } = themeManager
return (
<>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Appearance</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Choose a theme for your vault. Themes are stored in <code>_themes/</code> and synced with Git.
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }} data-testid="theme-list">
{themes.map(theme => (
<ThemeCard key={theme.id} theme={theme} active={theme.id === activeThemeId} onSelect={() => switchTheme(theme.id)} />
))}
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
onClick={() => createTheme(activeThemeId ?? undefined)}
type="button"
data-testid="create-theme"
>
<Plus size={14} />
New Theme
</button>
</>
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
return (
<div

View File

@@ -3,7 +3,7 @@ import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { SidebarSelection, VaultEntry } from '../types'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { ViewMode } from './useViewMode'
interface Tab { entry: VaultEntry; content: string }
@@ -43,6 +43,10 @@ interface AppCommandsConfig {
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
themes?: ThemeFile[]
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -107,6 +111,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
canGoForward: config.canGoForward,
themes: config.themes,
activeThemeId: config.activeThemeId,
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
})
useKeyboardNavigation({

View File

@@ -261,6 +261,78 @@ describe('useCommandRegistry', () => {
expect(eventCmds).toHaveLength(1)
})
})
describe('theme commands', () => {
const themeFixtures = [
{ id: 'default', name: 'Default', description: '', colors: {}, typography: {}, spacing: {} },
{ id: 'dark', name: 'Dark', description: '', colors: {}, typography: {}, spacing: {} },
]
it('generates switch-theme commands for each theme', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
const switchDefault = result.current.find(c => c.id === 'switch-theme-default')
const switchDark = result.current.find(c => c.id === 'switch-theme-dark')
expect(switchDefault).toBeDefined()
expect(switchDefault!.label).toBe('Switch to Default Theme')
expect(switchDefault!.group).toBe('Appearance')
expect(switchDark).toBeDefined()
expect(switchDark!.label).toBe('Switch to Dark Theme')
})
it('disables switch command for the currently active theme', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
expect(result.current.find(c => c.id === 'switch-theme-default')!.enabled).toBe(false)
expect(result.current.find(c => c.id === 'switch-theme-dark')!.enabled).toBe(true)
})
it('calls onSwitchTheme when switch command executes', () => {
const onSwitchTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme,
})))
result.current.find(c => c.id === 'switch-theme-dark')!.execute()
expect(onSwitchTheme).toHaveBeenCalledWith('dark')
})
it('includes new-theme command when onCreateTheme is provided', () => {
const onCreateTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onCreateTheme,
})))
const newTheme = result.current.find(c => c.id === 'new-theme')
expect(newTheme).toBeDefined()
expect(newTheme!.group).toBe('Appearance')
expect(newTheme!.enabled).toBe(true)
})
it('omits new-theme command when onCreateTheme is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default',
})))
expect(result.current.find(c => c.id === 'new-theme')).toBeUndefined()
})
it('calls onCreateTheme when new-theme executes', () => {
const onCreateTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onCreateTheme,
})))
result.current.find(c => c.id === 'new-theme')!.execute()
expect(onCreateTheme).toHaveBeenCalled()
})
it('includes Appearance in command groups', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
const groups = new Set(result.current.map(c => c.group))
expect(groups).toContain('Appearance')
})
})
})
describe('pluralizeType', () => {
@@ -345,6 +417,7 @@ describe('groupSortKey', () => {
expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note'))
expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git'))
expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View'))
expect(groupSortKey('View')).toBeLessThan(groupSortKey('Settings'))
expect(groupSortKey('View')).toBeLessThan(groupSortKey('Appearance'))
expect(groupSortKey('Appearance')).toBeLessThan(groupSortKey('Settings'))
})
})

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react'
import type { SidebarSelection, VaultEntry } from '../types'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { ViewMode } from './useViewMode'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings'
export interface CommandAction {
id: string
@@ -40,6 +40,10 @@ interface CommandRegistryConfig {
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
themes?: ThemeFile[]
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
}
const PLURAL_OVERRIDES: Record<string, string> = {
@@ -65,7 +69,7 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] {
return Array.from(typeSet).sort()
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Appearance', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
@@ -94,6 +98,29 @@ export function buildTypeCommands(
})
}
export function buildThemeCommands(
themes: ThemeFile[] | undefined,
activeThemeId: string | null | undefined,
onSwitchTheme: ((themeId: string) => void) | undefined,
onCreateTheme: (() => void) | undefined,
): CommandAction[] {
const switchCmds = (themes ?? []).map(t => ({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
}))
if (onCreateTheme) {
switchCmds.push({
id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup,
keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme,
})
}
return switchCmds
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
@@ -103,6 +130,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
} = config
const hasActiveNote = activeTabPath !== null
@@ -151,6 +179,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
@@ -167,6 +198,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme,
])
}

View File

@@ -0,0 +1,245 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { ThemeFile, VaultSettings } from '../types'
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Clean default theme',
colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: { 'sidebar-width': '240px' },
},
{
id: 'dark', name: 'Dark', description: 'Dark theme',
colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: {},
},
]
const mockSettings: VaultSettings = { theme: 'default' }
const mockInvokeFn = vi.fn(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
return null
})
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
// Must import after mocks
const { useThemeManager } = await import('./useThemeManager')
describe('useThemeManager', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
return null
})
// Clear any theme CSS properties from previous tests
const root = document.documentElement
root.style.cssText = ''
})
it('loads themes and active theme on mount', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
expect(result.current.activeThemeId).toBe('default')
expect(result.current.activeTheme?.name).toBe('Default')
})
it('returns empty state when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
// Give it time to settle — should remain empty
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
expect(result.current.activeTheme).toBeNull()
expect(mockInvokeFn).not.toHaveBeenCalled()
})
it('applies CSS custom properties for active theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--primary')).toBe('#6366F1')
expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif')
expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px')
})
it('maps sidebar-background to --sidebar', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC')
})
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vault_path: '/vault', theme_id: 'dark' })
expect(result.current.activeThemeId).toBe('dark')
})
it('clears old theme CSS and applies new theme on switch', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
await act(async () => {
await result.current.switchTheme('dark')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--background')).toBe('#0F0F23')
})
expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0')
})
it('createTheme calls create_theme and reloads themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('new-theme-id')
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vault_path: '/vault', source_id: 'default' })
// Should reload after creation
const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes')
expect(listCalls.length).toBeGreaterThanOrEqual(2)
})
it('createTheme passes null source_id when no sourceId provided', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.createTheme()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vault_path: '/vault', source_id: null })
})
it('handles load failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockRejectedValueOnce(new Error('disk error'))
const { result } = renderHook(() => useThemeManager('/vault'))
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error))
warnSpy.mockRestore()
})
it('handles switchTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
mockInvokeFn.mockRejectedValueOnce(new Error('permission denied'))
await act(async () => {
await result.current.switchTheme('dark')
})
// Should not have changed the active theme
expect(result.current.activeThemeId).toBe('default')
expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error))
errorSpy.mockRestore()
})
it('handles createTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
mockInvokeFn.mockRejectedValueOnce(new Error('write error'))
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('')
expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error))
errorSpy.mockRestore()
})
it('switchTheme is a no-op when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
await act(async () => {
await result.current.switchTheme('dark')
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything())
})
it('createTheme returns empty string when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
let newId = ''
await act(async () => {
newId = await result.current.createTheme()
})
expect(newId).toBe('')
})
it('reloadThemes re-fetches theme list', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
await act(async () => {
await result.current.reloadThemes()
})
const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
expect(afterListCalls).toBe(initialListCalls + 1)
})
})

View File

@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { ThemeFile, VaultSettings } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
function applyThemeToDom(theme: ThemeFile): void {
const root = document.documentElement
for (const [key, value] of Object.entries(theme.colors)) {
root.style.setProperty(`--theme-${key}`, value)
// Also set the shadcn-compatible variables
root.style.setProperty(`--${key}`, value)
}
for (const [key, value] of Object.entries(theme.typography)) {
root.style.setProperty(`--theme-${key}`, value)
}
for (const [key, value] of Object.entries(theme.spacing)) {
root.style.setProperty(`--theme-${key}`, value)
}
// Map sidebar-background to --sidebar (shadcn convention)
if (theme.colors['sidebar-background']) {
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
}
}
function clearThemeFromDom(theme: ThemeFile): void {
const root = document.documentElement
for (const key of Object.keys(theme.colors)) {
root.style.removeProperty(`--theme-${key}`)
root.style.removeProperty(`--${key}`)
}
for (const key of Object.keys(theme.typography)) {
root.style.removeProperty(`--theme-${key}`)
}
for (const key of Object.keys(theme.spacing)) {
root.style.removeProperty(`--theme-${key}`)
}
root.style.removeProperty('--sidebar')
}
export interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
switchTheme: (themeId: string) => Promise<void>
createTheme: (sourceId?: string) => Promise<string>
reloadThemes: () => Promise<void>
}
/** Sync CSS custom properties: clear old theme, apply new one. */
function syncThemeDom(
prevRef: React.MutableRefObject<ThemeFile | null>,
theme: ThemeFile | null,
): void {
if (prevRef.current) clearThemeFromDom(prevRef.current)
if (theme) {
applyThemeToDom(theme)
prevRef.current = theme
} else {
prevRef.current = null
}
}
export function useThemeManager(vaultPath: string | null): ThemeManager {
const [themes, setThemes] = useState<ThemeFile[]>([])
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
const prevThemeRef = useRef<ThemeFile | null>(null)
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
const loadThemes = useCallback(async () => {
if (!vaultPath) return
try {
const [themeList, settings] = await Promise.all([
tauriCall<ThemeFile[]>('list_themes', { vault_path: vaultPath }),
tauriCall<VaultSettings>('get_vault_settings', { vault_path: vaultPath }),
])
setThemes(themeList)
setActiveThemeId(settings.theme)
} catch (err) {
console.warn('Failed to load themes:', err)
}
}, [vaultPath])
useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme])
const switchTheme = useCallback(async (themeId: string) => {
if (!vaultPath) return
try {
await tauriCall<null>('set_active_theme', { vault_path: vaultPath, theme_id: themeId })
setActiveThemeId(themeId)
} catch (err) {
console.error('Failed to switch theme:', err)
}
}, [vaultPath])
const createTheme = useCallback(async (sourceId?: string) => {
if (!vaultPath) return ''
try {
const newId = await tauriCall<string>('create_theme', {
vault_path: vaultPath,
source_id: sourceId ?? null,
})
await loadThemes()
return newId
} catch (err) {
console.error('Failed to create theme:', err)
return ''
}
}, [vaultPath, loadThemes])
return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes }
}

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo } from '../types'
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -82,6 +82,29 @@ let mockSettings: Settings = {
auto_pull_interval_minutes: 5,
}
let mockVaultSettings: VaultSettings = { theme: null }
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
colors: { background: '#FFFFFF', foreground: '#37352F', primary: '#155DFF', 'sidebar-background': '#F7F6F3', border: '#E9E9E7', muted: '#F0F0EF' },
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
spacing: { 'sidebar-width': '250px' },
},
{
id: 'dark', name: 'Dark', description: 'Dark variant with deep navy tones',
colors: { background: '#0f0f1a', foreground: '#e0e0e0', primary: '#155DFF', 'sidebar-background': '#1a1a2e', border: '#2a2a4a', muted: '#1e1e3a' },
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
spacing: { 'sidebar-width': '250px' },
},
{
id: 'minimal', name: 'Minimal', description: 'High contrast, minimal chrome',
colors: { background: '#FAFAFA', foreground: '#111111', primary: '#000000', 'sidebar-background': '#F5F5F5', border: '#E0E0E0', muted: '#F5F5F5' },
typography: { 'font-family': "'SF Mono', 'Menlo', monospace", 'font-size-base': '13px' },
spacing: { 'sidebar-width': '220px' },
},
]
let mockDeviceFlowPollCount = 0
function handleAiChat(args: { request: { messages: { role: string; content: string }[]; model?: string; system?: string } }) {
@@ -240,6 +263,22 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
register_mcp_tools: () => 'registered',
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { theme_id: string }): ThemeFile => {
const t = mockThemes.find(t => t.id === args.theme_id)
if (!t) throw new Error(`Theme not found: ${args.theme_id}`)
return { ...t }
},
get_vault_settings: (): VaultSettings => ({ ...mockVaultSettings }),
save_vault_settings: (args: { settings: VaultSettings }) => { mockVaultSettings = { ...args.settings }; return null },
set_active_theme: (args: { theme_id: string }) => { mockVaultSettings.theme = args.theme_id; return null },
create_theme: (args: { source_id?: string }): string => {
const sourceId = args.source_id ?? 'default'
const source = mockThemes.find(t => t.id === sourceId) ?? mockThemes[0]
const newId = `untitled-${mockThemes.length}`
mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' })
return newId
},
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -115,6 +115,19 @@ export interface SearchResponse {
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
export interface ThemeFile {
id: string
name: string
description: string
colors: Record<string, string>
typography: Record<string, string>
spacing: Record<string, string>
}
export interface VaultSettings {
theme: string | null
}
export type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' }
| { kind: 'sectionGroup'; type: string }