Compare commits

...

5 Commits

Author SHA1 Message Date
Test
845181d002 refactor: move vault UI config from ui.config.md to localStorage
Replace the in-vault ui.config.md file with localStorage persistence
keyed by vault path. Vaults now contain only user content — no app
config files. Removes the Rust vault_config module, Tauri commands
(get/save_vault_config), startup migrations, and demo vault config files.

The reactive store (vaultConfigStore) and consumer hooks (useZoom,
useViewMode, useRawMode) are unchanged — only the storage backend moved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:07:38 +01:00
Test
35c62583d9 style: rustfmt use statement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:01:15 +01:00
Test
a6b2454184 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>
2026-03-23 19:57:58 +01:00
Test
a74f76fdf1 test: add sidebar emoji icon rendering tests
Verify that SectionChildItem renders emoji icons before note titles
in expanded sidebar sections, and that notes without icons render
without any extra content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:32:16 +01:00
Test
c6fa1f48cb feat: add filter pills (Open/Archived/Trashed) to All Notes view
Reuses the existing FilterPills component and sub-filter mechanism from
sectionGroup views. Adds countAllByFilter helper for counting across all
entry types and extends filterByKind to support subFilter for the 'all'
filter. Bulk actions automatically adapt based on active pill. Switching
pills resets multi-selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:03:17 +01:00
44 changed files with 238 additions and 5513 deletions

View File

@@ -1,5 +0,0 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

@@ -1,5 +0,0 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

@@ -13,13 +13,9 @@ 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,
};
use crate::{frontmatter, git, github, indexing, menu, search, vault, vault_list};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -520,62 +516,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,13 +523,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)
vault::repair_config_files(&vault_path)?;
// Ensure .gitignore with sensible defaults exists
@@ -642,18 +575,6 @@ pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -846,7 +767,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 +776,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());
}

View File

@@ -9,9 +9,7 @@ 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;
use std::process::Child;
@@ -44,22 +42,6 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
// Migrate legacy config/ui.config.md → root ui.config.md
vault_config::migrate_ui_config_to_root(vp_str);
log_startup_result(
"Migrated hidden_sections to visible property",
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,18 +157,7 @@ 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
commands::repair_vault
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -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,

View File

@@ -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"
);
}
}

View File

@@ -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";

View File

@@ -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:"));
}
}

View File

@@ -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");
}
}

View File

@@ -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();

View File

@@ -1,579 +0,0 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Vault-wide UI configuration stored in `ui.config.md` at vault root.
///
/// This file is a regular vault note with YAML frontmatter, visible in the
/// sidebar under the "Config" section and editable like any note.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct VaultConfig {
pub zoom: Option<f64>,
pub view_mode: Option<String>,
pub editor_mode: Option<String>,
#[serde(default)]
pub tag_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub status_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub property_display_modes: Option<HashMap<String, String>>,
}
const CONFIG_FILENAME: &str = "ui.config.md";
fn config_path(vault_path: &str) -> std::path::PathBuf {
Path::new(vault_path).join(CONFIG_FILENAME)
}
/// Read the vault-wide UI config from `ui.config.md` at vault root.
/// Returns default values if the file doesn't exist.
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(VaultConfig::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
parse_vault_config(&content)
}
/// Parse VaultConfig from markdown content with YAML frontmatter.
fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return Ok(VaultConfig::default()),
};
let json_map: serde_json::Map<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
let json = serde_json::Value::Object(json_map);
serde_json::from_value(json.clone())
.or_else(|_| {
// If direct deserialization fails, strip the `type` field and retry
let mut map = match json {
serde_json::Value::Object(m) => m,
_ => return Ok(VaultConfig::default()),
};
map.remove("type");
serde_json::from_value(serde_json::Value::Object(map))
})
.map_err(|e| format!("Failed to parse config: {e}"))
}
/// Save the vault-wide UI config to `ui.config.md` at vault root.
/// Creates the file if it doesn't exist.
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
let path = config_path(vault_path);
let content = serialize_config(&config);
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
}
/// Migrate legacy `config/ui.config.md` → root `ui.config.md`.
/// If the root file already exists, the legacy file is simply removed.
/// Cleans up empty `config/` directory after migration.
pub fn migrate_ui_config_to_root(vault_path: &str) {
let vault = Path::new(vault_path);
let legacy = vault.join("config").join(CONFIG_FILENAME);
let root = vault.join(CONFIG_FILENAME);
if legacy.exists() {
if !root.exists() {
// Move legacy content to root
if let Ok(content) = std::fs::read_to_string(&legacy) {
let _ = std::fs::write(&root, content);
}
}
let _ = std::fs::remove_file(&legacy);
}
// Clean up empty config/ directory
let config_dir = vault.join("config");
if config_dir.is_dir() {
let is_empty = std::fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
if is_empty {
let _ = std::fs::remove_dir(&config_dir);
}
}
}
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
fn serialize_config(config: &VaultConfig) -> String {
let mut lines = vec!["---".to_string(), "type: config".to_string()];
if let Some(zoom) = config.zoom {
lines.push(format!("zoom: {zoom}"));
}
if let Some(ref mode) = config.view_mode {
lines.push(format!("view_mode: {mode}"));
}
if let Some(ref mode) = config.editor_mode {
lines.push(format!("editor_mode: {mode}"));
}
append_string_map(&mut lines, "tag_colors", config.tag_colors.as_ref());
append_string_map(&mut lines, "status_colors", config.status_colors.as_ref());
append_string_map(
&mut lines,
"property_display_modes",
config.property_display_modes.as_ref(),
);
lines.push("---".to_string());
lines.join("\n") + "\n"
}
/// Append a YAML map section with sorted keys for stable output.
fn append_string_map(lines: &mut Vec<String>, key: &str, map: Option<&HashMap<String, String>>) {
if let Some(m) = map {
if !m.is_empty() {
lines.push(format!("{key}:"));
let mut entries: Vec<_> = m.iter().collect();
entries.sort_by_key(|(k, _)| k.to_owned());
for (k, v) in entries {
lines.push(format!(" {}: {}", yaml_safe_key(k), yaml_safe_value(v)));
}
}
}
}
/// Quote a YAML key if it contains special characters.
fn yaml_safe_key(key: &str) -> String {
if key.contains(':') || key.contains('#') || key.contains(' ') {
format!("\"{}\"", key.replace('"', "\\\""))
} else {
key.to_string()
}
}
/// Quote a YAML value if it contains special characters.
fn yaml_safe_value(value: &str) -> String {
if value.contains(':')
|| value.contains('#')
|| value.starts_with('"')
|| value.starts_with('\'')
{
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Migrate `hidden_sections` from `ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
/// - If `<slug>.md` exists at vault root, adds `visible: false` to its frontmatter
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
/// - Re-saves the config without `hidden_sections`
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(0);
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
let hidden = extract_hidden_sections(&content);
if hidden.is_empty() {
return Ok(0);
}
let vault = Path::new(vault_path);
let mut migrated = 0;
for type_name in &hidden {
let slug = type_name_to_slug(type_name);
let type_path = vault.join(format!("{slug}.md"));
if type_path.exists() {
let type_content = std::fs::read_to_string(&type_path)
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
if !type_content.contains("visible:") {
let updated = crate::frontmatter::update_frontmatter_content(
&type_content,
"visible",
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
)
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
std::fs::write(&type_path, updated)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
} else {
let new_content = format!(
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
type_name, type_name
);
std::fs::write(&type_path, new_content)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
migrated += 1;
}
// Re-save config without hidden_sections
let config = parse_vault_config(&content)?;
save_vault_config(vault_path, config)?;
Ok(migrated)
}
/// Extract `hidden_sections` from raw YAML frontmatter.
fn extract_hidden_sections(content: &str) -> Vec<String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return vec![],
};
match hash.get("hidden_sections") {
Some(gray_matter::Pod::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
}
}
/// Convert a Type name to a filesystem slug.
fn type_name_to_slug(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("-")
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_returns_defaults() {
let config = parse_vault_config("").unwrap();
assert!(config.zoom.is_none());
assert!(config.view_mode.is_none());
assert!(config.tag_colors.is_none());
}
#[test]
fn parse_full_config() {
let content = r#"---
type: config
zoom: 1.1
view_mode: all
tag_colors:
engineering: blue
personal: green
status_colors:
Active: green
Done: blue
property_display_modes:
deadline: date
---
"#;
let config = parse_vault_config(content).unwrap();
assert_eq!(config.zoom, Some(1.1));
assert_eq!(config.view_mode.as_deref(), Some("all"));
let tags = config.tag_colors.unwrap();
assert_eq!(tags.get("engineering").unwrap(), "blue");
assert_eq!(tags.get("personal").unwrap(), "green");
let statuses = config.status_colors.unwrap();
assert_eq!(statuses.get("Active").unwrap(), "green");
let props = config.property_display_modes.unwrap();
assert_eq!(props.get("deadline").unwrap(), "date");
}
#[test]
fn roundtrip_serialization() {
let mut tag_colors = HashMap::new();
tag_colors.insert("work".to_string(), "blue".to_string());
let config = VaultConfig {
zoom: Some(1.2),
view_mode: Some("editor-only".to_string()),
editor_mode: None,
tag_colors: Some(tag_colors),
status_colors: None,
property_display_modes: None,
};
let serialized = serialize_config(&config);
let parsed = parse_vault_config(&serialized).unwrap();
assert_eq!(parsed.zoom, Some(1.2));
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
}
#[test]
fn get_config_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let config = get_vault_config(dir.path().to_str().unwrap()).unwrap();
assert!(config.zoom.is_none());
}
#[test]
fn save_and_read_config() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let mut status_colors = HashMap::new();
status_colors.insert("Active".to_string(), "green".to_string());
let config = VaultConfig {
zoom: Some(0.9),
view_mode: None,
editor_mode: None,
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
};
save_vault_config(vault_path, config).unwrap();
let loaded = get_vault_config(vault_path).unwrap();
assert_eq!(loaded.zoom, Some(0.9));
assert_eq!(
loaded.status_colors.unwrap().get("Active").unwrap(),
"green"
);
}
#[test]
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections at vault root
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 2);
// Check type notes were created
let bookmark = std::fs::read_to_string(dir.path().join("bookmark.md")).unwrap();
assert!(bookmark.contains("visible: false"));
assert!(bookmark.contains("title: Bookmark"));
let recipe = std::fs::read_to_string(dir.path().join("recipe.md")).unwrap();
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
#[test]
fn migrate_hidden_sections_updates_existing_type_note() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections at vault root
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
// Create existing type note at vault root without visible
std::fs::write(
dir.path().join("project.md"),
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(dir.path().join("project.md")).unwrap();
assert!(content.contains("visible: false"));
assert!(
content.contains("icon: briefcase"),
"should preserve existing fields"
);
}
#[test]
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_skips_when_no_config_file() {
let dir = tempfile::TempDir::new().unwrap();
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_does_not_duplicate_visible() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false at vault root
std::fs::write(
dir.path().join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(dir.path().join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
#[test]
fn type_name_to_slug_converts_names() {
assert_eq!(type_name_to_slug("Project"), "project");
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
assert_eq!(type_name_to_slug("My Note!"), "my-note");
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
}
#[test]
fn migrate_ui_config_moves_legacy_to_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.5\n---\n",
)
.unwrap();
migrate_ui_config_to_root(vault_path);
// Root file should exist with migrated content
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(content.contains("zoom: 1.5"));
// Legacy file and empty config dir should be gone
assert!(!config_dir.join("ui.config.md").exists());
assert!(!config_dir.exists());
}
#[test]
fn migrate_ui_config_preserves_existing_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Root already has content
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nzoom: 2.0\n---\n",
)
.unwrap();
// Legacy also exists
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
migrate_ui_config_to_root(vault_path);
// Root should keep its original content
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(content.contains("zoom: 2.0"));
// Legacy file should be removed
assert!(!config_dir.join("ui.config.md").exists());
}
#[test]
fn migrate_ui_config_keeps_nonempty_config_dir() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("ui.config.md"), "---\ntype: config\n---\n").unwrap();
std::fs::write(config_dir.join("other.md"), "Other file").unwrap();
migrate_ui_config_to_root(vault_path);
// config/ should still exist because it has other files
assert!(config_dir.exists());
assert!(config_dir.join("other.md").exists());
assert!(!config_dir.join("ui.config.md").exists());
}
#[test]
fn save_config_writes_to_vault_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config = VaultConfig {
zoom: Some(1.0),
..Default::default()
};
save_vault_config(vault_path, config).unwrap();
// File should be at vault root, not in config/
assert!(dir.path().join("ui.config.md").exists());
assert!(!dir.path().join("config").exists());
}
}

View File

@@ -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}

View File

@@ -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(() => {

View File

@@ -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'])
})
})
})

View File

@@ -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}

View File

@@ -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 />}

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList } from './NoteList'
import { getSortComparator, filterEntries, countByFilter } from '../utils/noteListHelpers'
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
@@ -1293,11 +1293,44 @@ describe('NoteList — filter pills', () => {
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
})
it('does not show filter pills in All Notes view', () => {
it('shows filter pills in All Notes view', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('filter-pills')).not.toBeInTheDocument()
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
})
it('shows correct All Notes count badges across all types', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived, 1 trashed
const openPill = screen.getByTestId('filter-pill-open')
const archivedPill = screen.getByTestId('filter-pill-archived')
const trashedPill = screen.getByTestId('filter-pill-trashed')
expect(openPill).toHaveTextContent('3')
expect(archivedPill).toHaveTextContent('1')
expect(trashedPill).toHaveTextContent('1')
})
it('shows archived notes in All Notes when filter is archived', () => {
render(
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Archived Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
})
it('shows trashed notes in All Notes when filter is trashed', () => {
render(
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows correct count badges for each filter', () => {
@@ -1377,4 +1410,33 @@ describe('NoteList — filterEntries with subFilter', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
expect(result.map(e => e.title)).toEqual(['Active'])
})
it('filters all notes by open sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'open')
expect(result.map(e => e.title)).toEqual(['Active', 'Other'])
})
it('filters all notes by archived sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived')
expect(result.map(e => e.title)).toEqual(['Archived'])
})
it('filters all notes by trashed sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'trashed')
expect(result.map(e => e.title)).toEqual(['Trashed'])
})
})
describe('countAllByFilter', () => {
it('counts all entries by filter status', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
makeEntry({ path: '/2.md', isA: 'Note' }),
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', isA: 'Note', trashed: true }),
makeEntry({ path: '/5.md', isA: 'Person', archived: true, trashed: true }),
]
const counts = countAllByFilter(entries)
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
})
})

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
import { NoteItem } from './NoteItem'
import { prefetchNoteContent } from '../hooks/useTabManagement'
import { BulkActionBar } from './BulkActionBar'
@@ -48,11 +48,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const isSectionGroup = selection.kind === 'sectionGroup'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const subFilter = isSectionGroup ? noteListFilter : undefined
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const showFilterPills = isSectionGroup || isAllNotesView
const subFilter = showFilterPills ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, selection],
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, selection],
)
const inboxCounts = useMemo(
@@ -75,7 +77,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
@@ -100,7 +102,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>

View File

@@ -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')

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -1058,4 +1058,49 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByText('Inbox'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
})
describe('emoji icon in sidebar section children', () => {
const entriesWithEmoji: VaultEntry[] = [
{
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
{
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
{
path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project',
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
]
it('shows emoji icon before title in expanded section child', () => {
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand Projects'))
const buildApp = screen.getByText('Build App')
const parent = buildApp.closest('div')!
expect(parent.textContent).toBe('🚀Build App')
})
it('does not show emoji for notes without icon', () => {
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand Projects'))
const noIcon = screen.getByText('No Icon Project')
const parent = noIcon.closest('div')!
expect(parent.textContent).toBe('No Icon Project')
})
})
})

View File

@@ -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}
>

View File

@@ -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')
})
})

View File

@@ -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>
)
}

View File

@@ -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' },

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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',

View File

@@ -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()
})
})

View File

@@ -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,
}
}

View File

@@ -1,6 +1,4 @@
import { useEffect, useCallback, useSyncExternalStore } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultConfig } from '../types'
import { initStatusColors } from '../utils/statusStyles'
import { initTagColors } from '../utils/tagStyles'
@@ -14,8 +12,32 @@ import {
} from '../utils/vaultConfigStore'
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
const STORAGE_PREFIX = 'laputa:vault-config:'
function storageKey(vaultPath: string): string {
return `${STORAGE_PREFIX}${vaultPath}`
}
function loadFromStorage(vaultPath: string): VaultConfig {
const DEFAULT: VaultConfig = {
zoom: null, view_mode: null, editor_mode: null,
tag_colors: null, status_colors: null, property_display_modes: null,
}
try {
const raw = localStorage.getItem(storageKey(vaultPath))
if (!raw) return DEFAULT
return { ...DEFAULT, ...JSON.parse(raw) }
} catch {
return DEFAULT
}
}
function saveToStorage(vaultPath: string, config: VaultConfig): void {
try {
localStorage.setItem(storageKey(vaultPath), JSON.stringify(config))
} catch (err) {
console.warn('Failed to save vault config:', err)
}
}
function applyToModules(c: VaultConfig): void {
@@ -24,34 +46,18 @@ function applyToModules(c: VaultConfig): void {
initDisplayModeOverrides(c.property_display_modes ?? {})
}
function persistConfig(vaultPath: string, config: VaultConfig): void {
tauriCall<void>('save_vault_config', { vaultPath, config })
.catch((err) => console.warn('Failed to save vault config:', err))
}
export function useVaultConfig(vaultPath: string) {
const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
useEffect(() => {
resetVaultConfigStore()
tauriCall<VaultConfig>('get_vault_config', { vaultPath })
.then((loaded) => {
const migrated = migrateLocalStorageToVaultConfig(loaded)
const needsSave = migrated !== loaded
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (needsSave) persistConfig(vaultPath, migrated)
})
.catch((err) => {
console.warn('Failed to load vault config:', err)
const migrated = migrateLocalStorageToVaultConfig(null)
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (migrated.zoom !== null || migrated.tag_colors !== null || migrated.status_colors !== null) {
persistConfig(vaultPath, migrated)
}
})
const loaded = loadFromStorage(vaultPath)
const migrated = migrateLocalStorageToVaultConfig(loaded)
const needsSave = migrated !== loaded
bindVaultConfigStore(migrated, (c) => saveToStorage(vaultPath, c))
applyToModules(migrated)
if (needsSave) saveToStorage(vaultPath, migrated)
return () => resetVaultConfigStore()
}, [vaultPath])

View File

@@ -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.
`,
}

View File

@@ -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))

View File

@@ -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, 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,192 +268,7 @@ 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,
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -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

View File

@@ -315,6 +315,7 @@ function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFil
const typeEntries = entries.filter((e) => e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
if (selection.filter === 'all' && subFilter) return applySubFilter(entries, subFilter)
return filterByFilterType(entries, selection.filter)
}
@@ -342,6 +343,17 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
return { open, archived, trashed }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
let open = 0, archived = 0, trashed = 0
for (const e of entries) {
if (e.trashed) trashed++
else if (e.archived) archived++
else open++
}
return { open, archived, trashed }
}
// --- Inbox ---
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -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')
})
})

View File

@@ -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')
})
})