refactor: remove theming system entirely
Remove all theme switching, creation, and management functionality from the app — backend (Rust theme module, Tauri commands, menu items, startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor, themeSchema, command palette Appearance group, settings panel appearance section, isDarkTheme prop chain), mock data, and all related tests. Editor styling via theme.json/EditorTheme.css is preserved (not user theming). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<crate::mcp::McpStatus, String> {
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Theme commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, 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<ThemeFile, String> {
|
||||
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<VaultSettings, String> {
|
||||
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<String>) -> 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<String>) -> Result<String, String> {
|
||||
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<String>) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -583,11 +526,6 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<String, String>,
|
||||
#[serde(default)]
|
||||
pub typography: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub spacing: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultSettings {
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
/// List all theme files in _themes/ directory of the vault (legacy).
|
||||
/// Returns an empty list if the directory doesn't exist.
|
||||
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if !themes_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut themes = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
match parse_theme_file(&path) {
|
||||
Ok(theme) => themes.push(theme),
|
||||
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(themes)
|
||||
}
|
||||
|
||||
/// Parse a single theme JSON file.
|
||||
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
|
||||
let id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid theme filename".to_string())?;
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
|
||||
let mut theme: ThemeFile = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
|
||||
|
||||
theme.id = id;
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Read vault-level settings from .laputa/settings.json.
|
||||
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
|
||||
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
return Ok(VaultSettings::default());
|
||||
}
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Save vault-level settings to .laputa/settings.json.
|
||||
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
|
||||
let laputa_dir = Path::new(vault_path).join(".laputa");
|
||||
fs::create_dir_all(&laputa_dir)
|
||||
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
|
||||
fs::write(laputa_dir.join("settings.json"), json)
|
||||
.map_err(|e| format!("Failed to write vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Set the active theme in vault settings. 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<ThemeFile, String> {
|
||||
let path = Path::new(vault_path)
|
||||
.join("_themes")
|
||||
.join(format!("{theme_id}.json"));
|
||||
if !path.exists() {
|
||||
return Err(format!("Theme not found: {theme_id}"));
|
||||
}
|
||||
parse_theme_file(&path)
|
||||
}
|
||||
|
||||
#[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:"));
|
||||
}
|
||||
}
|
||||
@@ -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<bool, String> {
|
||||
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<String, String> {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -402,23 +402,6 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.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();
|
||||
|
||||
47
src/App.tsx
47
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<string>('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<string>('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}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Editor } from './components/Editor'
|
||||
import { Toast } from './components/Toast'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { getNoteWindowParams } from './utils/windowMode'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import type { VaultEntry } from './types'
|
||||
@@ -53,9 +52,7 @@ export default function NoteWindow() {
|
||||
return () => { 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(() => {
|
||||
|
||||
@@ -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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string | null>
|
||||
/** 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<string | null>
|
||||
}) {
|
||||
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 && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div className="editor-scroll-area">
|
||||
<div className="title-section">
|
||||
@@ -220,7 +217,7 @@ export function EditorContent({
|
||||
/>
|
||||
<div className="title-section__separator" />
|
||||
</div>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
|
||||
@@ -142,15 +142,6 @@ describe('RawEditorView', () => {
|
||||
expect(cmScroller).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports dark theme', () => {
|
||||
render(<RawEditorView {...defaultProps} isDark />)
|
||||
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(<RawEditorView {...defaultProps} />)
|
||||
const container = screen.getByTestId('raw-editor-codemirror')
|
||||
|
||||
@@ -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<string | null>
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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,
|
||||
|
||||
@@ -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(
|
||||
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders modal when open', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google AI')).toBeInTheDocument()
|
||||
@@ -80,7 +69,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('populates fields from settings', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
// 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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -139,7 +128,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Close settings'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -147,7 +136,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose on Escape key', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -155,7 +144,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('saves on Cmd+Enter', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const clearBtn = screen.getByTestId('clear-openai')
|
||||
fireEvent.click(clearBtn)
|
||||
@@ -192,14 +181,14 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('shows keyboard shortcut hint in footer', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets fields when reopened with different settings', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
// 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(
|
||||
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' }
|
||||
rerender(
|
||||
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('github-connected')).toBeInTheDocument()
|
||||
expect(screen.getByText('lucaong')).toBeInTheDocument()
|
||||
@@ -256,7 +245,7 @@ describe('SettingsPanel', () => {
|
||||
github_username: 'lucaong',
|
||||
}
|
||||
render(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-disconnect'))
|
||||
|
||||
@@ -285,7 +274,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -316,7 +305,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -337,7 +326,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -350,7 +339,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('shows GitHub section description about connecting', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -365,7 +354,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -387,7 +376,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement
|
||||
|
||||
@@ -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 <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} themeManager={themeManager} />
|
||||
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
|
||||
}
|
||||
|
||||
function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<SettingsPanelProps, 'open'>) {
|
||||
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
|
||||
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<Se
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
themeManager={themeManager}
|
||||
/>
|
||||
<SettingsFooter onClose={onClose} onSave={handleSave} />
|
||||
</div>
|
||||
@@ -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) {
|
||||
<option value={30}>30</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<AppearanceSection themeManager={props.themeManager} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Appearance Section ---
|
||||
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: 14, height: 14, borderRadius: 3, background: color, border: '1px solid var(--border)', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) {
|
||||
const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted']
|
||||
return (
|
||||
<button
|
||||
className="border rounded cursor-pointer text-left"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', width: '100%',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
borderColor: active ? 'var(--primary)' : 'var(--border)',
|
||||
}}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
data-testid={`theme-card-${theme.id}`}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{swatchColors.map(key => theme.colors[key] && <ColorSwatch key={key} color={theme.colors[key]} />)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{theme.name}</div>
|
||||
{theme.description && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{theme.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{active && <Check size={14} weight="bold" style={{ color: 'var(--primary)', flexShrink: 0 }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
|
||||
const { themes, activeThemeId, switchTheme, createTheme } = themeManager
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Appearance</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Choose a theme for your vault. Themes are stored in <code>_themes/</code> and synced with Git.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }} data-testid="theme-list">
|
||||
{themes.map(theme => (
|
||||
<ThemeCard key={theme.id} theme={theme} active={theme.id === activeThemeId} onSelect={() => switchTheme(theme.id)} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
|
||||
onClick={() => createTheme()}
|
||||
type="button"
|
||||
data-testid="create-theme"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Theme
|
||||
</button>
|
||||
|
||||
{activeThemeId && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
|
||||
<ThemePropertyEditor themeManager={themeManager} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -23,13 +23,12 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** 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<typeof useCreateBlockNote>
|
||||
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
|
||||
)}
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme={isDarkTheme ? 'dark' : 'light'}
|
||||
theme="light"
|
||||
onChange={onChange}
|
||||
editable={editable}
|
||||
>
|
||||
|
||||
@@ -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> = {}): 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(<ThemePropertyEditor themeManager={tm} />)
|
||||
expect(screen.getByText(/Select a theme/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the editor when a theme is active', () => {
|
||||
const tm = makeThemeManager()
|
||||
render(<ThemePropertyEditor themeManager={tm} />)
|
||||
expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows section headers for all theme.json sections', () => {
|
||||
const tm = makeThemeManager()
|
||||
render(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
expect(screen.getByText('My Theme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands Typography section by default', () => {
|
||||
const tm = makeThemeManager()
|
||||
render(<ThemePropertyEditor themeManager={tm} />)
|
||||
// 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(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
// 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(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
// 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(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
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(<ThemePropertyEditor themeManager={tm} />)
|
||||
// "px" should appear near Font Size input
|
||||
const container = screen.getByTestId('theme-input-editor-font-size').parentElement!
|
||||
expect(container.textContent).toContain('px')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> {
|
||||
return useMemo(() => {
|
||||
if (!content) return {}
|
||||
const fm = parseFrontmatter(content)
|
||||
const result: Record<string, string> = {}
|
||||
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<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={numericValue}
|
||||
onChange={handleChange}
|
||||
min={property.min}
|
||||
step={property.unit ? 1 : 0.1}
|
||||
className="w-20 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
|
||||
data-testid={`theme-input-${property.cssVar}`}
|
||||
/>
|
||||
{property.unit && (
|
||||
<span className="text-[11px] text-muted-foreground">{property.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorInput({ property, value, onChange }: {
|
||||
property: ThemeProperty
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const showSwatch = isValidCssColor(localValue)
|
||||
|
||||
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showSwatch && <ColorSwatch color={localValue} onChange={handlePickerChange} />}
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleTextChange}
|
||||
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
|
||||
data-testid={`theme-input-${property.cssVar}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectInput({ property, value, onChange }: {
|
||||
property: ThemeProperty
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
|
||||
data-testid={`theme-input-${property.cssVar}`}
|
||||
>
|
||||
{property.options?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function TextInput({ property, value, onChange }: {
|
||||
property: ThemeProperty
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
}) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVal = e.target.value
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => onChange(newVal), 500)
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={value}
|
||||
onChange={handleChange}
|
||||
className="w-40 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
|
||||
data-testid={`theme-input-${property.cssVar}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 py-1"
|
||||
style={{ minHeight: 28 }}
|
||||
>
|
||||
<label
|
||||
className="text-xs shrink-0"
|
||||
style={{ color: isPlaceholder ? 'var(--muted-foreground)' : 'var(--foreground)', minWidth: 100 }}
|
||||
>
|
||||
{property.label}
|
||||
</label>
|
||||
<div className="flex-shrink-0">
|
||||
{property.inputType === 'number' && (
|
||||
<NumberInput property={property} value={displayValue} onChange={handleChange} />
|
||||
)}
|
||||
{property.inputType === 'color' && (
|
||||
<ColorInput property={property} value={String(displayValue)} onChange={handleChange} />
|
||||
)}
|
||||
{property.inputType === 'select' && (
|
||||
<SelectInput property={property} value={String(displayValue)} onChange={handleChange} />
|
||||
)}
|
||||
{property.inputType === 'text' && (
|
||||
<TextInput property={property} value={String(displayValue)} onChange={handleChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<div data-testid={testId}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 border-none bg-transparent p-0 cursor-pointer"
|
||||
style={{ fontSize: 12, fontWeight: 600, color: 'var(--foreground)', padding: '4px 0' }}
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
data-testid={testId ? `${testId}-toggle` : undefined}
|
||||
>
|
||||
<CaretRight
|
||||
size={12}
|
||||
weight="bold"
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ paddingLeft: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Section renderers ---
|
||||
|
||||
function SubsectionBlock({ subsection, currentValues, onUpdate }: {
|
||||
subsection: ThemeSubsection
|
||||
currentValues: Record<string, string>
|
||||
onUpdate: (cssVar: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<CollapsibleSection label={subsection.label} testId={`theme-sub-${subsection.id}`}>
|
||||
{subsection.properties.map(prop => (
|
||||
<PropertyRow
|
||||
key={prop.cssVar}
|
||||
property={prop}
|
||||
currentValue={currentValues[prop.cssVar]}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionBlock({ section, currentValues, onUpdate }: {
|
||||
section: ThemeSection
|
||||
currentValues: Record<string, string>
|
||||
onUpdate: (cssVar: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<CollapsibleSection
|
||||
label={section.label}
|
||||
defaultOpen={section.id === 'editor'}
|
||||
testId={`theme-section-${section.id}`}
|
||||
>
|
||||
{section.properties.map(prop => (
|
||||
<PropertyRow
|
||||
key={prop.cssVar}
|
||||
property={prop}
|
||||
currentValue={currentValues[prop.cssVar]}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
{section.subsections.map(sub => (
|
||||
<SubsectionBlock
|
||||
key={sub.id}
|
||||
subsection={sub}
|
||||
currentValues={currentValues}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<div className="text-xs text-muted-foreground" style={{ padding: '8px 0' }}>
|
||||
Select a theme to customize its properties.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
data-testid="theme-property-editor"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', marginBottom: 4 }}>
|
||||
Editing: <strong>{themeManager.activeTheme?.name ?? 'Theme'}</strong>
|
||||
</div>
|
||||
{schema.map(section => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
currentValues={currentValues}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLDivElement | null>,
|
||||
content: string,
|
||||
isDark: boolean,
|
||||
callbacks: CodeMirrorCallbacks,
|
||||
) {
|
||||
const viewRef = useRef<EditorView | null>(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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, OptionalHandler> = {
|
||||
'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',
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => 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<string, string> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string>(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()
|
||||
})
|
||||
})
|
||||
@@ -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<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(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<string, string> {
|
||||
const fm = parseFrontmatter(content)
|
||||
const vars: Record<string, string> = {}
|
||||
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<string, string> {
|
||||
const fm = parseFrontmatter(content)
|
||||
const colors: Record<string, string> = {}
|
||||
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<string, string>): 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<string, string>): 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 <style> element's content forces a full style tree invalidation
|
||||
// that covers pseudo-elements using var() references (e.g. bullet size/color).
|
||||
const css = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';')
|
||||
getOrCreateThemeStyle().textContent = `:root{${css}}`
|
||||
}
|
||||
|
||||
function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
const root = document.documentElement
|
||||
for (const key of Object.keys(vars)) {
|
||||
root.style.removeProperty(key)
|
||||
}
|
||||
getOrCreateThemeStyle().textContent = ''
|
||||
clearColorScheme()
|
||||
}
|
||||
|
||||
/** Build a ThemeFile descriptor from a vault entry, enriched with content colors. */
|
||||
function entryToThemeFile(entry: VaultEntry, content: string | undefined): ThemeFile {
|
||||
return {
|
||||
id: entry.path,
|
||||
name: entry.title,
|
||||
description: '',
|
||||
path: entry.path,
|
||||
colors: content ? extractColorsFromContent(content) : {},
|
||||
typography: {},
|
||||
spacing: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a theme entry should no longer be applied (trashed or archived). */
|
||||
function isEntryRemoved(entry: VaultEntry): boolean {
|
||||
return entry.trashed || entry.archived
|
||||
}
|
||||
|
||||
export interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
activeThemeContent: string | undefined
|
||||
isDark: boolean
|
||||
switchTheme: (themeId: string) => Promise<void>
|
||||
createTheme: (name?: string) => Promise<string>
|
||||
reloadThemes: () => Promise<void>
|
||||
/** Update a single frontmatter property on the active theme note. */
|
||||
updateThemeProperty: (key: string, value: string) => Promise<void>
|
||||
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
|
||||
notifyThemeSaved: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/** Manages loading and persisting the active theme path from vault settings. */
|
||||
function useThemeSetting(vaultPath: string | null) {
|
||||
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!vaultPath) return
|
||||
try {
|
||||
const s = await tauriCall<VaultSettings>('get_vault_settings', { vaultPath })
|
||||
setActiveThemeId(s.theme)
|
||||
} catch { /* no settings file — fine, no active theme */ }
|
||||
}, [vaultPath])
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const lastLoadRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastLoadRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastLoadRef.current = now
|
||||
load()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [load])
|
||||
|
||||
return { activeThemeId, setActiveThemeId, reload: load }
|
||||
}
|
||||
|
||||
/** Applies CSS custom properties to the document root from the active theme. */
|
||||
function useThemeApplier(
|
||||
activeThemeId: string | null,
|
||||
cachedContent: string | undefined,
|
||||
) {
|
||||
const appliedVarsRef = useRef<Record<string, string>>({})
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const versionRef = useRef(0)
|
||||
|
||||
const applyDom = useCallback((content: string) => {
|
||||
const newVars = extractCssVars(content)
|
||||
clearVarsFromDom(appliedVarsRef.current)
|
||||
applyVarsToDom(newVars)
|
||||
appliedVarsRef.current = newVars
|
||||
return newVars
|
||||
}, [])
|
||||
|
||||
const clearDom = useCallback(() => {
|
||||
clearVarsFromDom(appliedVarsRef.current)
|
||||
appliedVarsRef.current = {}
|
||||
}, [])
|
||||
|
||||
// Apply theme when activeThemeId or cached content changes.
|
||||
// Also serves as live-preview: re-applies when the user saves the theme note.
|
||||
useEffect(() => {
|
||||
const version = ++versionRef.current
|
||||
if (!activeThemeId) {
|
||||
clearDom()
|
||||
setIsDark(false) // eslint-disable-line react-hooks/set-state-in-effect -- sync dark mode with cleared theme
|
||||
return
|
||||
}
|
||||
if (cachedContent) {
|
||||
const vars = applyDom(cachedContent)
|
||||
setIsDark(isColorDark(vars['--background'] ?? ''))
|
||||
return
|
||||
}
|
||||
tauriCall<string>('get_note_content', { path: activeThemeId })
|
||||
.then(content => {
|
||||
if (versionRef.current !== version) return
|
||||
const vars = applyDom(content)
|
||||
setIsDark(isColorDark(vars['--background'] ?? ''))
|
||||
})
|
||||
.catch(() => {
|
||||
if (versionRef.current !== version) return
|
||||
clearDom(); setIsDark(false)
|
||||
})
|
||||
}, [activeThemeId, cachedContent, applyDom, clearDom])
|
||||
|
||||
return { clearDom, isDark }
|
||||
}
|
||||
|
||||
/** Deactivate the theme and persist `null` to vault settings. */
|
||||
function deactivateTheme(
|
||||
vaultPath: string | null,
|
||||
clearTheme: () => void,
|
||||
setActiveThemeId: (id: string | null) => void,
|
||||
) {
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}
|
||||
|
||||
/** True when the active theme should be cleared (stale, trashed, or archived). */
|
||||
function shouldDeactivate(
|
||||
activeThemeId: string | null,
|
||||
themes: ThemeFile[],
|
||||
entries: VaultEntry[],
|
||||
userSetId: string | null,
|
||||
): boolean {
|
||||
if (!activeThemeId) return false
|
||||
// Stale ID from old theme system — skip IDs just set by user action
|
||||
if (themes.length > 0 && activeThemeId !== userSetId && !themes.some(t => t.id === activeThemeId)) return true
|
||||
// Trashed or archived
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
return !!entry && isEntryRemoved(entry)
|
||||
}
|
||||
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
): ThemeManager {
|
||||
useEffect(() => {
|
||||
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
|
||||
}, [vaultPath])
|
||||
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
|
||||
|
||||
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
const userSetIdRef = useRef<string | null>(null)
|
||||
|
||||
const themes = useMemo(
|
||||
() => entries
|
||||
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
|
||||
.map(e => entryToThemeFile(e, e.path === activeThemeId ? cachedThemeContent : undefined)),
|
||||
[entries, activeThemeId, cachedThemeContent],
|
||||
)
|
||||
|
||||
const activeTheme = useMemo(
|
||||
() => themes.find(t => t.id === activeThemeId) ?? null,
|
||||
[themes, activeThemeId],
|
||||
)
|
||||
|
||||
// Deactivate stale, trashed, or archived theme
|
||||
useEffect(() => {
|
||||
if (shouldDeactivate(activeThemeId, themes, entries, userSetIdRef.current)) {
|
||||
deactivateTheme(vaultPath, clearTheme, setActiveThemeId)
|
||||
}
|
||||
}, [activeThemeId, themes, entries, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
const switchTheme = useCallback(async (themeId: string) => {
|
||||
if (!vaultPath) return
|
||||
try {
|
||||
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
|
||||
userSetIdRef.current = themeId
|
||||
setActiveThemeId(themeId)
|
||||
} catch (err) { console.error('Failed to switch theme:', err) }
|
||||
}, [vaultPath, setActiveThemeId])
|
||||
|
||||
const createTheme = useCallback(async (name?: string) => {
|
||||
if (!vaultPath) return ''
|
||||
try {
|
||||
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
|
||||
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
|
||||
userSetIdRef.current = path
|
||||
setActiveThemeId(path)
|
||||
return path
|
||||
} catch (err) { console.error('Failed to create theme:', err); return '' }
|
||||
}, [vaultPath, setActiveThemeId])
|
||||
|
||||
const reloadThemes = useCallback(async () => { await reload() }, [reload])
|
||||
|
||||
const notifyThemeSaved = useCallback((path: string, content: string) => {
|
||||
if (path === activeThemeId) setCachedThemeContent(content)
|
||||
}, [activeThemeId])
|
||||
|
||||
const updateThemeProperty = useCallback(async (key: string, value: string) => {
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
const newContent = await tauriCall<string>('update_frontmatter', {
|
||||
path: activeThemeId, key, value,
|
||||
})
|
||||
setCachedThemeContent(newContent)
|
||||
} catch (err) { console.error('Failed to update theme property:', err) }
|
||||
}, [activeThemeId])
|
||||
|
||||
return {
|
||||
themes, activeThemeId, activeTheme,
|
||||
activeThemeContent: cachedThemeContent,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
|
||||
}
|
||||
}
|
||||
@@ -732,452 +732,5 @@ rating: 5
|
||||
# Designing Data-Intensive Applications
|
||||
|
||||
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/default.md': `---
|
||||
type: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
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-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
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"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
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-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-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-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-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-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
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-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
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-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
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-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-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-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-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
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)"
|
||||
---
|
||||
|
||||
# Default
|
||||
|
||||
Light theme with warm, paper-like tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/dark.md': `---
|
||||
type: Theme
|
||||
Description: Dark variant with deep navy tones
|
||||
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: "#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-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
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-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-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-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-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-bullet-size: 28px
|
||||
lists-bullet-color: "#155DFF"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
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-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
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-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
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-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-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-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-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
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)"
|
||||
---
|
||||
|
||||
# Dark
|
||||
|
||||
Dark variant with deep navy tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/minimal.md': `---
|
||||
type: Theme
|
||||
Description: High contrast, minimal chrome
|
||||
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: "#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-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
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-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-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-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-bullet-size: 28px
|
||||
lists-bullet-color: "#000000"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
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-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
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-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
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-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-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-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-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
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)"
|
||||
---
|
||||
|
||||
# Minimal
|
||||
|
||||
High contrast, minimal chrome.
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -1242,87 +1242,5 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
return entries
|
||||
}
|
||||
|
||||
// Theme entries — seeded vault themes
|
||||
MOCK_ENTRIES.push(
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/default.md',
|
||||
filename: 'default.md',
|
||||
title: 'Default',
|
||||
isA: 'Theme',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 512,
|
||||
snippet: 'Light theme with warm, paper-like tones.',
|
||||
wordCount: 10,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/dark.md',
|
||||
filename: 'dark.md',
|
||||
title: 'Dark',
|
||||
isA: 'Theme',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 512,
|
||||
snippet: 'Dark variant with deep navy tones.',
|
||||
wordCount: 10,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/minimal.md',
|
||||
filename: 'minimal.md',
|
||||
title: 'Minimal',
|
||||
isA: 'Theme',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 512,
|
||||
snippet: 'High contrast, minimal chrome.',
|
||||
wordCount: 10,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
)
|
||||
|
||||
// Append 9000 generated entries for realistic large-vault testing
|
||||
MOCK_ENTRIES.push(...generateBulkEntries(9000))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Each handler simulates a Tauri backend command.
|
||||
*/
|
||||
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, PulseCommit } from '../types'
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { MOCK_ENTRIES } from './mock-entries'
|
||||
|
||||
@@ -85,34 +85,11 @@ let mockSettings: Settings = {
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
|
||||
let mockVaultSettings: VaultSettings = { theme: null }
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const mockThemes: ThemeFile[] = [
|
||||
{
|
||||
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
|
||||
colors: { background: '#FFFFFF', foreground: '#37352F', primary: '#155DFF', 'sidebar-background': '#F7F6F3', border: '#E9E9E7', muted: '#F0F0EF' },
|
||||
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
|
||||
spacing: { 'sidebar-width': '250px' },
|
||||
},
|
||||
{
|
||||
id: 'dark', name: 'Dark', description: 'Dark variant with deep navy tones',
|
||||
colors: { background: '#0f0f1a', foreground: '#e0e0e0', primary: '#155DFF', 'sidebar-background': '#1a1a2e', border: '#2a2a4a', muted: '#1e1e3a' },
|
||||
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
|
||||
spacing: { 'sidebar-width': '250px' },
|
||||
},
|
||||
{
|
||||
id: 'minimal', name: 'Minimal', description: 'High contrast, minimal chrome',
|
||||
colors: { background: '#FAFAFA', foreground: '#111111', primary: '#000000', 'sidebar-background': '#F5F5F5', border: '#E0E0E0', muted: '#F5F5F5' },
|
||||
typography: { 'font-family': "'SF Mono', 'Menlo', monospace", 'font-size-base': '13px' },
|
||||
spacing: { 'sidebar-width': '220px' },
|
||||
},
|
||||
]
|
||||
|
||||
let mockDeviceFlowPollCount = 0
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
|
||||
@@ -291,189 +268,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
|
||||
start_indexing: () => null,
|
||||
trigger_incremental_index: () => null,
|
||||
list_themes: (): ThemeFile[] => [...mockThemes],
|
||||
get_theme: (args: { themeId: string }): ThemeFile => {
|
||||
const t = mockThemes.find(t => t.id === args.themeId)
|
||||
if (!t) throw new Error(`Theme not found: ${args.themeId}`)
|
||||
return { ...t }
|
||||
},
|
||||
get_vault_settings: (): VaultSettings => ({ ...mockVaultSettings }),
|
||||
save_vault_settings: (args: { settings: VaultSettings }) => { mockVaultSettings = { ...args.settings }; return null },
|
||||
set_active_theme: (args: { themeId: string }) => { mockVaultSettings.theme = args.themeId; return null },
|
||||
create_theme: (args: { sourceId?: string }): string => {
|
||||
const sourceId = args.sourceId ?? 'default'
|
||||
const source = mockThemes.find(t => t.id === sourceId) ?? mockThemes[0]
|
||||
const newId = `untitled-${mockThemes.length}`
|
||||
mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' })
|
||||
return newId
|
||||
},
|
||||
create_vault_theme: (args: { vaultPath: string; name?: string | null }): string => {
|
||||
const displayName = args.name ?? 'Untitled Theme'
|
||||
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
|
||||
const path = `${args.vaultPath}/theme/${slug}.md`
|
||||
MOCK_CONTENT[path] = `---
|
||||
Is A: Theme
|
||||
Description: ${displayName} theme
|
||||
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-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
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"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
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-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-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-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-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-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
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-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
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-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
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-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-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-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-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
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)"
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
|
||||
A custom ${displayName} theme for Laputa.
|
||||
`
|
||||
const now = Date.now() / 1000
|
||||
MOCK_ENTRIES.push({
|
||||
path, filename: `${slug}.md`, title: displayName, isA: 'Theme',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`,
|
||||
wordCount: 10, relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
})
|
||||
syncWindowContent()
|
||||
return path
|
||||
},
|
||||
ensure_vault_themes: (): null => null,
|
||||
restore_default_themes: (): string => 'Default themes restored',
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, editor_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
|
||||
save_vault_config: (): null => null,
|
||||
|
||||
16
src/types.ts
16
src/types.ts
@@ -139,22 +139,6 @@ export interface SearchResponse {
|
||||
|
||||
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
export interface ThemeFile {
|
||||
/** For vault-based themes: absolute note path. For legacy JSON themes: filename stem. */
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
/** Absolute path to the vault note (vault-based themes only). */
|
||||
path?: string
|
||||
colors: Record<string, string>
|
||||
typography: Record<string, string>
|
||||
spacing: Record<string, string>
|
||||
}
|
||||
|
||||
export interface VaultSettings {
|
||||
theme: string | null
|
||||
}
|
||||
|
||||
/** Vault-wide UI configuration stored in ui.config.md at vault root. */
|
||||
export interface VaultConfig {
|
||||
zoom: number | null
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
|
||||
import type { ThemeProperty } from './themeSchema'
|
||||
|
||||
describe('buildThemeSchema', () => {
|
||||
const schema = buildThemeSchema()
|
||||
|
||||
it('returns all top-level sections from theme.json', () => {
|
||||
const ids = schema.map(s => s.id)
|
||||
expect(ids).toContain('editor')
|
||||
expect(ids).toContain('headings')
|
||||
expect(ids).toContain('lists')
|
||||
expect(ids).toContain('checkboxes')
|
||||
expect(ids).toContain('inlineStyles')
|
||||
expect(ids).toContain('codeBlocks')
|
||||
expect(ids).toContain('blockquote')
|
||||
expect(ids).toContain('table')
|
||||
expect(ids).toContain('horizontalRule')
|
||||
expect(ids).toContain('colors')
|
||||
})
|
||||
|
||||
it('assigns human-readable labels to sections', () => {
|
||||
const editor = schema.find(s => s.id === 'editor')!
|
||||
expect(editor.label).toBe('Typography')
|
||||
const headings = schema.find(s => s.id === 'headings')!
|
||||
expect(headings.label).toBe('Headings')
|
||||
})
|
||||
|
||||
it('produces flat CSS variable names from editor section', () => {
|
||||
const editor = schema.find(s => s.id === 'editor')!
|
||||
const vars = editor.properties.map(p => p.cssVar)
|
||||
expect(vars).toContain('editor-font-family')
|
||||
expect(vars).toContain('editor-font-size')
|
||||
expect(vars).toContain('editor-line-height')
|
||||
expect(vars).toContain('editor-max-width')
|
||||
expect(vars).toContain('editor-padding-horizontal')
|
||||
expect(vars).toContain('editor-paragraph-spacing')
|
||||
})
|
||||
|
||||
it('detects numeric input type for number values', () => {
|
||||
const editor = schema.find(s => s.id === 'editor')!
|
||||
const fontSize = editor.properties.find(p => p.cssVar === 'editor-font-size')!
|
||||
expect(fontSize.inputType).toBe('number')
|
||||
expect(fontSize.unit).toBe('px')
|
||||
expect(fontSize.defaultValue).toBe(15)
|
||||
})
|
||||
|
||||
it('detects unitless numbers for lineHeight and fontWeight', () => {
|
||||
const editor = schema.find(s => s.id === 'editor')!
|
||||
const lineHeight = editor.properties.find(p => p.cssVar === 'editor-line-height')!
|
||||
expect(lineHeight.inputType).toBe('number')
|
||||
expect(lineHeight.unit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('detects text input type for font family', () => {
|
||||
const editor = schema.find(s => s.id === 'editor')!
|
||||
const fontFamily = editor.properties.find(p => p.cssVar === 'editor-font-family')!
|
||||
expect(fontFamily.inputType).toBe('text')
|
||||
})
|
||||
|
||||
it('creates subsections for headings h1-h4', () => {
|
||||
const headings = schema.find(s => s.id === 'headings')!
|
||||
const subIds = headings.subsections.map(s => s.id)
|
||||
expect(subIds).toContain('h1')
|
||||
expect(subIds).toContain('h2')
|
||||
expect(subIds).toContain('h3')
|
||||
expect(subIds).toContain('h4')
|
||||
})
|
||||
|
||||
it('produces correct CSS var names for heading subsections', () => {
|
||||
const headings = schema.find(s => s.id === 'headings')!
|
||||
const h1 = headings.subsections.find(s => s.id === 'h1')!
|
||||
const vars = h1.properties.map(p => p.cssVar)
|
||||
expect(vars).toContain('headings-h1-font-size')
|
||||
expect(vars).toContain('headings-h1-font-weight')
|
||||
expect(vars).toContain('headings-h1-line-height')
|
||||
expect(vars).toContain('headings-h1-margin-top')
|
||||
expect(vars).toContain('headings-h1-color')
|
||||
expect(vars).toContain('headings-h1-letter-spacing')
|
||||
})
|
||||
|
||||
it('detects color values from var(--) references', () => {
|
||||
const headings = schema.find(s => s.id === 'headings')!
|
||||
const h1 = headings.subsections.find(s => s.id === 'h1')!
|
||||
const color = h1.properties.find(p => p.cssVar === 'headings-h1-color')!
|
||||
expect(color.inputType).toBe('color')
|
||||
})
|
||||
|
||||
it('detects hex color values', () => {
|
||||
const lists = schema.find(s => s.id === 'lists')!
|
||||
const bulletColor = lists.properties.find(p => p.cssVar === 'lists-bullet-color')!
|
||||
expect(bulletColor.inputType).toBe('color')
|
||||
})
|
||||
|
||||
it('creates subsections for inline styles', () => {
|
||||
const inline = schema.find(s => s.id === 'inlineStyles')!
|
||||
const subIds = inline.subsections.map(s => s.id)
|
||||
expect(subIds).toContain('bold')
|
||||
expect(subIds).toContain('italic')
|
||||
expect(subIds).toContain('code')
|
||||
expect(subIds).toContain('link')
|
||||
expect(subIds).toContain('wikilink')
|
||||
})
|
||||
|
||||
it('produces correct CSS var names for code blocks section', () => {
|
||||
const codeBlocks = schema.find(s => s.id === 'codeBlocks')!
|
||||
const vars = codeBlocks.properties.map(p => p.cssVar)
|
||||
expect(vars).toContain('code-blocks-font-family')
|
||||
expect(vars).toContain('code-blocks-font-size')
|
||||
expect(vars).toContain('code-blocks-background-color')
|
||||
expect(vars).toContain('code-blocks-border-radius')
|
||||
})
|
||||
|
||||
it('skips array values like nestedBulletSymbols', () => {
|
||||
const lists = schema.find(s => s.id === 'lists')!
|
||||
const vars = lists.properties.map(p => p.cssVar)
|
||||
expect(vars).not.toContain('lists-nested-bullet-symbols')
|
||||
})
|
||||
|
||||
it('assigns select input type for fontStyle', () => {
|
||||
const blockquote = schema.find(s => s.id === 'blockquote')!
|
||||
const fontStyle = blockquote.properties.find(p => p.cssVar === 'blockquote-font-style')!
|
||||
expect(fontStyle.inputType).toBe('select')
|
||||
expect(fontStyle.options).toContain('normal')
|
||||
expect(fontStyle.options).toContain('italic')
|
||||
})
|
||||
|
||||
it('every var(--xxx) in EditorTheme.css has a matching default in theme schema or base UI', () => {
|
||||
const cssPath = resolve(__dirname, '../components/EditorTheme.css')
|
||||
const css = readFileSync(cssPath, 'utf-8')
|
||||
|
||||
// Extract all var(--xxx) references (first arg only, ignore fallbacks)
|
||||
const varRegex = /var\(--([a-z0-9-]+)/g
|
||||
const usedVars = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = varRegex.exec(css)) !== null) {
|
||||
usedVars.add(match[1])
|
||||
}
|
||||
|
||||
// Collect all CSS var names from the schema
|
||||
const schemaVars = new Set<string>()
|
||||
for (const section of schema) {
|
||||
for (const prop of section.properties) schemaVars.add(prop.cssVar)
|
||||
for (const sub of section.subsections) {
|
||||
for (const prop of sub.properties) schemaVars.add(prop.cssVar)
|
||||
}
|
||||
}
|
||||
|
||||
// Base UI color vars set by the theme color system (not in theme.json schema)
|
||||
const baseUIVars = new Set([
|
||||
'border-primary', 'bg-primary', 'bg-card', 'bg-hover-subtle', 'bg-selected',
|
||||
'text-primary', 'text-secondary', 'text-muted', 'text-heading', 'text-tertiary',
|
||||
'accent-blue',
|
||||
])
|
||||
|
||||
for (const varName of usedVars) {
|
||||
expect(
|
||||
schemaVars.has(varName) || baseUIVars.has(varName),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForFrontmatter', () => {
|
||||
it('appends unit to numeric values', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
|
||||
inputType: 'number', unit: 'px', min: 0,
|
||||
}
|
||||
expect(formatValueForFrontmatter(15, prop)).toBe('15px')
|
||||
})
|
||||
|
||||
it('does not append unit for unitless values', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
|
||||
inputType: 'number',
|
||||
}
|
||||
expect(formatValueForFrontmatter(1.5, prop)).toBe('1.5')
|
||||
})
|
||||
|
||||
it('returns string values as-is', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
|
||||
inputType: 'text',
|
||||
}
|
||||
expect(formatValueForFrontmatter('Helvetica', prop)).toBe('Helvetica')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseValueFromFrontmatter', () => {
|
||||
it('extracts numeric value from string with unit', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
|
||||
inputType: 'number', unit: 'px',
|
||||
}
|
||||
expect(parseValueFromFrontmatter('15px', prop)).toBe(15)
|
||||
})
|
||||
|
||||
it('returns string for non-numeric values', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
|
||||
inputType: 'text',
|
||||
}
|
||||
expect(parseValueFromFrontmatter('Helvetica', prop)).toBe('Helvetica')
|
||||
})
|
||||
|
||||
it('parses bare numbers', () => {
|
||||
const prop: ThemeProperty = {
|
||||
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
|
||||
inputType: 'number',
|
||||
}
|
||||
expect(parseValueFromFrontmatter('1.6', prop)).toBe(1.6)
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import themeConfig from '../theme.json'
|
||||
import { isValidCssColor } from './colorUtils'
|
||||
|
||||
export type InputType = 'number' | 'color' | 'text' | 'select'
|
||||
|
||||
export interface ThemeProperty {
|
||||
/** Flat kebab-case key used in frontmatter, e.g. "editor-font-size" */
|
||||
cssVar: string
|
||||
/** Human-readable label, e.g. "Font Size" */
|
||||
label: string
|
||||
/** Default value from theme.json */
|
||||
defaultValue: string | number
|
||||
inputType: InputType
|
||||
/** Unit label shown next to numeric inputs (e.g. "px"). Absent for unitless. */
|
||||
unit?: string
|
||||
/** Options for select inputs (e.g. font weights). */
|
||||
options?: string[]
|
||||
/** Minimum allowed value for numeric inputs. */
|
||||
min?: number
|
||||
}
|
||||
|
||||
export interface ThemeSubsection {
|
||||
id: string
|
||||
label: string
|
||||
properties: ThemeProperty[]
|
||||
}
|
||||
|
||||
export interface ThemeSection {
|
||||
id: string
|
||||
label: string
|
||||
properties: ThemeProperty[]
|
||||
subsections: ThemeSubsection[]
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
editor: 'Typography',
|
||||
headings: 'Headings',
|
||||
lists: 'Lists',
|
||||
checkboxes: 'Checkboxes',
|
||||
inlineStyles: 'Inline Styles',
|
||||
codeBlocks: 'Code Blocks',
|
||||
blockquote: 'Blockquote',
|
||||
table: 'Table',
|
||||
horizontalRule: 'Horizontal Rule',
|
||||
colors: 'Colors',
|
||||
}
|
||||
|
||||
const SUBSECTION_LABELS: Record<string, string> = {
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
bold: 'Bold',
|
||||
italic: 'Italic',
|
||||
strikethrough: 'Strikethrough',
|
||||
code: 'Inline Code',
|
||||
link: 'Link',
|
||||
wikilink: 'Wiki Link',
|
||||
}
|
||||
|
||||
/** Keys where the numeric value is unitless (ratios, weights). */
|
||||
const UNITLESS_KEYS = /weight|lineHeight|opacity/i
|
||||
|
||||
/** Keys that should use a select input with predefined options. */
|
||||
const SELECT_OPTIONS: Record<string, string[]> = {
|
||||
fontWeight: ['400', '500', '600', '700'],
|
||||
fontStyle: ['normal', 'italic'],
|
||||
textDecoration: ['none', 'underline', 'line-through'],
|
||||
cursor: ['default', 'pointer', 'text'],
|
||||
}
|
||||
|
||||
function camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function camelToTitle(str: string): string {
|
||||
return str
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function isColorValue(value: unknown, key: string): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
if (value.startsWith('#') || value.startsWith('var(--')) return isColorKeyHint(key) || isValidCssColor(value)
|
||||
return false
|
||||
}
|
||||
|
||||
function isColorKeyHint(key: string): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
return lower === 'color' || lower.endsWith('color') || lower === 'background'
|
||||
|| lower.endsWith('background') || lower === 'fill' || lower === 'tint'
|
||||
}
|
||||
|
||||
function deriveInputType(key: string, value: unknown): { inputType: InputType; unit?: string; options?: string[]; min?: number } {
|
||||
// Select options take priority
|
||||
for (const [pattern, opts] of Object.entries(SELECT_OPTIONS)) {
|
||||
if (key === pattern || key.endsWith(pattern.charAt(0).toUpperCase() + pattern.slice(1))) {
|
||||
// Check if current key ends with the select key (e.g. "fontWeight" matches "boldFontWeight")
|
||||
if (key === pattern || key.toLowerCase().endsWith(pattern.toLowerCase())) {
|
||||
return { inputType: 'select', options: opts }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const isUnitless = UNITLESS_KEYS.test(key)
|
||||
return { inputType: 'number', unit: isUnitless ? undefined : 'px', min: 0 }
|
||||
}
|
||||
|
||||
if (isColorValue(value, key)) {
|
||||
return { inputType: 'color' }
|
||||
}
|
||||
|
||||
return { inputType: 'text' }
|
||||
}
|
||||
|
||||
function buildProperty(parentPrefix: string, key: string, value: string | number): ThemeProperty {
|
||||
const cssVar = `${parentPrefix}${camelToKebab(key)}`
|
||||
const { inputType, unit, options, min } = deriveInputType(key, value)
|
||||
return { cssVar, label: camelToTitle(key), defaultValue: value, inputType, unit, options, min }
|
||||
}
|
||||
|
||||
/** Build the full theme schema from theme.json, grouped by section. */
|
||||
export function buildThemeSchema(): ThemeSection[] {
|
||||
const sections: ThemeSection[] = []
|
||||
|
||||
for (const [sectionKey, sectionValue] of Object.entries(themeConfig)) {
|
||||
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) continue
|
||||
const sectionObj = sectionValue as Record<string, unknown>
|
||||
const sectionPrefix = `${camelToKebab(sectionKey)}-`
|
||||
|
||||
const section: ThemeSection = {
|
||||
id: sectionKey,
|
||||
label: SECTION_LABELS[sectionKey] ?? camelToTitle(sectionKey),
|
||||
properties: [],
|
||||
subsections: [],
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(sectionObj)) {
|
||||
if (Array.isArray(value)) continue // skip arrays like nestedBulletSymbols
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Subsection (e.g. headings.h1, inlineStyles.bold)
|
||||
const subPrefix = `${sectionPrefix}${camelToKebab(key)}-`
|
||||
const subProperties: ThemeProperty[] = []
|
||||
for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof subValue === 'string' || typeof subValue === 'number') {
|
||||
subProperties.push(buildProperty(subPrefix, subKey, subValue))
|
||||
}
|
||||
}
|
||||
if (subProperties.length > 0) {
|
||||
section.subsections.push({
|
||||
id: key,
|
||||
label: SUBSECTION_LABELS[key] ?? camelToTitle(key),
|
||||
properties: subProperties,
|
||||
})
|
||||
}
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
section.properties.push(buildProperty(sectionPrefix, key, value))
|
||||
}
|
||||
}
|
||||
|
||||
if (section.properties.length > 0 || section.subsections.length > 0) {
|
||||
sections.push(section)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/** Format a value for storage in theme note frontmatter. */
|
||||
export function formatValueForFrontmatter(value: string | number, property: ThemeProperty): string {
|
||||
if (property.inputType === 'number' && property.unit && typeof value === 'number') {
|
||||
return `${value}${property.unit}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** Parse a frontmatter value back to its editable form (strip unit suffix). */
|
||||
export function parseValueFromFrontmatter(raw: string, property: ThemeProperty): string | number {
|
||||
if (property.inputType === 'number') {
|
||||
const numeric = parseFloat(raw)
|
||||
if (!isNaN(numeric)) return numeric
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Cached schema — built once from theme.json. */
|
||||
let cachedSchema: ThemeSection[] | null = null
|
||||
export function getThemeSchema(): ThemeSection[] {
|
||||
if (!cachedSchema) cachedSchema = buildThemeSchema()
|
||||
return cachedSchema
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
async function getCssVar(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(
|
||||
(n) => document.documentElement.style.getPropertyValue(n),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace all text in the CodeMirror editor via the exposed __cmView. */
|
||||
async function setCmContent(page: Page, newContent: string) {
|
||||
await page.evaluate((text) => {
|
||||
const container = document.querySelector('[data-testid="raw-editor-codemirror"]')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (container as any)?.__cmView
|
||||
if (!view) throw new Error('No __cmView on raw-editor-codemirror container')
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||||
})
|
||||
}, newContent)
|
||||
}
|
||||
|
||||
test.describe('Theme live reload on raw editor save (rework)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block vault API so the app uses mock handlers
|
||||
await page.route('**/api/vault/ping', (route) =>
|
||||
route.fulfill({ status: 404, body: 'blocked for testing' }),
|
||||
)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('editing theme frontmatter in raw mode and saving updates CSS vars', async ({ page }) => {
|
||||
// 1. Switch to the default theme
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Switch to Default Theme')
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Open the theme note in the editor
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Edit Default Theme')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 3. Switch to raw editor mode
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw Editor')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 4. Replace content with a changed background color
|
||||
const updatedContent = [
|
||||
'---',
|
||||
'type: Theme',
|
||||
'Description: Light theme with warm, paper-like tones',
|
||||
'background: "#FFD700"',
|
||||
'foreground: "#37352F"',
|
||||
'primary: "#155DFF"',
|
||||
'sidebar: "#F7F6F3"',
|
||||
'text-primary: "#37352F"',
|
||||
'lists-bullet-size: 32px',
|
||||
'lists-bullet-color: "#FF0000"',
|
||||
'---',
|
||||
'',
|
||||
'# Default',
|
||||
'',
|
||||
'Light theme with warm, paper-like tones.',
|
||||
].join('\n')
|
||||
await setCmContent(page, updatedContent)
|
||||
|
||||
// Wait for debounce to flush (RawEditorView has 500ms debounce)
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
// 5. Save with Ctrl+S (works on all platforms in browser mode)
|
||||
await page.keyboard.press('Control+s')
|
||||
|
||||
// 6. Verify CSS vars updated live
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFD700')
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Verify other vars also updated
|
||||
expect(await getCssVar(page, '--sidebar')).toBe('#F7F6F3')
|
||||
expect(await getCssVar(page, '--foreground')).toBe('#37352F')
|
||||
|
||||
// Verify bullet-size and bullet-color (rework regression)
|
||||
expect(await getCssVar(page, '--lists-bullet-size')).toBe('32px')
|
||||
expect(await getCssVar(page, '--lists-bullet-color')).toBe('#FF0000')
|
||||
})
|
||||
|
||||
test('saving a non-theme note does not affect active theme CSS', async ({ page }) => {
|
||||
// 1. Switch to the default theme
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Switch to Default Theme')
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Open a regular note (first in the note list)
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// 3. Switch to raw editor mode
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw Editor')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 4. Type something and save
|
||||
await page.locator('.cm-content').click()
|
||||
await page.keyboard.type('test edit ')
|
||||
await page.waitForTimeout(600)
|
||||
await page.keyboard.press('Control+s')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 5. Theme CSS vars should be unchanged
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
})
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function getCssVar(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(
|
||||
(n) => document.documentElement.style.getPropertyValue(n),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically switch theme by calling mock handlers and triggering reload.
|
||||
* This avoids relying on command palette label matching.
|
||||
*/
|
||||
async function activateTheme(page: Page, themePath: string) {
|
||||
await page.evaluate((path) => {
|
||||
const win = window as Record<string, unknown>
|
||||
const handlers = win.__mockHandlers as Record<string, (...args: unknown[]) => unknown>
|
||||
if (handlers?.set_active_theme) {
|
||||
handlers.set_active_theme({ themeId: path })
|
||||
}
|
||||
}, themePath)
|
||||
|
||||
// The app polls vault settings on window focus, trigger a focus event
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
test.describe('Theme properties defaults', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block the vault API ping so the app falls back to mock content
|
||||
// instead of reading real files from the filesystem.
|
||||
await page.route('**/api/vault/ping', (route) =>
|
||||
route.fulfill({ status: 404, body: 'blocked for testing' }),
|
||||
)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('default theme applies all editor CSS vars from frontmatter', async ({ page }) => {
|
||||
await activateTheme(page, '/Users/luca/Laputa/theme/default.md')
|
||||
|
||||
// Wait for CSS vars to be applied
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Editor properties that were previously missing from frontmatter
|
||||
expect(await getCssVar(page, '--editor-font-size')).toBe('15px')
|
||||
expect(await getCssVar(page, '--editor-max-width')).toBe('720px')
|
||||
expect(await getCssVar(page, '--editor-padding-horizontal')).toBe('40px')
|
||||
|
||||
// Heading properties
|
||||
expect(await getCssVar(page, '--headings-h1-font-size')).toBe('32px')
|
||||
expect(await getCssVar(page, '--headings-h1-font-weight')).toBe('700')
|
||||
|
||||
// List properties
|
||||
expect(await getCssVar(page, '--lists-bullet-size')).toBe('28px')
|
||||
expect(await getCssVar(page, '--lists-bullet-color')).toBe('#177bfd')
|
||||
|
||||
// Checkbox properties
|
||||
expect(await getCssVar(page, '--checkboxes-size')).toBe('18px')
|
||||
|
||||
// Inline styles
|
||||
expect(await getCssVar(page, '--inline-styles-bold-font-weight')).toBe('700')
|
||||
|
||||
// Code blocks
|
||||
expect(await getCssVar(page, '--code-blocks-font-size')).toBe('13px')
|
||||
|
||||
// Blockquote
|
||||
expect(await getCssVar(page, '--blockquote-border-left-width')).toBe('3px')
|
||||
|
||||
// Table
|
||||
expect(await getCssVar(page, '--table-font-size')).toBe('14px')
|
||||
|
||||
// Horizontal rule
|
||||
expect(await getCssVar(page, '--horizontal-rule-thickness')).toBe('1px')
|
||||
|
||||
// Colors semantic aliases (should resolve to var() references)
|
||||
expect(await getCssVar(page, '--colors-text')).toBe('var(--text-primary)')
|
||||
expect(await getCssVar(page, '--colors-cursor')).toBe('var(--text-primary)')
|
||||
|
||||
// Semantic vars that were previously undefined
|
||||
expect(await getCssVar(page, '--text-tertiary')).toBe('#B4B4B4')
|
||||
expect(await getCssVar(page, '--bg-card')).toBe('#FFFFFF')
|
||||
expect(await getCssVar(page, '--border-primary')).toBe('#E9E9E7')
|
||||
})
|
||||
|
||||
test('newly created theme frontmatter contains all default properties', async ({ page }) => {
|
||||
// Call create_vault_theme mock and read the generated content
|
||||
const content = await page.evaluate(() => {
|
||||
const win = window as Record<string, unknown>
|
||||
const handlers = win.__mockHandlers as Record<string, (...args: unknown[]) => unknown>
|
||||
const mockContent = win.__mockContent as Record<string, string>
|
||||
if (!handlers?.create_vault_theme) return 'no handler'
|
||||
|
||||
const path = handlers.create_vault_theme({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
name: 'Test Theme',
|
||||
}) as string
|
||||
|
||||
return mockContent[path] || 'no content'
|
||||
})
|
||||
|
||||
// Verify frontmatter includes ALL editor properties
|
||||
expect(content).toContain('editor-font-size:')
|
||||
expect(content).toContain('editor-max-width:')
|
||||
expect(content).toContain('editor-padding-horizontal:')
|
||||
expect(content).toContain('headings-h1-font-size:')
|
||||
expect(content).toContain('headings-h2-font-weight:')
|
||||
expect(content).toContain('lists-bullet-size:')
|
||||
expect(content).toContain('lists-bullet-color:')
|
||||
expect(content).toContain('checkboxes-size:')
|
||||
expect(content).toContain('inline-styles-bold-font-weight:')
|
||||
expect(content).toContain('inline-styles-code-font-family:')
|
||||
expect(content).toContain('code-blocks-font-family:')
|
||||
expect(content).toContain('blockquote-border-left-width:')
|
||||
expect(content).toContain('table-border-color:')
|
||||
expect(content).toContain('horizontal-rule-thickness:')
|
||||
expect(content).toContain('colors-text:')
|
||||
expect(content).toContain('colors-cursor:')
|
||||
|
||||
// Verify numeric values have px units
|
||||
expect(content).toContain('editor-font-size: 15px')
|
||||
expect(content).toContain('editor-max-width: 720px')
|
||||
|
||||
// Verify semantic vars are present
|
||||
expect(content).toContain('text-tertiary:')
|
||||
expect(content).toContain('bg-card:')
|
||||
expect(content).toContain('border-primary:')
|
||||
})
|
||||
|
||||
test('dark theme applies dark-specific color overrides', async ({ page }) => {
|
||||
await activateTheme(page, '/Users/luca/Laputa/theme/dark.md')
|
||||
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#0f0f1a')
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Dark overrides
|
||||
expect(await getCssVar(page, '--bg-card')).toBe('#16162a')
|
||||
expect(await getCssVar(page, '--text-primary')).toBe('#e0e0e0')
|
||||
expect(await getCssVar(page, '--border-primary')).toBe('#2a2a4a')
|
||||
|
||||
// Editor properties should still be present (inherited from defaults)
|
||||
expect(await getCssVar(page, '--editor-font-size')).toBe('15px')
|
||||
expect(await getCssVar(page, '--headings-h1-font-size')).toBe('32px')
|
||||
expect(await getCssVar(page, '--lists-bullet-size')).toBe('28px')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user