diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 22b2fc64..2b32d37b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,12 +13,11 @@ use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRep use crate::indexing::{IndexStatus, IndexingProgress}; use crate::search::SearchResponse; use crate::settings::Settings; -use crate::theme::{ThemeFile, VaultSettings}; use crate::vault::{RenameResult, VaultEntry}; use crate::vault_config::VaultConfig; use crate::vault_list::VaultList; use crate::{ - frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list, + frontmatter, git, github, indexing, menu, search, vault, vault_config, vault_list, }; /// Expand a leading `~` or `~/` in a path string to the user's home directory. @@ -520,62 +519,6 @@ pub async fn check_mcp_status() -> Result { .map_err(|e| format!("MCP status check failed: {e}")) } -// ── Theme commands ────────────────────────────────────────────────────────── - -#[tauri::command] -pub fn list_themes(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - theme::list_themes(&vault_path) -} - -#[tauri::command] -pub fn get_theme(vault_path: String, theme_id: String) -> Result { - let vault_path = expand_tilde(&vault_path); - theme::get_theme(&vault_path, &theme_id) -} - -#[tauri::command] -pub fn get_vault_settings(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - theme::get_vault_settings(&vault_path) -} - -#[tauri::command] -pub 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] -pub fn set_active_theme(vault_path: String, theme_id: Option) -> Result<(), String> { - let vault_path = expand_tilde(&vault_path); - theme::set_active_theme(&vault_path, theme_id.as_deref()) -} - -#[tauri::command] -pub fn create_theme(vault_path: String, source_id: Option) -> Result { - let vault_path = expand_tilde(&vault_path); - theme::create_theme(&vault_path, source_id.as_deref()) -} - -#[tauri::command] -pub fn create_vault_theme(vault_path: String, name: Option) -> Result { - let vault_path = expand_tilde(&vault_path); - theme::create_vault_theme(&vault_path, name.as_deref()) -} - -#[tauri::command] -pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> { - let vault_path = expand_tilde(&vault_path); - theme::ensure_vault_themes(&vault_path) -} - -#[tauri::command] -pub fn restore_default_themes(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - theme::restore_default_themes(&vault_path) -} - #[tauri::command] pub fn repair_vault(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); @@ -583,11 +526,6 @@ pub fn repair_vault(vault_path: String) -> Result { vault::migrate_is_a_to_type(&vault_path)?; // Flatten vault: move notes from type-based subfolders to root vault::flatten_vault(&vault_path)?; - // Remove legacy _themes/ directory (JSON theme store) if only defaults remain - theme::migrate_legacy_themes_dir(&vault_path); - // Migrate legacy theme/ directory to root, then repair themes - theme::migrate_theme_dir_to_root(&vault_path); - theme::restore_default_themes(&vault_path)?; // Migrate legacy config/ui.config.md → root ui.config.md vault_config::migrate_ui_config_to_root(&vault_path); // Repair config files (AGENTS.md at root, config.md type def) @@ -846,7 +784,7 @@ mod tests { } #[test] - fn test_repair_vault_creates_config_and_theme_files() { + fn test_repair_vault_creates_config_files() { let dir = tempfile::TempDir::new().unwrap(); let vault = dir.path(); @@ -855,14 +793,6 @@ mod tests { // Config files at root assert!(vault.join("AGENTS.md").exists()); assert!(vault.join("config.md").exists()); - // Theme files at root (flat structure) - assert!(vault.join("default-theme.md").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - assert!(vault.join("theme.md").exists()); - // No type/themes subfolders - assert!(!vault.join("theme").exists()); - assert!(!vault.join("config").exists()); // .gitignore assert!(vault.join(".gitignore").exists()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 502e81c8..7145ea10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,6 @@ pub mod mcp; pub mod menu; pub mod search; pub mod settings; -pub mod theme; pub mod vault; pub mod vault_config; pub mod vault_list; @@ -51,15 +50,6 @@ fn run_startup_tasks() { vault_config::migrate_hidden_sections_to_visible(vp_str), ); - // Remove legacy _themes/ directory (JSON theme store) if only defaults remain - theme::migrate_legacy_themes_dir(vp_str); - // Migrate legacy theme/ directory notes to root (flat structure) - theme::migrate_theme_dir_to_root(vp_str); - // Seed vault theme notes at root (flat structure) if missing - theme::seed_vault_themes(vp_str); - // Seed theme.md type definition so the Theme type has an icon in the sidebar - let _ = theme::ensure_theme_type_definition(vp_str); - // Migrate legacy config/agents.md → root AGENTS.md (one-time, idempotent) vault::migrate_agents_md(vp_str); // Seed AGENTS.md and config.md at vault root if missing @@ -175,15 +165,6 @@ pub fn run() { commands::get_default_vault_path, commands::register_mcp_tools, commands::check_mcp_status, - commands::list_themes, - commands::get_theme, - commands::get_vault_settings, - commands::save_vault_settings, - commands::set_active_theme, - commands::create_theme, - commands::create_vault_theme, - commands::ensure_vault_themes, - commands::restore_default_themes, commands::repair_vault, commands::get_vault_config, commands::save_vault_config diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 8a754c7d..d162a574 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -46,8 +46,6 @@ const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window"; const VAULT_OPEN: &str = "vault-open"; const VAULT_REMOVE: &str = "vault-remove"; const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started"; -const VAULT_NEW_THEME: &str = "vault-new-theme"; -const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes"; const VAULT_COMMIT_PUSH: &str = "vault-commit-push"; const VAULT_PULL: &str = "vault-pull"; const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts"; @@ -93,8 +91,6 @@ const CUSTOM_IDS: &[&str] = &[ VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, - VAULT_NEW_THEME, - VAULT_RESTORE_DEFAULT_THEMES, VAULT_COMMIT_PUSH, VAULT_PULL, VAULT_RESOLVE_CONFLICTS, @@ -346,12 +342,6 @@ fn build_vault_menu(app: &App) -> MenuResult { let restore_getting_started = MenuItemBuilder::new("Restore Getting Started") .id(VAULT_RESTORE_GETTING_STARTED) .build(app)?; - let new_theme = MenuItemBuilder::new("New Theme") - .id(VAULT_NEW_THEME) - .build(app)?; - let restore_default_themes = MenuItemBuilder::new("Restore Default Themes") - .id(VAULT_RESTORE_DEFAULT_THEMES) - .build(app)?; let commit_push = MenuItemBuilder::new("Commit & Push") .id(VAULT_COMMIT_PUSH) .build(app)?; @@ -383,9 +373,6 @@ fn build_vault_menu(app: &App) -> MenuResult { .item(&remove_vault) .item(&restore_getting_started) .separator() - .item(&new_theme) - .item(&restore_default_themes) - .separator() .item(&commit_push) .item(&pull) .item(&resolve_conflicts) @@ -507,8 +494,6 @@ mod tests { VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, - VAULT_NEW_THEME, - VAULT_RESTORE_DEFAULT_THEMES, VAULT_COMMIT_PUSH, VAULT_PULL, VAULT_RESOLVE_CONFLICTS, diff --git a/src-tauri/src/theme/create.rs b/src-tauri/src/theme/create.rs deleted file mode 100644 index 31ff24cb..00000000 --- a/src-tauri/src/theme/create.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::fs; -use std::path::Path; - -use super::defaults::DEFAULT_VAULT_THEME_VARS; - -/// Create a new vault theme note at vault root (flat structure). -/// Returns the absolute path to the newly created theme note. -pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result { - let vault_dir = Path::new(vault_path); - - let display_name = name.unwrap_or("Untitled Theme"); - let slug = slugify(display_name); - let filename = format!("{}.md", find_available_stem(vault_dir, &slug, "md")); - let path = vault_dir.join(&filename); - - let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS); - fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?; - - Ok(path.to_string_lossy().to_string()) -} - -/// Create a new theme file by copying the active theme (or default). -/// Returns the ID of the new theme. -/// NOTE: Legacy — operates on `_themes/` JSON store. Prefer `create_vault_theme`. -pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result { - let themes_dir = Path::new(vault_path).join("_themes"); - if !themes_dir.is_dir() { - return Err("Legacy _themes/ directory not found — use vault themes instead".to_string()); - } - - let new_id = find_available_stem(&themes_dir, "untitled", "json"); - - 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) -} - -/// Convert a display name to a URL-safe slug. -fn slugify(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-") -} - -/// Find an available filename stem (base, base-2, base-3, …) that doesn't -/// conflict when `ext` is appended. -fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String { - if !dir.join(format!("{base}.{ext}")).exists() { - return base.to_string(); - } - for i in 2.. { - let candidate = format!("{base}-{i}"); - if !dir.join(format!("{candidate}.{ext}")).exists() { - return candidate; - } - } - unreachable!() -} - -/// Build a vault theme note markdown string from a name and CSS variable map. -fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String { - let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n"); - for (key, value) in vars { - if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(') - { - fm.push_str(&format!("{key}: \"{value}\"\n")); - } else { - fm.push_str(&format!("{key}: {value}\n")); - } - } - fm.push_str("---\n\n"); - fm.push_str(&format!( - "# {name} Theme\n\nA custom {name} theme for Laputa.\n" - )); - fm -} - -/// 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() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::theme::defaults::*; - use crate::theme::get_theme; - 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_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_create_vault_theme_creates_md_file() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - let path = create_vault_theme(vp, Some("My Theme")).unwrap(); - assert!(std::path::Path::new(&path).exists()); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("Is A: Theme")); - assert!(content.contains("# My Theme")); - assert!(content.contains("background:")); - } - - #[test] - fn test_create_vault_theme_default_name() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - let path = create_vault_theme(vp, None).unwrap(); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("# Untitled Theme")); - } - - #[test] - fn test_create_vault_theme_avoids_conflicts() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - let p1 = create_vault_theme(vp, Some("Custom")).unwrap(); - let p2 = create_vault_theme(vp, Some("Custom")).unwrap(); - assert_ne!(p1, p2); - } - - #[test] - fn test_slugify() { - assert_eq!(slugify("My Cool Theme"), "my-cool-theme"); - assert_eq!(slugify("default"), "default"); - assert_eq!(slugify("Dark Mode!"), "dark-mode"); - } - - #[test] - fn test_create_vault_theme_contains_all_default_css_vars() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - let path = create_vault_theme(vp, Some("Full Theme")).unwrap(); - let content = fs::read_to_string(&path).unwrap(); - - // Every entry in DEFAULT_VAULT_THEME_VARS must appear in the generated file - for (key, _) in &DEFAULT_VAULT_THEME_VARS { - assert!( - content.contains(&format!("{key}:")), - "missing key in theme file: {key}" - ); - } - - // Spot-check editor properties from theme.json that were previously missing - assert!( - content.contains("editor-font-family:"), - "missing editor-font-family" - ); - assert!( - content.contains("editor-padding-horizontal:"), - "missing editor-padding-horizontal" - ); - assert!( - content.contains("headings-h1-font-size:"), - "missing headings-h1-font-size" - ); - assert!( - content.contains("lists-bullet-size:"), - "missing lists-bullet-size" - ); - assert!( - content.contains("lists-bullet-color:"), - "missing lists-bullet-color" - ); - assert!( - content.contains("checkboxes-size:"), - "missing checkboxes-size" - ); - assert!( - content.contains("inline-styles-bold-font-weight:"), - "missing inline-styles-bold-font-weight" - ); - assert!( - content.contains("code-blocks-font-family:"), - "missing code-blocks-font-family" - ); - assert!( - content.contains("blockquote-border-left-width:"), - "missing blockquote-border-left-width" - ); - assert!( - content.contains("table-border-color:"), - "missing table-border-color" - ); - assert!( - content.contains("horizontal-rule-thickness:"), - "missing horizontal-rule-thickness" - ); - assert!(content.contains("colors-text:"), "missing colors-text"); - assert!(content.contains("colors-cursor:"), "missing colors-cursor"); - - // Numeric values that need CSS units must have px suffix - assert!( - content.contains("editor-font-size: 15px"), - "editor-font-size should have px unit" - ); - assert!( - content.contains("editor-max-width: 720px"), - "editor-max-width should have px unit" - ); - assert!( - content.contains("editor-padding-horizontal: 40px"), - "editor-padding-horizontal should have px unit" - ); - } -} diff --git a/src-tauri/src/theme/defaults.rs b/src-tauri/src/theme/defaults.rs deleted file mode 100644 index 809c1785..00000000 --- a/src-tauri/src/theme/defaults.rs +++ /dev/null @@ -1,453 +0,0 @@ -/// 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" - } -}"##; - -// --------------------------------------------------------------------------- -// Vault-based theme notes (markdown with frontmatter CSS custom properties) -// --------------------------------------------------------------------------- - -/// Complete set of CSS variable key-value pairs for the default light vault theme. -/// Includes both UI chrome colours and all editor styling properties from theme.json. -/// Numeric values that need CSS units include the `px` suffix; unitless values -/// (line-height, font-weight) are bare numbers. -pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 140] = [ - // ── shadcn/ui base colours ────────────────────────────────────────── - ("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", "#F7F6F3"), - ("sidebar-foreground", "#37352F"), - ("sidebar-border", "#E9E9E7"), - ("sidebar-accent", "#EBEBEA"), - // ── Text hierarchy ────────────────────────────────────────────────── - ("text-primary", "#37352F"), - ("text-secondary", "#787774"), - ("text-tertiary", "#B4B4B4"), - ("text-muted", "#B4B4B4"), - ("text-heading", "#37352F"), - // ── Backgrounds ───────────────────────────────────────────────────── - ("bg-primary", "#FFFFFF"), - ("bg-card", "#FFFFFF"), - ("bg-sidebar", "#F7F6F3"), - ("bg-hover", "#EBEBEA"), - ("bg-hover-subtle", "#F0F0EF"), - ("bg-selected", "#E8F4FE"), - ("border-primary", "#E9E9E7"), - // ── Accent colours ────────────────────────────────────────────────── - ("accent-blue", "#155DFF"), - ("accent-green", "#00B38B"), - ("accent-orange", "#D9730D"), - ("accent-red", "#E03E3E"), - ("accent-purple", "#A932FF"), - ("accent-yellow", "#F0B100"), - ("accent-blue-light", "#155DFF14"), - ("accent-green-light", "#00B38B14"), - ("accent-purple-light", "#A932FF14"), - ("accent-red-light", "#E03E3E14"), - ("accent-yellow-light", "#F0B10014"), - // ── Typography base ───────────────────────────────────────────────── - ( - "font-family", - "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", - ), - ("font-size-base", "14px"), - // ── Editor (from theme.json → editor) ─────────────────────────────── - ( - "editor-font-family", - "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", - ), - ("editor-font-size", "15px"), - ("editor-line-height", "1.5"), - ("editor-max-width", "720px"), - ("editor-padding-horizontal", "40px"), - ("editor-padding-vertical", "20px"), - ("editor-paragraph-spacing", "8px"), - // ── Headings H1 ──────────────────────────────────────────────────── - ("headings-h1-font-size", "32px"), - ("headings-h1-font-weight", "700"), - ("headings-h1-line-height", "1.2"), - ("headings-h1-margin-top", "32px"), - ("headings-h1-margin-bottom", "12px"), - ("headings-h1-color", "var(--text-heading)"), - ("headings-h1-letter-spacing", "-0.5px"), - // ── Headings H2 ──────────────────────────────────────────────────── - ("headings-h2-font-size", "27px"), - ("headings-h2-font-weight", "600"), - ("headings-h2-line-height", "1.4"), - ("headings-h2-margin-top", "28px"), - ("headings-h2-margin-bottom", "10px"), - ("headings-h2-color", "var(--text-heading)"), - ("headings-h2-letter-spacing", "-0.5px"), - // ── Headings H3 ──────────────────────────────────────────────────── - ("headings-h3-font-size", "20px"), - ("headings-h3-font-weight", "600"), - ("headings-h3-line-height", "1.4"), - ("headings-h3-margin-top", "24px"), - ("headings-h3-margin-bottom", "8px"), - ("headings-h3-color", "var(--text-heading)"), - ("headings-h3-letter-spacing", "-0.5px"), - // ── Headings H4 ──────────────────────────────────────────────────── - ("headings-h4-font-size", "20px"), - ("headings-h4-font-weight", "600"), - ("headings-h4-line-height", "1.4"), - ("headings-h4-margin-top", "20px"), - ("headings-h4-margin-bottom", "6px"), - ("headings-h4-color", "var(--text-heading)"), - ("headings-h4-letter-spacing", "0px"), - // ── Lists ─────────────────────────────────────────────────────────── - ("lists-bullet-size", "28px"), - ("lists-bullet-color", "#177bfd"), - ("lists-indent-size", "24px"), - ("lists-item-spacing", "4px"), - ("lists-padding-left", "8px"), - ("lists-bullet-gap", "6px"), - // ── Checkboxes ────────────────────────────────────────────────────── - ("checkboxes-size", "18px"), - ("checkboxes-border-radius", "3px"), - ("checkboxes-checked-color", "var(--accent-blue)"), - ("checkboxes-unchecked-border-color", "var(--text-muted)"), - ("checkboxes-gap", "8px"), - // ── Inline styles: bold ───────────────────────────────────────────── - ("inline-styles-bold-font-weight", "700"), - ("inline-styles-bold-color", "var(--text-primary)"), - // ── Inline styles: italic ─────────────────────────────────────────── - ("inline-styles-italic-font-style", "italic"), - ("inline-styles-italic-color", "var(--text-primary)"), - // ── Inline styles: strikethrough ──────────────────────────────────── - ("inline-styles-strikethrough-color", "var(--text-tertiary)"), - ( - "inline-styles-strikethrough-text-decoration", - "line-through", - ), - // ── Inline styles: code ───────────────────────────────────────────── - ( - "inline-styles-code-font-family", - "'SF Mono', 'Fira Code', monospace", - ), - ("inline-styles-code-font-size", "14px"), - ( - "inline-styles-code-background-color", - "var(--bg-hover-subtle)", - ), - ("inline-styles-code-padding-horizontal", "4px"), - ("inline-styles-code-padding-vertical", "2px"), - ("inline-styles-code-border-radius", "3px"), - ("inline-styles-code-color", "var(--text-secondary)"), - // ── Inline styles: link ───────────────────────────────────────────── - ("inline-styles-link-color", "var(--accent-blue)"), - ("inline-styles-link-text-decoration", "underline"), - // ── Inline styles: wikilink ───────────────────────────────────────── - ("inline-styles-wikilink-color", "var(--accent-blue)"), - ("inline-styles-wikilink-text-decoration", "none"), - ( - "inline-styles-wikilink-border-bottom", - "1px dotted currentColor", - ), - ("inline-styles-wikilink-cursor", "pointer"), - // ── Code blocks ───────────────────────────────────────────────────── - ( - "code-blocks-font-family", - "'SF Mono', 'Fira Code', monospace", - ), - ("code-blocks-font-size", "13px"), - ("code-blocks-line-height", "1.5"), - ("code-blocks-background-color", "var(--bg-card)"), - ("code-blocks-padding-horizontal", "16px"), - ("code-blocks-padding-vertical", "12px"), - ("code-blocks-border-radius", "6px"), - ("code-blocks-margin-vertical", "12px"), - // ── Blockquote ────────────────────────────────────────────────────── - ("blockquote-border-left-width", "3px"), - ("blockquote-border-left-color", "var(--accent-blue)"), - ("blockquote-padding-left", "16px"), - ("blockquote-margin-vertical", "12px"), - ("blockquote-color", "var(--text-secondary)"), - ("blockquote-font-style", "italic"), - // ── Table ─────────────────────────────────────────────────────────── - ("table-border-color", "var(--border-primary)"), - ("table-header-background", "var(--bg-card)"), - ("table-cell-padding-horizontal", "12px"), - ("table-cell-padding-vertical", "8px"), - ("table-font-size", "14px"), - // ── Horizontal rule ───────────────────────────────────────────────── - ("horizontal-rule-color", "var(--border-primary)"), - ("horizontal-rule-margin-vertical", "24px"), - ("horizontal-rule-thickness", "1px"), - // ── Colors (semantic aliases from theme.json → colors) ────────────── - ("colors-background", "var(--bg-primary)"), - ("colors-text", "var(--text-primary)"), - ("colors-text-secondary", "var(--text-secondary)"), - ("colors-text-muted", "var(--text-muted)"), - ("colors-heading", "var(--text-heading)"), - ("colors-accent", "var(--accent-blue)"), - ("colors-selection", "var(--bg-selected)"), - ("colors-cursor", "var(--text-primary)"), -]; - -/// UI-colour overrides for the Dark vault theme (keys that differ from default). -const DARK_COLOR_OVERRIDES: &[(&str, &str)] = &[ - ("background", "#0f0f1a"), - ("foreground", "#e0e0e0"), - ("card", "#16162a"), - ("popover", "#1e1e3a"), - ("secondary", "#2a2a4a"), - ("secondary-foreground", "#e0e0e0"), - ("muted", "#1e1e3a"), - ("muted-foreground", "#888888"), - ("accent", "#2a2a4a"), - ("accent-foreground", "#e0e0e0"), - ("destructive", "#f44336"), - ("border", "#2a2a4a"), - ("input", "#2a2a4a"), - ("sidebar", "#1a1a2e"), - ("sidebar-foreground", "#e0e0e0"), - ("sidebar-border", "#2a2a4a"), - ("sidebar-accent", "#2a2a4a"), - ("text-primary", "#e0e0e0"), - ("text-secondary", "#888888"), - ("text-tertiary", "#666666"), - ("text-muted", "#666666"), - ("text-heading", "#e0e0e0"), - ("bg-primary", "#0f0f1a"), - ("bg-card", "#16162a"), - ("bg-sidebar", "#1a1a2e"), - ("bg-hover", "#2a2a4a"), - ("bg-hover-subtle", "#1e1e3a"), - ("bg-selected", "#155DFF22"), - ("border-primary", "#2a2a4a"), - ("accent-red", "#f44336"), - ("accent-blue-light", "#155DFF33"), - ("accent-green-light", "#00B38B33"), - ("accent-purple-light", "#A932FF33"), - ("accent-red-light", "#f4433633"), - ("accent-yellow-light", "#F0B10033"), - ("lists-bullet-color", "#155DFF"), -]; - -/// UI-colour + editor-property overrides for the Minimal vault theme. -const MINIMAL_OVERRIDES: &[(&str, &str)] = &[ - ("background", "#FAFAFA"), - ("foreground", "#111111"), - ("primary", "#000000"), - ("secondary", "#F0F0F0"), - ("secondary-foreground", "#111111"), - ("muted", "#F5F5F5"), - ("muted-foreground", "#666666"), - ("accent", "#F0F0F0"), - ("accent-foreground", "#111111"), - ("destructive", "#CC0000"), - ("border", "#E0E0E0"), - ("input", "#E0E0E0"), - ("ring", "#000000"), - ("sidebar", "#F5F5F5"), - ("sidebar-foreground", "#111111"), - ("sidebar-border", "#E0E0E0"), - ("sidebar-accent", "#E8E8E8"), - ("text-primary", "#111111"), - ("text-secondary", "#666666"), - ("text-tertiary", "#999999"), - ("text-muted", "#999999"), - ("text-heading", "#111111"), - ("bg-primary", "#FAFAFA"), - ("bg-card", "#FFFFFF"), - ("bg-sidebar", "#F5F5F5"), - ("bg-hover", "#EBEBEB"), - ("bg-hover-subtle", "#F5F5F5"), - ("bg-selected", "#00000014"), - ("border-primary", "#E0E0E0"), - ("accent-blue", "#000000"), - ("accent-green", "#006600"), - ("accent-orange", "#996600"), - ("accent-red", "#CC0000"), - ("accent-purple", "#660099"), - ("accent-yellow", "#996600"), - ("accent-blue-light", "#00000014"), - ("accent-green-light", "#00660014"), - ("accent-purple-light", "#66009914"), - ("accent-red-light", "#CC000014"), - ("accent-yellow-light", "#99660014"), - ("font-family", "'SF Mono', 'Menlo', monospace"), - ("font-size-base", "13px"), - ("editor-font-size", "15px"), - ("editor-line-height", "1.6"), - ("editor-max-width", "680px"), - ("lists-bullet-color", "#000000"), -]; - -/// Build a vault theme note string from a set of CSS variable pairs. -/// -/// Values containing `#`, `'`, `,`, or `(` are YAML-quoted to avoid parse errors. -fn build_vault_theme_note(name: &str, description: &str, vars: &[(&str, &str)]) -> String { - let mut fm = format!("---\ntype: Theme\nDescription: {description}\n"); - for (key, value) in vars { - if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(') - { - fm.push_str(&format!("{key}: \"{value}\"\n")); - } else { - fm.push_str(&format!("{key}: {value}\n")); - } - } - fm.push_str("---\n\n"); - fm.push_str(&format!("# {name} Theme\n\n{description}.\n")); - fm -} - -/// Apply overrides on top of DEFAULT_VAULT_THEME_VARS, returning a new Vec. -fn apply_overrides( - overrides: &[(&'static str, &'static str)], -) -> Vec<(&'static str, &'static str)> { - let mut vars: Vec<(&'static str, &'static str)> = DEFAULT_VAULT_THEME_VARS.to_vec(); - for &(key, value) in overrides { - if let Some(entry) = vars.iter_mut().find(|e| e.0 == key) { - entry.1 = value; - } - } - vars -} - -/// Generate the Default vault theme note content. -pub fn default_vault_theme() -> String { - build_vault_theme_note( - "Default", - "Light theme with warm, paper-like tones", - &DEFAULT_VAULT_THEME_VARS, - ) -} - -/// Generate the Dark vault theme note content. -pub fn dark_vault_theme() -> String { - let vars = apply_overrides(DARK_COLOR_OVERRIDES); - build_vault_theme_note("Dark", "Dark variant with deep navy tones", &vars) -} - -/// Generate the Minimal vault theme note content. -pub fn minimal_vault_theme() -> String { - let vars = apply_overrides(MINIMAL_OVERRIDES); - build_vault_theme_note("Minimal", "High contrast, minimal chrome", &vars) -} - -/// Type definition for the Theme note type. -pub const THEME_TYPE_DEFINITION: &str = "---\n\ -type: Type\n\ -icon: palette\n\ -color: purple\n\ -order: 50\n\ ----\n\ -\n\ -# Theme\n\ -\n\ -A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n"; diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs deleted file mode 100644 index 4862aac7..00000000 --- a/src-tauri/src/theme/mod.rs +++ /dev/null @@ -1,263 +0,0 @@ -mod create; -pub mod defaults; -mod seed; - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -pub use create::{create_theme, create_vault_theme}; -pub use defaults::*; -pub use seed::{ - ensure_theme_type_definition, ensure_vault_themes, migrate_legacy_themes_dir, - migrate_theme_dir_to_root, restore_default_themes, seed_vault_themes, -}; - -/// 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, - #[serde(default)] - pub typography: HashMap, - #[serde(default)] - pub spacing: HashMap, -} - -/// Vault-level settings stored in .laputa/settings.json (git-tracked). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct VaultSettings { - #[serde(default)] - pub theme: Option, -} - -/// List all theme files in _themes/ directory of the vault (legacy). -/// Returns an empty list if the directory doesn't exist. -pub fn list_themes(vault_path: &str) -> Result, 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 { - 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 { - 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. Pass `None` to clear. -pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> { - let mut settings = get_vault_settings(vault_path)?; - settings.theme = theme_id.map(|s| s.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 { - 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) -} - -#[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_returns_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(), "must return empty when _themes/ absent"); - assert!(!vault.join("_themes").exists(), "must not create _themes/"); - } - - #[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(); - - let settings = get_vault_settings(vp).unwrap(); - assert!(settings.theme.is_none()); - - set_active_theme(vp, Some("dark")).unwrap(); - let settings = get_vault_settings(vp).unwrap(); - assert_eq!(settings.theme.as_deref(), Some("dark")); - - set_active_theme(vp, None).unwrap(); - let settings = get_vault_settings(vp).unwrap(); - assert_eq!(settings.theme, None); - } - - #[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_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); - } - - #[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); - } - - #[test] - fn test_vault_theme_content_contains_all_vars() { - let content = default_vault_theme(); - assert!(content.contains("background:")); - assert!(content.contains("primary:")); - assert!(content.contains("sidebar:")); - assert!(content.contains("text-primary:")); - assert!(content.contains("accent-blue:")); - assert!(content.contains("editor-font-size:")); - } -} diff --git a/src-tauri/src/theme/seed.rs b/src-tauri/src/theme/seed.rs deleted file mode 100644 index 775c38bc..00000000 --- a/src-tauri/src/theme/seed.rs +++ /dev/null @@ -1,554 +0,0 @@ -use std::fs; -use std::path::Path; - -use super::defaults::*; - -/// Write a vault theme file if it doesn't exist or is empty (corrupt). -fn write_if_missing(path: &Path, content: &str) -> Result { - let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0); - if needs_write { - fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; - } - Ok(needs_write) -} - -/// Filenames for built-in vault theme notes at vault root (flat structure). -const VAULT_THEME_FILES: [&str; 3] = ["default-theme.md", "dark-theme.md", "minimal-theme.md"]; - -/// Seed built-in vault theme notes at vault root (flat structure). -/// Per-file idempotent: writes each default file only when it doesn't exist -/// or is empty (corrupt). Never overwrites existing files that have content. -pub fn seed_vault_themes(vault_path: &str) { - let vault = Path::new(vault_path); - let default_content = default_vault_theme(); - let dark_content = dark_vault_theme(); - let minimal_content = minimal_vault_theme(); - let defaults: &[(&str, &str)] = &[ - (VAULT_THEME_FILES[0], &default_content), - (VAULT_THEME_FILES[1], &dark_content), - (VAULT_THEME_FILES[2], &minimal_content), - ]; - let mut seeded = false; - for (name, content) in defaults { - let wrote = write_if_missing(&vault.join(name), content).unwrap_or(false); - seeded = seeded || wrote; - } - if seeded { - log::info!("Seeded vault root with built-in vault themes"); - } -} - -/// Ensure vault theme files exist at vault root (flat structure). -/// Returns an error on read-only filesystem. -pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> { - let vault = Path::new(vault_path); - let default_content = default_vault_theme(); - let dark_content = dark_vault_theme(); - let minimal_content = minimal_vault_theme(); - let defaults: &[(&str, &str)] = &[ - (VAULT_THEME_FILES[0], &default_content), - (VAULT_THEME_FILES[1], &dark_content), - (VAULT_THEME_FILES[2], &minimal_content), - ]; - for (name, content) in defaults { - write_if_missing(&vault.join(name), content) - .map_err(|e| format!("Failed to write {name}: {e}"))?; - } - Ok(()) -} - -/// Restore default themes for a vault: seeds vault root theme notes (flat -/// structure) and the theme.md type definition. Per-file idempotent — never -/// overwrites files that already have content. Returns an error on read-only -/// filesystems. -pub fn restore_default_themes(vault_path: &str) -> Result { - // Seed vault theme notes at root (flat structure) - ensure_vault_themes(vault_path)?; - - // Seed theme.md type definition so the Theme type has an icon and label in the sidebar - ensure_theme_type_definition(vault_path)?; - - Ok("Default themes restored".to_string()) -} - -/// Create `theme.md` at vault root if it doesn't exist (gives the Theme type a sidebar icon/color). -pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> { - let vault = Path::new(vault_path); - write_if_missing(&vault.join("theme.md"), THEME_TYPE_DEFINITION)?; - Ok(()) -} - -/// Migrate legacy `theme/` directory vault notes to root (flat structure). -/// -/// Moves `theme/default.md` → `default-theme.md`, etc. Only moves a file if the -/// target doesn't exist yet (preserves existing root files). Cleans up the empty -/// `theme/` directory afterwards. Idempotent and silent. -pub fn migrate_theme_dir_to_root(vault_path: &str) { - let vault = Path::new(vault_path); - let theme_dir = vault.join("theme"); - if !theme_dir.is_dir() { - return; - } - - let migrations: &[(&str, &str)] = &[ - ("default.md", "default-theme.md"), - ("dark.md", "dark-theme.md"), - ("minimal.md", "minimal-theme.md"), - ]; - - for (old_name, new_name) in migrations { - let old_path = theme_dir.join(old_name); - let new_path = vault.join(new_name); - if old_path.exists() && !new_path.exists() { - if let Ok(content) = fs::read_to_string(&old_path) { - if !content.is_empty() { - let _ = fs::write(&new_path, &content); - log::info!("Migrated theme/{old_name} → {new_name}"); - } - } - let _ = fs::remove_file(&old_path); - } else if old_path.exists() { - // Target exists, just remove the old file - let _ = fs::remove_file(&old_path); - } - } - - // Clean up empty theme/ directory - if theme_dir.is_dir() { - let is_empty = fs::read_dir(&theme_dir).map_or(true, |mut d| d.next().is_none()); - if is_empty { - let _ = fs::remove_dir(&theme_dir); - log::info!("Removed empty theme/ directory"); - } - } -} - -/// Remove the legacy `_themes/` directory if it only contains default JSON files. -/// Leaves the directory intact if it has any custom (non-default) files. -/// Idempotent and silent. -pub fn migrate_legacy_themes_dir(vault_path: &str) { - let themes_dir = Path::new(vault_path).join("_themes"); - if !themes_dir.is_dir() { - return; - } - - let default_filenames: &[&str] = &["default.json", "dark.json", "minimal.json"]; - - // Check if directory only has default files (or is empty) - let has_custom = fs::read_dir(&themes_dir).is_ok_and(|entries| { - entries.filter_map(|e| e.ok()).any(|e| { - let name = e.file_name(); - let name_str = name.to_string_lossy(); - !default_filenames.contains(&name_str.as_ref()) - }) - }); - - if has_custom { - return; - } - - // Remove default JSON files then the empty directory - for name in default_filenames { - let _ = fs::remove_file(themes_dir.join(name)); - } - let _ = fs::remove_dir(&themes_dir); - log::info!("Removed legacy _themes/ directory"); -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_seed_vault_themes_creates_files_at_root() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - seed_vault_themes(vp); - assert!(vault.join("default-theme.md").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - // Must NOT create a theme/ subdirectory - assert!(!vault.join("theme").exists()); - } - - #[test] - fn test_seed_vault_themes_is_idempotent() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - seed_vault_themes(vp); - seed_vault_themes(vp); // second call should be a no-op - assert!(vault.join("default-theme.md").exists()); - } - - #[test] - fn test_seed_vault_themes_writes_missing_files() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("default-theme.md"), &default_vault_theme()).unwrap(); - let vp = vault.to_str().unwrap(); - - seed_vault_themes(vp); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - } - - #[test] - fn test_seed_vault_themes_reseeds_empty_files() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("default-theme.md"), "").unwrap(); - let vp = vault.to_str().unwrap(); - - seed_vault_themes(vp); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!(content.contains("type: Theme")); - } - - #[test] - fn test_seed_vault_themes_preserves_existing_content() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n"; - fs::write(vault.join("default-theme.md"), custom).unwrap(); - let vp = vault.to_str().unwrap(); - - seed_vault_themes(vp); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!( - content.contains("#FF0000"), - "existing content must be preserved" - ); - } - - #[test] - fn test_ensure_vault_themes_creates_root_level_defaults() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_vault_themes(vp).unwrap(); - assert!(vault.join("default-theme.md").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - // Must NOT create a theme/ subdirectory - assert!(!vault.join("theme").exists()); - } - - #[test] - fn test_ensure_vault_themes_reseeds_empty_files() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("default-theme.md"), "").unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_vault_themes(vp).unwrap(); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!(content.contains("type: Theme")); - } - - #[test] - fn test_ensure_vault_themes_preserves_custom_themes() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n"; - fs::write(vault.join("default-theme.md"), custom).unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_vault_themes(vp).unwrap(); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!(content.contains("#123456")); - } - - #[test] - fn test_restore_default_themes_creates_flat_structure() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - let msg = restore_default_themes(vp).unwrap(); - assert_eq!(msg, "Default themes restored"); - // Must NOT create _themes/ directory (legacy) - assert!(!vault.join("_themes").exists()); - // Vault theme notes at root (flat structure) - assert!(vault.join("default-theme.md").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - // Must NOT create a theme/ subdirectory - assert!(!vault.join("theme").is_dir()); - // Type definition at root - assert!( - vault.join("theme.md").exists(), - "restore must create theme.md" - ); - let type_content = fs::read_to_string(vault.join("theme.md")).unwrap(); - assert!(type_content.contains("type: Type")); - assert!(type_content.contains("icon: palette")); - } - - #[test] - fn test_ensure_theme_type_definition_creates_file() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_theme_type_definition(vp).unwrap(); - let path = vault.join("theme.md"); - assert!(path.exists()); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("type: Type")); - assert!(content.contains("icon: palette")); - } - - #[test] - fn test_ensure_theme_type_definition_is_idempotent() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n"; - fs::write(vault.join("theme.md"), custom).unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_theme_type_definition(vp).unwrap(); - let content = fs::read_to_string(vault.join("theme.md")).unwrap(); - assert!( - content.contains("swatches"), - "existing content must be preserved" - ); - } - - #[test] - fn test_restore_default_themes_is_idempotent() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - restore_default_themes(vp).unwrap(); - let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n"; - fs::write(vault.join("default-theme.md"), custom).unwrap(); - - restore_default_themes(vp).unwrap(); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!( - content.contains("#CUSTOM"), - "must not overwrite existing content" - ); - } - - #[test] - fn test_restore_default_themes_fills_partial_state() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("default-theme.md"), &default_vault_theme()).unwrap(); - let vp = vault.to_str().unwrap(); - - restore_default_themes(vp).unwrap(); - // Must NOT create _themes/ directory - assert!(!vault.join("_themes").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!(content.contains("Light theme with warm")); - } - - #[test] - fn test_seeded_default_theme_contains_editor_properties() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - ensure_vault_themes(vp).unwrap(); - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - - // Must contain all editor properties from theme.json - assert!( - content.contains("editor-font-family:"), - "missing editor-font-family" - ); - assert!( - content.contains("headings-h1-font-size:"), - "missing headings-h1-font-size" - ); - assert!( - content.contains("lists-bullet-size:"), - "missing lists-bullet-size" - ); - assert!( - content.contains("checkboxes-size:"), - "missing checkboxes-size" - ); - assert!( - content.contains("inline-styles-bold-font-weight:"), - "missing inline-styles-bold" - ); - assert!( - content.contains("code-blocks-font-family:"), - "missing code-blocks-font-family" - ); - assert!( - content.contains("blockquote-border-left-width:"), - "missing blockquote" - ); - assert!( - content.contains("table-border-color:"), - "missing table-border-color" - ); - assert!( - content.contains("horizontal-rule-thickness:"), - "missing horizontal-rule" - ); - assert!(content.contains("colors-text:"), "missing colors-text"); - } - - #[test] - fn test_migrate_theme_dir_moves_files_to_root() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - let theme_dir = vault.join("theme"); - fs::create_dir_all(&theme_dir).unwrap(); - fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap(); - fs::write(theme_dir.join("dark.md"), &dark_vault_theme()).unwrap(); - fs::write(theme_dir.join("minimal.md"), &minimal_vault_theme()).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_theme_dir_to_root(vp); - - assert!(vault.join("default-theme.md").exists()); - assert!(vault.join("dark-theme.md").exists()); - assert!(vault.join("minimal-theme.md").exists()); - // Old files removed - assert!(!theme_dir.join("default.md").exists()); - // Empty directory cleaned up - assert!(!theme_dir.exists()); - } - - #[test] - fn test_migrate_theme_dir_preserves_existing_root_files() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - let theme_dir = vault.join("theme"); - fs::create_dir_all(&theme_dir).unwrap(); - let custom = "---\ntype: Theme\nbackground: \"#CUSTOM\"\n---\n# Custom\n"; - fs::write(vault.join("default-theme.md"), custom).unwrap(); - fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_theme_dir_to_root(vp); - - let content = fs::read_to_string(vault.join("default-theme.md")).unwrap(); - assert!( - content.contains("#CUSTOM"), - "must preserve existing root file" - ); - } - - #[test] - fn test_migrate_theme_dir_noop_when_no_theme_dir() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_theme_dir_to_root(vp); - assert!(!vault.join("theme").exists()); - } - - #[test] - fn test_migrate_theme_dir_keeps_nonempty_dir() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - let theme_dir = vault.join("theme"); - fs::create_dir_all(&theme_dir).unwrap(); - fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap(); - fs::write(theme_dir.join("custom-theme.md"), "custom content").unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_theme_dir_to_root(vp); - - assert!(vault.join("default-theme.md").exists()); - assert!(theme_dir.join("custom-theme.md").exists()); - assert!(theme_dir.exists()); - } - - #[test] - fn test_migrate_legacy_themes_dir_removes_defaults_only() { - let dir = TempDir::new().unwrap(); - 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(); - fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_legacy_themes_dir(vp); - - assert!( - !themes_dir.exists(), - "_themes/ must be removed when only defaults" - ); - } - - #[test] - fn test_migrate_legacy_themes_dir_keeps_custom_files() { - let dir = TempDir::new().unwrap(); - 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("custom.json"), r#"{"name":"Custom"}"#).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_legacy_themes_dir(vp); - - assert!( - themes_dir.exists(), - "_themes/ must be kept when custom files present" - ); - assert!(themes_dir.join("default.json").exists()); - assert!(themes_dir.join("custom.json").exists()); - } - - #[test] - fn test_migrate_legacy_themes_dir_noop_when_absent() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_legacy_themes_dir(vp); - - assert!(!vault.join("_themes").exists()); - } - - #[test] - fn test_migrate_legacy_themes_dir_removes_empty() { - let dir = TempDir::new().unwrap(); - let vault = dir.path().join("vault"); - let themes_dir = vault.join("_themes"); - fs::create_dir_all(&themes_dir).unwrap(); - let vp = vault.to_str().unwrap(); - - migrate_legacy_themes_dir(vp); - - assert!(!themes_dir.exists(), "empty _themes/ must be removed"); - } -} diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index e9f697f6..2968cde4 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -402,23 +402,6 @@ pub fn create_getting_started_vault(target_path: &str) -> Result .map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?; } - // Seed vault theme notes at root (flat structure) - fs::write( - vault_dir.join("default-theme.md"), - crate::theme::default_vault_theme(), - ) - .map_err(|e| format!("Failed to write default vault theme: {e}"))?; - fs::write( - vault_dir.join("dark-theme.md"), - crate::theme::dark_vault_theme(), - ) - .map_err(|e| format!("Failed to write dark vault theme: {e}"))?; - fs::write( - vault_dir.join("minimal-theme.md"), - crate::theme::minimal_vault_theme(), - ) - .map_err(|e| format!("Failed to write minimal vault theme: {e}"))?; - crate::git::init_repo(target_path)?; Ok(vault_dir @@ -521,8 +504,8 @@ mod tests { create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entries = crate::vault::scan_vault(&vault_path).unwrap(); - // SAMPLE_FILES + AGENTS.md + 3 vault theme notes (all at root) - assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3); + // SAMPLE_FILES + AGENTS.md + assert_eq!(entries.len(), SAMPLE_FILES.len() + 1); } #[test] @@ -578,26 +561,6 @@ 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(); - - // Must NOT create legacy _themes/ directory - assert!(!vault_path.join("_themes").exists()); - - // Vault-based theme notes at root (flat structure) - assert!(vault_path.join("default-theme.md").exists()); - assert!(vault_path.join("dark-theme.md").exists()); - assert!(vault_path.join("minimal-theme.md").exists()); - // Must NOT create a theme/ subdirectory - assert!(!vault_path.join("theme").exists()); - - // Theme type definition - assert!(vault_path.join("theme.md").exists()); - } - #[test] fn test_create_getting_started_vault_no_untracked_files() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index b3cf2c30..51990b86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,7 +35,6 @@ import { useZoom } from './hooks/useZoom' import { useVaultConfig } from './hooks/useVaultConfig' import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' -import { useThemeManager } from './hooks/useThemeManager' import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks' import { useAppNavigation } from './hooks/useAppNavigation' import { useAiActivity } from './hooks/useAiActivity' @@ -96,8 +95,6 @@ function App() { const vault = useVaultLoader(resolvedPath) useVaultConfig(resolvedPath) const { settings, saveSettings } = useSettings() - const themeManager = useThemeManager(resolvedPath, vault.entries) - const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) @@ -190,7 +187,7 @@ function App() { // Read at callback time, so it's always current when user presses Cmd+N. const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterContentChanged: themeManager.notifyThemeSaved }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry }) // Keep tab entries in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. @@ -303,11 +300,10 @@ function App() { triggerIncrementalIndex() }, [vault, triggerIncrementalIndex]) - const { notifyThemeSaved } = themeManager - const onNotePersisted = useCallback((path: string, content: string) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature required by useEditorSave + const onNotePersisted = useCallback((path: string, _content: string) => { vault.clearUnsaved(path) - notifyThemeSaved(path, content) - }, [vault, notifyThemeSaved]) + }, [vault]) const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ updateEntry: vault.updateEntry, @@ -456,31 +452,17 @@ function App() { // 'available' → UpdateBanner handles it automatically }, [updateActions, updateStatus.state, setToastMessage]) - const handleRestoreDefaultThemes = useCallback(async () => { - if (!resolvedPath) return - try { - const tauriInvoke = isTauri() ? invoke : mockInvoke - const msg = await tauriInvoke('restore_default_themes', { vaultPath: resolvedPath }) - await vault.reloadVault() - await themeManager.reloadThemes() - setToastMessage(msg) - } catch (err) { - setToastMessage(`Failed to restore themes: ${err}`) - } - }, [resolvedPath, vault, themeManager, setToastMessage]) - const handleRepairVault = useCallback(async () => { if (!resolvedPath) return try { const tauriInvoke = isTauri() ? invoke : mockInvoke const msg = await tauriInvoke('repair_vault', { vaultPath: resolvedPath }) await vault.reloadVault() - await themeManager.reloadThemes() setToastMessage(msg) } catch (err) { setToastMessage(`Failed to repair vault: ${err}`) } - }, [resolvedPath, vault, themeManager, setToastMessage]) + }, [resolvedPath, vault, setToastMessage]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, @@ -512,28 +494,12 @@ function App() { onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: canGoBack, canGoForward: canGoForward, - themes: themeManager.themes, activeThemeId: themeManager.activeThemeId, - onSwitchTheme: themeManager.switchTheme, - onCreateTheme: async () => { - const path = await themeManager.createTheme() - const freshEntries = await vault.reloadVault() - handleSetSelection({ kind: 'sectionGroup', type: 'Theme' }) - if (path) { - const entry = freshEntries.find(e => e.path === path) - if (entry) notes.handleSelectNote(entry) - } - }, - onOpenTheme: (themeId: string) => { - const entry = vault.entries.find(e => e.path === themeId) - if (entry) notes.handleSelectNote(entry) - }, onOpenVault: vaultSwitcher.handleOpenLocalFolder, onCreateType: dialogs.openCreateType, onToggleAIChat: dialogs.toggleAIChat, onCheckForUpdates: handleCheckForUpdates, onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath), onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted, - onRestoreDefaultThemes: handleRestoreDefaultThemes, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, mcpStatus, @@ -651,7 +617,6 @@ function App() { onGoBack={handleGoBack} onGoForward={handleGoForward} leftPanelsCollapsed={!sidebarVisible && !noteListVisible} - isDarkTheme={themeManager.isDark} onFileCreated={handleAgentFileCreated} onFileModified={handleAgentFileModified} onVaultChanged={handleAgentVaultChanged} @@ -693,7 +658,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={handleCloseConflictResolver} /> - + { cancelled = true } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params - // Apply theme const vaultPath = params?.vaultPath ?? '' - useThemeManager(vaultPath, entries) // Update window title when note title changes useEffect(() => { diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx index 58a61283..3e7634f1 100644 --- a/src/components/CommandPalette.test.tsx +++ b/src/components/CommandPalette.test.tsx @@ -170,7 +170,6 @@ describe('CommandPalette', () => { const relevanceCommands: CommandAction[] = [ makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }), makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }), - makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }), makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }), ] @@ -203,14 +202,6 @@ describe('CommandPalette', () => { expect(labels[0]).toBe('Create New Note') }) - it('ranks theme commands first for query "theme"', () => { - render() - fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } }) - - const labels = getVisibleLabels() - expect(labels[0]).toBe('Switch Theme') - }) - it('preserves default section order with empty query', () => { render() @@ -221,8 +212,8 @@ describe('CommandPalette', () => { !!el.textContent, ).map(el => el.textContent) - // Default order: Navigation < Note < View < Appearance - expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance']) + // Default order: Navigation < Note < View + expect(groupHeaders).toEqual(['Navigation', 'Note', 'View']) }) }) }) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 94fb8e28..65ab60b4 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -65,7 +65,6 @@ interface EditorProps { onGoBack?: () => void onGoForward?: () => void leftPanelsCollapsed?: boolean - isDarkTheme?: boolean /** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */ rawToggleRef?: React.MutableRefObject<() => void> /** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */ @@ -228,7 +227,7 @@ export const Editor = memo(function Editor(props: EditorProps) { onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote, onContentChange, onSave, onTitleSync, canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed, - isDarkTheme, onFileCreated, onFileModified, onVaultChanged, + onFileCreated, onFileModified, onVaultChanged, onSetNoteIcon, onRemoveNoteIcon, isConflicted, onKeepMine, onKeepTheirs, } = props @@ -293,7 +292,6 @@ export const Editor = memo(function Editor(props: EditorProps) { onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} - isDarkTheme={isDarkTheme} rawLatestContentRef={rawLatestContentRef} onTitleChange={onTitleSync} onSetNoteIcon={onSetNoteIcon} diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index aacc9647..afc1e5b2 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -46,7 +46,6 @@ interface EditorContentProps { onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void vaultPath?: string - isDarkTheme?: boolean /** Ref updated by RawEditorView on every keystroke with the latest doc. */ rawLatestContentRef?: React.MutableRefObject /** Called when the user edits the dedicated title field. */ @@ -92,14 +91,13 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul } function RawModeEditorSection({ - rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef, + rawMode, activeTab, entries, onContentChange, onSave, latestContentRef, }: { rawMode: boolean activeTab: Tab | null entries: VaultEntry[] onContentChange?: (path: string, content: string) => void onSave?: () => void - isDark?: boolean latestContentRef?: React.MutableRefObject }) { if (!rawMode || !activeTab) return null @@ -111,7 +109,6 @@ function RawModeEditorSection({ entries={entries} onContentChange={onContentChange ?? (() => {})} onSave={onSave ?? (() => {})} - isDark={isDark} latestContentRef={latestContentRef} /> ) @@ -155,7 +152,7 @@ export function EditorContent({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, onRawContentChange, onSave, - onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, + onNavigateWikilink, onEditorChange, vaultPath, onDeleteNote, rawLatestContentRef, onTitleChange, onSetNoteIcon, onRemoveNoteIcon, isConflicted, onKeepMine, onKeepTheirs, @@ -202,7 +199,7 @@ export function EditorContent({ /> )} {diffMode && } - + {showEditor && activeTab && (
@@ -220,7 +217,7 @@ export function EditorContent({ />
- +
)} {isLoadingNewTab && showEditor && } diff --git a/src/components/RawEditorView.test.tsx b/src/components/RawEditorView.test.tsx index 92126901..a11080d4 100644 --- a/src/components/RawEditorView.test.tsx +++ b/src/components/RawEditorView.test.tsx @@ -142,15 +142,6 @@ describe('RawEditorView', () => { expect(cmScroller).toBeInTheDocument() }) - it('supports dark theme', () => { - render() - const container = screen.getByTestId('raw-editor-codemirror') - const cmEditor = container.querySelector('.cm-editor') - expect(cmEditor).toBeInTheDocument() - // CM applies dark theme via .cm-theme class — verify editor re-creates with isDark - expect(cmEditor?.querySelector('.cm-gutters')).toBeInTheDocument() - }) - it('cleans up CodeMirror view on unmount', () => { const { unmount } = render() const container = screen.getByTestId('raw-editor-codemirror') diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index ed51d2a8..d0216ee0 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -22,7 +22,6 @@ export interface RawEditorViewProps { entries: VaultEntry[] onContentChange: (path: string, content: string) => void onSave: () => void - isDark?: boolean /** Mutable ref updated on every keystroke with the latest doc string. * Allows the parent to flush debounced content before unmount. */ latestContentRef?: React.MutableRefObject @@ -38,7 +37,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null return { top: coords.bottom, left: coords.left } } -export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false, latestContentRef }: RawEditorViewProps) { +export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) { const containerRef = useRef(null) const debounceRef = useRef | null>(null) const pathRef = useRef(path) @@ -112,7 +111,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, return false }, [autocomplete]) - const viewRef = useCodeMirror(containerRef, content, isDark, { + const viewRef = useCodeMirror(containerRef, content, { onDocChange: handleDocChange, onCursorActivity: handleCursorActivity, onSave: handleSave, diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 4481282c..27d73b12 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -2,7 +2,6 @@ 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() @@ -36,16 +35,6 @@ const populatedSettings: Settings = { auto_pull_interval_minutes: 5, } -const mockThemeManager: ThemeManager = { - themes: [], - activeThemeId: null, - activeTheme: null, - isDark: false, - switchTheme: vi.fn(), - createTheme: vi.fn().mockResolvedValue('untitled'), - reloadThemes: vi.fn(), -} - describe('SettingsPanel', () => { const onSave = vi.fn() const onClose = vi.fn() @@ -56,14 +45,14 @@ describe('SettingsPanel', () => { it('renders nothing when not open', () => { const { container } = render( - + ) expect(container.innerHTML).toBe('') }) it('renders modal when open', () => { render( - + ) expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('AI Provider Keys')).toBeInTheDocument() @@ -72,7 +61,7 @@ describe('SettingsPanel', () => { it('shows two key fields with labels', () => { render( - + ) expect(screen.getByText('OpenAI')).toBeInTheDocument() expect(screen.getByText('Google AI')).toBeInTheDocument() @@ -80,7 +69,7 @@ describe('SettingsPanel', () => { it('populates fields from settings', () => { render( - + ) const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement @@ -91,7 +80,7 @@ describe('SettingsPanel', () => { it('calls onSave with trimmed keys on save', () => { render( - + ) const openaiInput = screen.getByTestId('settings-key-openai') fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } }) @@ -111,7 +100,7 @@ describe('SettingsPanel', () => { it('converts empty/whitespace keys to null', () => { render( - + ) // Clear the openai key field const openaiInput = screen.getByTestId('settings-key-openai') @@ -131,7 +120,7 @@ describe('SettingsPanel', () => { it('calls onClose when Cancel is clicked', () => { render( - + ) fireEvent.click(screen.getByText('Cancel')) expect(onClose).toHaveBeenCalled() @@ -139,7 +128,7 @@ describe('SettingsPanel', () => { it('calls onClose when close button is clicked', () => { render( - + ) fireEvent.click(screen.getByTitle('Close settings')) expect(onClose).toHaveBeenCalled() @@ -147,7 +136,7 @@ describe('SettingsPanel', () => { it('calls onClose on Escape key', () => { render( - + ) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' }) expect(onClose).toHaveBeenCalled() @@ -155,7 +144,7 @@ describe('SettingsPanel', () => { it('saves on Cmd+Enter', () => { render( - + ) const openaiInput = screen.getByTestId('settings-key-openai') fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } }) @@ -173,7 +162,7 @@ describe('SettingsPanel', () => { it('calls onClose when clicking backdrop', () => { render( - + ) fireEvent.click(screen.getByTestId('settings-panel')) expect(onClose).toHaveBeenCalled() @@ -181,7 +170,7 @@ describe('SettingsPanel', () => { it('clears a key field when X button is clicked', () => { render( - + ) const clearBtn = screen.getByTestId('clear-openai') fireEvent.click(clearBtn) @@ -192,14 +181,14 @@ describe('SettingsPanel', () => { it('shows keyboard shortcut hint in footer', () => { render( - + ) expect(screen.getByText(/to open settings/)).toBeInTheDocument() }) it('resets fields when reopened with different settings', () => { const { rerender } = render( - + ) // Verify initial state const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement @@ -207,11 +196,11 @@ describe('SettingsPanel', () => { // Close and reopen with different settings rerender( - + ) const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' } rerender( - + ) const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement expect(updatedInput.value).toBe('new-key') @@ -220,7 +209,7 @@ describe('SettingsPanel', () => { describe('GitHub OAuth section', () => { it('shows Login with GitHub button when not connected', () => { render( - + ) expect(screen.getByTestId('github-login')).toBeInTheDocument() expect(screen.getByText('Login with GitHub')).toBeInTheDocument() @@ -228,7 +217,7 @@ describe('SettingsPanel', () => { it('does not show GitHub token input field', () => { render( - + ) expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument() @@ -241,7 +230,7 @@ describe('SettingsPanel', () => { github_username: 'lucaong', } render( - + ) expect(screen.getByTestId('github-connected')).toBeInTheDocument() expect(screen.getByText('lucaong')).toBeInTheDocument() @@ -256,7 +245,7 @@ describe('SettingsPanel', () => { github_username: 'lucaong', } render( - + ) fireEvent.click(screen.getByTestId('github-disconnect')) @@ -285,7 +274,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -316,7 +305,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -337,7 +326,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -350,7 +339,7 @@ describe('SettingsPanel', () => { it('shows GitHub section description about connecting', () => { render( - + ) expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument() }) @@ -365,7 +354,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -387,7 +376,7 @@ describe('SettingsPanel', () => { }) render( - + ) const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 2c3e89e2..4d61dd20 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,16 +1,13 @@ import { useState, useRef, useCallback, useEffect } from 'react' -import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react' +import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react' import { GitHubDeviceFlow } from './GitHubDeviceFlow' -import { ThemePropertyEditor } from './ThemePropertyEditor' -import type { Settings, ThemeFile } from '../types' -import type { ThemeManager } from '../hooks/useThemeManager' +import type { Settings } from '../types' interface SettingsPanelProps { open: boolean settings: Settings onSave: (settings: Settings) => void onClose: () => void - themeManager: ThemeManager } @@ -116,12 +113,12 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi // --- Settings Panel --- -export function SettingsPanel({ open, settings, onSave, onClose, themeManager }: SettingsPanelProps) { +export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) { if (!open) return null - return + return } -function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit) { +function SettingsPanelInner({ settings, onSave, onClose }: Omit) { const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '') const [googleKey, setGoogleKey] = useState(settings.google_key ?? '') const [githubToken, setGithubToken] = useState(settings.github_token) @@ -195,7 +192,6 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit
@@ -228,7 +224,6 @@ interface SettingsBodyProps { onGitHubConnected: (token: string, username: string) => void onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void - themeManager: ThemeManager } function SettingsBody(props: SettingsBodyProps) { @@ -286,88 +281,10 @@ function SettingsBody(props: SettingsBodyProps) { - -
- -
) } -// --- Appearance Section --- - -function ColorSwatch({ color }: { color: string }) { - return ( -
- ) -} - -function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) { - const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted'] - return ( - - ) -} - -function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) { - const { themes, activeThemeId, switchTheme, createTheme } = themeManager - return ( - <> -
-
Appearance
-
- Choose a theme for your vault. Themes are stored in _themes/ and synced with Git. -
-
-
- {themes.map(theme => ( - switchTheme(theme.id)} /> - ))} -
- - - {activeThemeId && ( - <> -
- - - )} - - ) -} - function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { return (
) { } /** Single BlockNote editor view — content is swapped via replaceBlocks */ -export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: { +export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true }: { editor: ReturnType entries: VaultEntry[] onNavigateWikilink: (target: string) => void onChange?: () => void vaultPath?: string - isDarkTheme?: boolean editable?: boolean }) { const navigateRef = useRef(onNavigateWikilink) @@ -113,7 +112,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange )} diff --git a/src/components/ThemePropertyEditor.test.tsx b/src/components/ThemePropertyEditor.test.tsx deleted file mode 100644 index 3119d90e..00000000 --- a/src/components/ThemePropertyEditor.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent, act } from '@testing-library/react' -import { ThemePropertyEditor } from './ThemePropertyEditor' -import type { ThemeManager } from '../hooks/useThemeManager' - -function makeThemeManager(overrides: Partial = {}): ThemeManager { - return { - themes: [], - activeThemeId: '/vault/_themes/My Theme.md', - activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} }, - activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n', - isDark: false, - switchTheme: vi.fn(), - createTheme: vi.fn().mockResolvedValue(''), - reloadThemes: vi.fn(), - updateThemeProperty: vi.fn(), - ...overrides, - } -} - -describe('ThemePropertyEditor', () => { - it('shows message when no theme is active', () => { - const tm = makeThemeManager({ activeThemeId: null }) - render() - expect(screen.getByText(/Select a theme/)).toBeInTheDocument() - }) - - it('renders the editor when a theme is active', () => { - const tm = makeThemeManager() - render() - expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument() - }) - - it('shows section headers for all theme.json sections', () => { - const tm = makeThemeManager() - render() - expect(screen.getByText('Typography')).toBeInTheDocument() - expect(screen.getByText('Headings')).toBeInTheDocument() - expect(screen.getByText('Lists')).toBeInTheDocument() - expect(screen.getByText('Code Blocks')).toBeInTheDocument() - expect(screen.getByText('Blockquote')).toBeInTheDocument() - expect(screen.getByText('Table')).toBeInTheDocument() - expect(screen.getByText('Horizontal Rule')).toBeInTheDocument() - expect(screen.getByText('Colors')).toBeInTheDocument() - }) - - it('shows active theme name', () => { - const tm = makeThemeManager() - render() - expect(screen.getByText('My Theme')).toBeInTheDocument() - }) - - it('expands Typography section by default', () => { - const tm = makeThemeManager() - render() - // Typography section should be expanded, showing its properties - expect(screen.getByTestId('theme-input-editor-font-size')).toBeInTheDocument() - }) - - it('shows current theme value for overridden properties', () => { - const tm = makeThemeManager() - render() - const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement - expect(fontSizeInput.value).toBe('18') - }) - - it('shows default value for non-overridden properties', () => { - const tm = makeThemeManager() - render() - // editor-max-width is not in the theme content, so it should show the default (720) - const maxWidthInput = screen.getByTestId('theme-input-editor-max-width') as HTMLInputElement - expect(maxWidthInput.value).toBe('720') - }) - - it('calls updateThemeProperty on number input change', async () => { - vi.useFakeTimers() - const updateFn = vi.fn() - const tm = makeThemeManager({ updateThemeProperty: updateFn }) - render() - const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement - fireEvent.change(fontSizeInput, { target: { value: '16' } }) - - // Debounce fires after 300ms - act(() => { vi.advanceTimersByTime(300) }) - expect(updateFn).toHaveBeenCalledWith('editor-font-size', '16px') - vi.useRealTimers() - }) - - it('expands collapsed sections on click', () => { - const tm = makeThemeManager() - render() - // Lists section should be collapsed by default - expect(screen.queryByTestId('theme-input-lists-bullet-size')).not.toBeInTheDocument() - - // Click to expand - fireEvent.click(screen.getByTestId('theme-section-lists-toggle')) - expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument() - }) - - it('toggles section via keyboard Enter', () => { - const tm = makeThemeManager() - render() - const toggle = screen.getByTestId('theme-section-lists-toggle') - fireEvent.keyDown(toggle, { key: 'Enter' }) - expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument() - }) - - it('toggles section via keyboard Space', () => { - const tm = makeThemeManager() - render() - const toggle = screen.getByTestId('theme-section-lists-toggle') - fireEvent.keyDown(toggle, { key: ' ' }) - expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument() - }) - - it('shows heading subsections after expanding Headings', () => { - const tm = makeThemeManager() - render() - fireEvent.click(screen.getByTestId('theme-section-headings-toggle')) - expect(screen.getByText('Heading 1')).toBeInTheDocument() - expect(screen.getByText('Heading 2')).toBeInTheDocument() - expect(screen.getByText('Heading 3')).toBeInTheDocument() - expect(screen.getByText('Heading 4')).toBeInTheDocument() - }) - - it('shows unit label for numeric properties', () => { - const tm = makeThemeManager() - render() - // "px" should appear near Font Size input - const container = screen.getByTestId('theme-input-editor-font-size').parentElement! - expect(container.textContent).toContain('px') - }) -}) diff --git a/src/components/ThemePropertyEditor.tsx b/src/components/ThemePropertyEditor.tsx deleted file mode 100644 index c1820ff5..00000000 --- a/src/components/ThemePropertyEditor.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useState, useCallback, useMemo, useRef } from 'react' -import { CaretRight } from '@phosphor-icons/react' -import { ColorSwatch } from './ColorInput' -import { getThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from '../utils/themeSchema' -import type { ThemeProperty, ThemeSection, ThemeSubsection } from '../utils/themeSchema' -import type { ThemeManager } from '../hooks/useThemeManager' -import { parseFrontmatter } from '../utils/frontmatter' -import { isValidCssColor } from '../utils/colorUtils' - -/** Extract current theme property values from frontmatter content. */ -function useThemeValues(content: string | undefined): Record { - return useMemo(() => { - if (!content) return {} - const fm = parseFrontmatter(content) - const result: Record = {} - for (const [key, value] of Object.entries(fm)) { - if (typeof value === 'string') result[key] = value - else if (typeof value === 'number') result[key] = String(value) - else if (typeof value === 'boolean') result[key] = String(value) - } - return result - }, [content]) -} - -// --- Individual input components --- - -function NumberInput({ property, value, onChange }: { - property: ThemeProperty - value: string | number - onChange: (val: string) => void -}) { - const numericValue = typeof value === 'number' ? value : parseFloat(String(value)) || 0 - const debounceRef = useRef>(undefined) - - const handleChange = useCallback((e: React.ChangeEvent) => { - const raw = e.target.value - if (raw === '' || raw === '-') return - const num = parseFloat(raw) - if (isNaN(num)) return - if (property.min !== undefined && num < property.min) return - clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => { - onChange(formatValueForFrontmatter(num, property)) - }, 300) - }, [onChange, property]) - - return ( -
- - {property.unit && ( - {property.unit} - )} -
- ) -} - -function ColorInput({ property, value, onChange }: { - property: ThemeProperty - value: string - onChange: (val: string) => void -}) { - const [localValue, setLocalValue] = useState(value) - const debounceRef = useRef>(undefined) - const showSwatch = isValidCssColor(localValue) - - const handleTextChange = useCallback((e: React.ChangeEvent) => { - const newVal = e.target.value - setLocalValue(newVal) - clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => onChange(newVal), 300) - }, [onChange]) - - const handlePickerChange = useCallback((hex: string) => { - setLocalValue(hex) - onChange(hex) - }, [onChange]) - - return ( -
- {showSwatch && } - -
- ) -} - -function SelectInput({ property, value, onChange }: { - property: ThemeProperty - value: string - onChange: (val: string) => void -}) { - return ( - - ) -} - -function TextInput({ property, value, onChange }: { - property: ThemeProperty - value: string - onChange: (val: string) => void -}) { - const debounceRef = useRef>(undefined) - - const handleChange = useCallback((e: React.ChangeEvent) => { - const newVal = e.target.value - clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => onChange(newVal), 500) - }, [onChange]) - - return ( - - ) -} - -// --- Property row --- - -function PropertyRow({ property, currentValue, onUpdate }: { - property: ThemeProperty - currentValue: string | undefined - onUpdate: (cssVar: string, value: string) => void -}) { - const displayValue = currentValue !== undefined - ? parseValueFromFrontmatter(currentValue, property) - : property.defaultValue - const isPlaceholder = currentValue === undefined - - const handleChange = useCallback((val: string) => { - onUpdate(property.cssVar, val) - }, [property.cssVar, onUpdate]) - - return ( -
- -
- {property.inputType === 'number' && ( - - )} - {property.inputType === 'color' && ( - - )} - {property.inputType === 'select' && ( - - )} - {property.inputType === 'text' && ( - - )} -
-
- ) -} - -// --- Collapsible section --- - -function CollapsibleSection({ label, defaultOpen, children, testId }: { - label: string - defaultOpen?: boolean - children: React.ReactNode - testId?: string -}) { - const [open, setOpen] = useState(defaultOpen ?? false) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setOpen(prev => !prev) - } - }, []) - - return ( -
- - {open && ( -
- {children} -
- )} -
- ) -} - -// --- Section renderers --- - -function SubsectionBlock({ subsection, currentValues, onUpdate }: { - subsection: ThemeSubsection - currentValues: Record - onUpdate: (cssVar: string, value: string) => void -}) { - return ( - - {subsection.properties.map(prop => ( - - ))} - - ) -} - -function SectionBlock({ section, currentValues, onUpdate }: { - section: ThemeSection - currentValues: Record - onUpdate: (cssVar: string, value: string) => void -}) { - return ( - - {section.properties.map(prop => ( - - ))} - {section.subsections.map(sub => ( - - ))} - - ) -} - -// --- Main component --- - -export function ThemePropertyEditor({ themeManager }: { themeManager: ThemeManager }) { - const schema = useMemo(() => getThemeSchema(), []) - const currentValues = useThemeValues(themeManager.activeThemeContent) - - const handleUpdate = useCallback((cssVar: string, value: string) => { - themeManager.updateThemeProperty(cssVar, value) - }, [themeManager]) - - if (!themeManager.activeThemeId) { - return ( -
- Select a theme to customize its properties. -
- ) - } - - return ( -
-
- Editing: {themeManager.activeTheme?.name ?? 'Theme'} -
- {schema.map(section => ( - - ))} -
- ) -} diff --git a/src/extensions/frontmatterHighlight.ts b/src/extensions/frontmatterHighlight.ts index ccccd763..1af79e1c 100644 --- a/src/extensions/frontmatterHighlight.ts +++ b/src/extensions/frontmatterHighlight.ts @@ -85,11 +85,11 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass( { decorations: (v) => v.decorations }, ) -export function frontmatterHighlightTheme(isDark: boolean) { - const keyColor = isDark ? '#f0a0a0' : '#c9383e' - const valueColor = isDark ? '#a0d0a0' : '#2a7e4f' - const delimiterColor = isDark ? '#f0a0a0' : '#c9383e' - const headingColor = isDark ? '#88c0ff' : '#0969da' +export function frontmatterHighlightTheme() { + const keyColor = '#c9383e' + const valueColor = '#2a7e4f' + const delimiterColor = '#c9383e' + const headingColor = '#0969da' return EditorView.baseTheme({ '.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index e6cc49ed..9b60fc4b 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -4,7 +4,7 @@ import { useCommandRegistry } from './useCommandRegistry' import type { CommandAction } from './useCommandRegistry' import { useKeyboardNavigation } from './useKeyboardNavigation' import { useMenuEvents } from './useMenuEvents' -import type { SidebarSelection, SidebarFilter, ThemeFile, VaultEntry } from '../types' +import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' @@ -51,18 +51,12 @@ interface AppCommandsConfig { onGoForward?: () => void canGoBack?: boolean canGoForward?: boolean - themes?: ThemeFile[] - activeThemeId?: string | null - onSwitchTheme?: (themeId: string) => void - onCreateTheme?: () => void - onOpenTheme?: (themeId: string) => void onOpenVault?: () => void onCreateType?: () => void onToggleAIChat?: () => void onCheckForUpdates?: () => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void - onRestoreDefaultThemes?: () => void isGettingStartedHidden?: boolean vaultCount?: number mcpStatus?: string @@ -157,8 +151,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onOpenVault: config.onOpenVault, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, - onCreateTheme: config.onCreateTheme, - onRestoreDefaultThemes: config.onRestoreDefaultThemes, onCommitPush: config.onCommitPush, onPull: config.onPull, onResolveConflicts: config.onResolveConflicts, @@ -210,16 +202,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, - onOpenTheme: config.onOpenTheme, onCheckForUpdates: config.onCheckForUpdates, onCreateType: config.onCreateType, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, - onRestoreDefaultThemes: config.onRestoreDefaultThemes, isGettingStartedHidden: config.isGettingStartedHidden, vaultCount: config.vaultCount, mcpStatus: config.mcpStatus, diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index aa1d05ed..38178228 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -14,13 +14,13 @@ export interface CodeMirrorCallbacks { onEscape: () => boolean } -function buildBaseTheme(isDark: boolean) { - const bg = isDark ? '#1e1e1e' : '#ffffff' - const fg = isDark ? '#d4d4d4' : '#1e1e1e' - const gutterBg = isDark ? '#1e1e1e' : '#ffffff' - const gutterColor = isDark ? '#555' : '#aaa' - const activeLineBg = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,100,255,0.06)' - const gutterBorder = isDark ? '#333' : '#eee' +function buildBaseTheme() { + const bg = '#ffffff' + const fg = '#1e1e1e' + const gutterBg = '#ffffff' + const gutterColor = '#aaa' + const activeLineBg = 'rgba(0,100,255,0.06)' + const gutterBorder = '#eee' return EditorView.theme({ '&': { @@ -60,7 +60,7 @@ function buildBaseTheme(isDark: boolean) { }, '&.cm-focused': { outline: 'none' }, '.cm-line': { padding: '0' }, - }, { dark: isDark }) + }) } function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) { @@ -76,7 +76,6 @@ function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) { export function useCodeMirror( containerRef: React.RefObject, content: string, - isDark: boolean, callbacks: CodeMirrorCallbacks, ) { const viewRef = useRef(null) @@ -109,8 +108,8 @@ export function useCodeMirror( history(), keymap.of([...defaultKeymap, ...historyKeymap]), buildSaveKeymap(callbacksRef), - buildBaseTheme(isDark), - frontmatterHighlightTheme(isDark), + buildBaseTheme(), + frontmatterHighlightTheme(), frontmatterHighlightPlugin, zoomCursorFix(), EditorView.updateListener.of((update) => { @@ -144,9 +143,8 @@ export function useCodeMirror( view.destroy() viewRef.current = null } - // Re-create editor when isDark changes (theme is baked into extensions) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDark]) + }, []) return viewRef } diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 398c2810..ccc0eade 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react' -import type { SidebarSelection, ThemeFile, VaultEntry } from '../types' +import type { SidebarSelection, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' -export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings' +export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings' export interface CommandAction { id: string @@ -64,14 +64,8 @@ interface CommandRegistryConfig { onGoForward?: () => void canGoBack?: boolean canGoForward?: boolean - themes?: ThemeFile[] - activeThemeId?: string | null - onSwitchTheme?: (themeId: string) => void - onCreateTheme?: () => void - onOpenTheme?: (themeId: string) => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void - onRestoreDefaultThemes?: () => void isGettingStartedHidden?: boolean vaultCount?: number /** Current selection — used to scope filter pill commands to section group views. */ @@ -103,7 +97,7 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] { return Array.from(typeSet).sort() } -const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Appearance', 'Settings'] +const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings'] export function groupSortKey(group: CommandGroup): number { return GROUP_ORDER.indexOf(group) @@ -160,43 +154,6 @@ export function buildViewCommands( ] } -export function buildThemeCommands( - themes: ThemeFile[] | undefined, - activeThemeId: string | null | undefined, - onSwitchTheme: ((themeId: string) => void) | undefined, - onCreateTheme: (() => void) | undefined, - onOpenTheme: ((themeId: string) => void) | undefined, -): CommandAction[] { - const cmds: CommandAction[] = [] - for (const t of (themes ?? [])) { - cmds.push({ - 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 (onOpenTheme) { - cmds.push({ - id: `open-theme-${t.id}`, - label: `Edit ${t.name} Theme`, - group: 'Appearance' as CommandGroup, - keywords: ['theme', 'edit', 'open', 'appearance', t.name.toLowerCase()], - enabled: true, - execute: () => onOpenTheme(t.id), - }) - } - } - if (onCreateTheme) { - cmds.push({ - id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup, - keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme, - }) - } - return cmds -} - export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] { const { activeTabPath, entries, modifiedCount, @@ -207,10 +164,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onCheckForUpdates, onCreateType, - onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount, + onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, onEmptyTrash, trashedCount, onReindexVault, @@ -293,10 +249,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction // View ...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset), - // Appearance - ...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme), - { id: 'restore-default-themes', label: 'Restore Default Themes', group: 'Appearance', keywords: ['theme', 'reset', 'restore', 'default', 'fix', 'missing'], enabled: true, execute: () => onRestoreDefaultThemes?.() }, - // Settings { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, { id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() }, @@ -327,7 +279,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes, + vaultTypes, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, onEmptyTrash, trashedCount, onReindexVault, onReloadVault, onRepairVault, diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index 36ef5a0d..b500d632 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -28,8 +28,6 @@ function makeHandlers(): MenuEventHandlers { onOpenVault: vi.fn(), onRemoveActiveVault: vi.fn(), onRestoreGettingStarted: vi.fn(), - onCreateTheme: vi.fn(), - onRestoreDefaultThemes: vi.fn(), onCommitPush: vi.fn(), onPull: vi.fn(), onResolveConflicts: vi.fn(), @@ -268,18 +266,6 @@ describe('dispatchMenuEvent', () => { expect(h.onRestoreGettingStarted).toHaveBeenCalled() }) - it('vault-new-theme triggers create theme', () => { - const h = makeHandlers() - dispatchMenuEvent('vault-new-theme', h) - expect(h.onCreateTheme).toHaveBeenCalled() - }) - - it('vault-restore-default-themes triggers restore default themes', () => { - const h = makeHandlers() - dispatchMenuEvent('vault-restore-default-themes', h) - expect(h.onRestoreDefaultThemes).toHaveBeenCalled() - }) - it('vault-commit-push triggers commit push', () => { const h = makeHandlers() dispatchMenuEvent('vault-commit-push', h) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 078d55c6..e99ef94e 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -29,8 +29,6 @@ export interface MenuEventHandlers { onOpenVault?: () => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void - onCreateTheme?: () => void - onRestoreDefaultThemes?: () => void onCommitPush?: () => void onPull?: () => void onResolveConflicts?: () => void @@ -84,7 +82,6 @@ type OptionalHandler = | 'onGoBack' | 'onGoForward' | 'onCheckForUpdates' | 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' - | 'onCreateTheme' | 'onRestoreDefaultThemes' | 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault' | 'onEmptyTrash' | 'onReopenClosedTab' @@ -101,8 +98,6 @@ const OPTIONAL_EVENT_MAP: Record = { 'vault-open': 'onOpenVault', 'vault-remove': 'onRemoveActiveVault', 'vault-restore-getting-started': 'onRestoreGettingStarted', - 'vault-new-theme': 'onCreateTheme', - 'vault-restore-default-themes': 'onRestoreDefaultThemes', 'vault-commit-push': 'onCommitPush', 'vault-pull': 'onPull', 'vault-resolve-conflicts': 'onResolveConflicts', diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts deleted file mode 100644 index a4e64f1c..00000000 --- a/src/hooks/useThemeManager.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, act, waitFor } from '@testing-library/react' -import type { VaultEntry } from '../types' - -const THEME_PATH_DEFAULT = '/vault/theme/default.md' -const THEME_PATH_DARK = '/vault/theme/dark.md' - -const DEFAULT_THEME_CONTENT = `--- -type: Theme -Description: Light theme -background: "#FFFFFF" -foreground: "#37352F" -primary: "#155DFF" -sidebar: "#F7F6F3" -text-primary: "#37352F" ---- - -# Default Theme -` - -const DARK_THEME_CONTENT = `--- -type: Theme -Description: Dark theme -background: "#0f0f1a" -foreground: "#e0e0e0" -primary: "#155DFF" -sidebar: "#1a1a2e" -text-primary: "#e0e0e0" ---- - -# Dark Theme -` - -function makeThemeEntry(path: string, title: string): VaultEntry { - return { - path, - filename: path.split('/').pop()!, - title, - isA: 'Theme', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - archived: false, - trashed: false, - trashedAt: null, - modifiedAt: null, - createdAt: null, - fileSize: 0, - snippet: '', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - sidebarLabel: null, - template: null, sort: null, - outgoingLinks: [], - properties: {}, - } -} - -const defaultEntry = makeThemeEntry(THEME_PATH_DEFAULT, 'Default Theme') -const darkEntry = makeThemeEntry(THEME_PATH_DARK, 'Dark Theme') - -const mockInvokeFn = vi.fn(async (cmd: string, args?: Record) => { - if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT } - if (cmd === 'get_note_content') { - const path = args?.path as string | undefined - if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT - if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT - return '' - } - if (cmd === 'set_active_theme') return null - if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md' - return null -}) - -vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) -vi.mock('../mock-tauri', () => ({ - isTauri: () => false, - mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), -})) - -const { useThemeManager, extractCssVars, isColorDark } = await import('./useThemeManager') - -describe('extractCssVars', () => { - it('extracts color variables from frontmatter', () => { - const vars = extractCssVars(DEFAULT_THEME_CONTENT) - expect(vars['--background']).toBe('#FFFFFF') - expect(vars['--foreground']).toBe('#37352F') - expect(vars['--primary']).toBe('#155DFF') - }) - - it('excludes metadata keys', () => { - const vars = extractCssVars(DEFAULT_THEME_CONTENT) - expect('--Is A' in vars).toBe(false) - expect('--Description' in vars).toBe(false) - }) -}) - -describe('isColorDark', () => { - it('identifies dark colors', () => { - expect(isColorDark('#000000')).toBe(true) - expect(isColorDark('#0f0f1a')).toBe(true) - expect(isColorDark('#1a1a2e')).toBe(true) - }) - - it('identifies light colors', () => { - expect(isColorDark('#FFFFFF')).toBe(false) - expect(isColorDark('#F7F6F3')).toBe(false) - expect(isColorDark('#E0E0E0')).toBe(false) - }) - - it('returns false for invalid hex', () => { - expect(isColorDark('')).toBe(false) - expect(isColorDark('#abc')).toBe(false) - expect(isColorDark('red')).toBe(false) - }) -}) - -describe('useThemeManager', () => { - const entries = [defaultEntry, darkEntry] - const allContent: Record = {} - - beforeEach(() => { - vi.clearAllMocks() - mockInvokeFn.mockImplementation(async (cmd: string, args?: Record) => { - if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT } - if (cmd === 'get_note_content') { - const path = args?.path as string | undefined - if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT - if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT - return '' - } - if (cmd === 'set_active_theme') return null - if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md' - return null - }) - document.documentElement.style.cssText = '' - }) - - it('builds themes list from vault entries with isA === Theme', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - expect(result.current.themes[0].name).toBe('Default Theme') - expect(result.current.themes[0].id).toBe(THEME_PATH_DEFAULT) - }) - - it('loads active theme from vault settings on mount', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - expect(result.current.activeTheme?.name).toBe('Default Theme') - }) - - it('applies CSS vars from theme note content', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - 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('#37352F') - expect(root.style.getPropertyValue('--primary')).toBe('#155DFF') - }) - - it('returns empty state when vaultPath is null', async () => { - const { result } = renderHook(() => - useThemeManager(null, entries, allContent) - ) - await new Promise(r => setTimeout(r, 50)) - expect(result.current.activeThemeId).toBeNull() - expect(result.current.activeTheme).toBeNull() - expect(mockInvokeFn).not.toHaveBeenCalled() - }) - - it('excludes trashed entries from themes list', async () => { - const trashedEntry = { ...darkEntry, trashed: true } - const { result } = renderHook(() => - useThemeManager('/vault', [defaultEntry, trashedEntry], allContent) - ) - await waitFor(() => { - expect(result.current.themes).toHaveLength(1) - }) - expect(result.current.themes[0].name).toBe('Default Theme') - }) - - it('switchTheme calls set_active_theme and updates activeThemeId', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - - await act(async () => { - await result.current.switchTheme(THEME_PATH_DARK) - }) - - expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { - vaultPath: '/vault', themeId: THEME_PATH_DARK, - }) - expect(result.current.activeThemeId).toBe(THEME_PATH_DARK) - }) - - it('clears old CSS vars and applies new theme on switch', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF') - }) - - await act(async () => { - await result.current.switchTheme(THEME_PATH_DARK) - }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a') - }) - }) - - it('createTheme calls create_vault_theme and switches to new theme', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - - let newPath = '' - await act(async () => { - newPath = await result.current.createTheme('My Theme') - }) - - expect(newPath).toBe('/vault/theme/untitled.md') - expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', { - vaultPath: '/vault', name: 'My Theme', - }) - expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md') - }) - - it('createTheme passes null name when none provided', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - - await act(async () => { - await result.current.createTheme() - }) - - expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', { - vaultPath: '/vault', name: null, - }) - }) - - it('falls back when active theme is trashed', async () => { - const { result, rerender } = renderHook( - ({ ents }) => useThemeManager('/vault', ents, allContent), - { initialProps: { ents: entries } }, - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - - const trashedDefault = { ...defaultEntry, trashed: true } - rerender({ ents: [trashedDefault, darkEntry] }) - - await waitFor(() => { - expect(result.current.activeThemeId).toBeNull() - }) - // CSS vars are cleared from tracked applied vars — DOM state depends on prior apply - }) - - it('handles load failure gracefully', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockInvokeFn.mockRejectedValue(new Error('disk error')) - - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await new Promise(r => setTimeout(r, 50)) - - expect(result.current.activeThemeId).toBeNull() - warnSpy.mockRestore() - }) - - it('handles switchTheme failure gracefully', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - - mockInvokeFn.mockRejectedValueOnce(new Error('permission denied')) - - await act(async () => { - await result.current.switchTheme(THEME_PATH_DARK) - }) - - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - errorSpy.mockRestore() - }) - - it('switchTheme is a no-op when vaultPath is null', async () => { - const { result } = renderHook(() => - useThemeManager(null, entries, allContent) - ) - await act(async () => { - await result.current.switchTheme(THEME_PATH_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, entries, allContent) - ) - let newPath = '' - await act(async () => { - newPath = await result.current.createTheme() - }) - expect(newPath).toBe('') - }) - - it('reloadThemes re-reads vault settings', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - - const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length - - await act(async () => { - await result.current.reloadThemes() - }) - - const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length - expect(afterCalls).toBe(initialCalls + 1) - }) - - it('clears stale theme ID that does not match any known theme', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'get_vault_settings') return { theme: 'untitled-2' } - if (cmd === 'set_active_theme') return null - if (cmd === 'ensure_vault_themes') return null - return null - }) - - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - - // Stale ID "untitled-2" doesn't match any theme path — should be cleared - await waitFor(() => { - expect(result.current.activeThemeId).toBeNull() - }) - expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { - vaultPath: '/vault', themeId: null, - }) - }) - - it('sets color-scheme to light for light theme', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.activeTheme).not.toBeNull() - }) - - const root = document.documentElement - expect(root.style.getPropertyValue('color-scheme')).toBe('light') - expect(root.dataset.themeMode).toBe('light') - }) - - it('sets color-scheme to dark for dark theme', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - - await act(async () => { - await result.current.switchTheme(THEME_PATH_DARK) - }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark') - }) - expect(document.documentElement.dataset.themeMode).toBe('dark') - }) - - it('isDark detects dark theme from cached content', async () => { - const contentWithColors = { - [THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT, - [THEME_PATH_DARK]: DARK_THEME_CONTENT, - } - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DARK } - if (cmd === 'set_active_theme') return null - if (cmd === 'get_note_content') return DARK_THEME_CONTENT - return null - }) - - const { result } = renderHook(() => - useThemeManager('/vault', entries, contentWithColors) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DARK) - }) - await waitFor(() => { - expect(result.current.isDark).toBe(true) - }) - }) - - it('isDark is false for light theme', async () => { - const contentWithColors = { - [THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT, - } - - const { result } = renderHook(() => - useThemeManager('/vault', entries, contentWithColors) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - // Light theme isDark should be false (default state is false, so this is stable) - expect(result.current.isDark).toBe(false) - }) - - it('notifyThemeSaved updates CSS vars when active theme is saved', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF') - - const updatedContent = `--- -type: Theme -Description: Light theme -background: "#1a1a2e" -foreground: "#e0e0e0" -primary: "#155DFF" -sidebar: "#2a2a3e" -text-primary: "#e0e0e0" ---- - -# Default Theme -` - act(() => { - result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent) - }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e') - }) - expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e') - }) - - it('notifyThemeSaved updates bullet-size and bullet-color CSS vars', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - - const updatedContent = `--- -type: Theme -Description: Light theme -background: "#FFFFFF" -foreground: "#37352F" -primary: "#155DFF" -sidebar: "#F7F6F3" -text-primary: "#37352F" -lists-bullet-size: 32px -lists-bullet-color: "#FF0000" ---- - -# Default Theme -` - act(() => { - result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent) - }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--lists-bullet-size')).toBe('32px') - }) - expect(document.documentElement.style.getPropertyValue('--lists-bullet-color')).toBe('#FF0000') - }) - - it('notifyThemeSaved is a no-op for non-active theme path', async () => { - const { result } = renderHook(() => - useThemeManager('/vault', entries, allContent) - ) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF') - - act(() => { - result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT) - }) - - // Background should still be the default theme's white, not dark - await new Promise(r => setTimeout(r, 50)) - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF') - }) - - it('stale async fetch does not overwrite live-reload content', async () => { - // Simulate a slow get_note_content that resolves AFTER notifyThemeSaved - let resolveSlowFetch!: (v: string) => void - let fetchCount = 0 - mockInvokeFn.mockImplementation(async (cmd: string, args?: Record) => { - if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT } - if (cmd === 'get_note_content') { - fetchCount++ - if (fetchCount === 1) { - // First fetch: return a pending promise (simulates slow disk) - return new Promise(r => { resolveSlowFetch = r }) - } - const path = args?.path as string | undefined - if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT - return '' - } - if (cmd === 'set_active_theme') return null - return null - }) - - const { result } = renderHook(() => useThemeManager('/vault', entries, allContent)) - await waitFor(() => { - expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) - }) - - // Before slow fetch resolves, user saves the theme note (live-reload via Cmd+S) - const updatedContent = `--- -type: Theme -background: "#FF0000" ---- -` - act(() => { - result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent) - }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000') - }) - - // Now the stale fetch resolves with old content — should be ignored - resolveSlowFetch(DEFAULT_THEME_CONTENT) - await new Promise(r => setTimeout(r, 50)) - - // Background should still be the live-reload value, not the stale fetch - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000') - }) - - it('calls ensure_vault_themes on mount with vaultPath', async () => { - renderHook(() => useThemeManager('/vault', entries, allContent)) - await waitFor(() => { - expect(mockInvokeFn).toHaveBeenCalledWith('ensure_vault_themes', { vaultPath: '/vault' }) - }) - }) - - it('does not call ensure_vault_themes when vaultPath is null', async () => { - renderHook(() => useThemeManager(null, entries, allContent)) - await new Promise(r => setTimeout(r, 50)) - expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything()) - }) - - it('skips theme reload on focus within 30s cooldown', async () => { - const now = vi.spyOn(Date, 'now') - let clock = 1000 - now.mockImplementation(() => clock) - - renderHook(() => useThemeManager('/vault', entries, allContent)) - await waitFor(() => { - expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' }) - }) - - mockInvokeFn.mockClear() - - // Focus within cooldown — should NOT reload settings - clock += 5_000 - await act(async () => { window.dispatchEvent(new Event('focus')) }) - const settingsCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'get_vault_settings') - expect(settingsCalls).toHaveLength(0) - - // Focus after cooldown — should reload - clock += 30_000 - await act(async () => { window.dispatchEvent(new Event('focus')) }) - await waitFor(() => { - expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' }) - }) - - now.mockRestore() - }) -}) diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts deleted file mode 100644 index d95c1d9d..00000000 --- a/src/hooks/useThemeManager.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke } from '../mock-tauri' -import { parseFrontmatter } from '../utils/frontmatter' -import type { ThemeFile, VaultEntry, VaultSettings } from '../types' - -function tauriCall(command: string, args: Record): Promise { - return isTauri() ? invoke(command, args) : mockInvoke(command, args) -} - -/** Frontmatter keys that are metadata — not CSS custom properties. */ -const NON_THEME_KEYS = new Set([ - 'Is A', 'type', 'is_a', 'is a', - 'Name', 'name', 'title', 'Title', - 'Description', 'description', - 'Archived', 'archived', - 'Trashed', 'trashed', - 'Trashed at', 'trashed at', 'trashed_at', - 'Created at', 'created at', 'created_at', - 'Created time', 'created_time', - 'Owner', 'owner', - 'Status', 'status', - 'Cadence', 'cadence', - 'aliases', - 'Belongs to', 'belongs_to', 'belongs to', - 'Related to', 'related_to', 'related to', -]) - -/** Extract CSS custom properties from a theme note's frontmatter content. */ -export function extractCssVars(content: string): Record { - const fm = parseFrontmatter(content) - const vars: Record = {} - for (const [key, value] of Object.entries(fm)) { - if (NON_THEME_KEYS.has(key)) continue - if (typeof value === 'string' && value) { - vars[`--${key}`] = value - } else if (typeof value === 'number') { - vars[`--${key}`] = String(value) - } - } - return vars -} - -/** Extract bare colors (without -- prefix) for ThemeFile.colors from content. */ -function extractColorsFromContent(content: string): Record { - const fm = parseFrontmatter(content) - const colors: Record = {} - for (const [key, value] of Object.entries(fm)) { - if (NON_THEME_KEYS.has(key)) continue - if (typeof value === 'string' && value.startsWith('#')) { - colors[key] = value - } - } - return colors -} - -/** Check if a hex color is perceptually dark (luminance < 0.5). */ -export function isColorDark(hex: string): boolean { - if (!hex.startsWith('#') || hex.length < 7) return false - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 -} - -/** Update color-scheme and data-theme-mode on document root based on --background. */ -function updateColorScheme(vars: Record): void { - const bg = vars['--background'] - if (!bg) return - const dark = isColorDark(bg) - const root = document.documentElement - root.style.setProperty('color-scheme', dark ? 'dark' : 'light') - root.dataset.themeMode = dark ? 'dark' : 'light' -} - -function clearColorScheme(): void { - const root = document.documentElement - root.style.removeProperty('color-scheme') - delete root.dataset.themeMode -} - -const THEME_STYLE_ID = 'laputa-theme-vars' - -function getOrCreateThemeStyle(): HTMLStyleElement { - let el = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null - if (!el) { - el = document.createElement('style') - el.id = THEME_STYLE_ID - document.head.appendChild(el) - } - return el -} - -function applyVarsToDom(vars: Record): void { - const root = document.documentElement - for (const [key, value] of Object.entries(vars)) { - root.style.setProperty(key, value) - } - updateColorScheme(vars) - // WKWebView doesn't invalidate ::before/::after pseudo-element styles when - // CSS custom properties change via inline styles alone — `void offsetHeight` - // triggers layout reflow but not style recalculation on pseudo-elements. - // Replacing a