Compare commits
25 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec | ||
|
|
d2538e121f | ||
|
|
531666b031 | ||
|
|
e239d71a48 | ||
|
|
e2f1fe239e | ||
|
|
8495f0e2e6 | ||
|
|
19bc3c67e3 | ||
|
|
628d97d909 | ||
|
|
aa6b31317c | ||
|
|
b24ba8a4c6 | ||
|
|
b3f47f4507 | ||
|
|
6fcd0552d0 | ||
|
|
8747a84453 | ||
|
|
2eb3080050 | ||
|
|
99b64c0fac | ||
|
|
b2a68a9a67 | ||
|
|
f5bbcb81a4 | ||
|
|
db5e53f77e | ||
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 |
@@ -1 +1 @@
|
||||
81859
|
||||
21351
|
||||
|
||||
1
design/raw-editor-mode.pen
Normal file
1
design/raw-editor-mode.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/themes-editable.pen
Normal file
1
design/themes-editable.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -372,6 +372,12 @@ fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String,
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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())
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -398,8 +404,10 @@ fn run_startup_tasks() {
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
|
||||
// Seed _themes/ with built-in themes if missing
|
||||
// Seed _themes/ with built-in JSON themes (legacy) if missing
|
||||
theme::seed_default_themes(vp_str);
|
||||
// Seed theme/ with built-in vault theme notes if missing
|
||||
theme::seed_vault_themes(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
@@ -549,7 +557,8 @@ pub fn run() {
|
||||
get_vault_settings,
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme
|
||||
create_theme,
|
||||
create_vault_theme
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -116,20 +116,112 @@ pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String>
|
||||
parse_theme_file(&path)
|
||||
}
|
||||
|
||||
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
|
||||
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
|
||||
if dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
for (name, content) in files {
|
||||
let _ = fs::write(dir.join(name), content);
|
||||
}
|
||||
log::info!("{log_msg}");
|
||||
}
|
||||
|
||||
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_default_themes(vault_path: &str) {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if themes_dir.is_dir() {
|
||||
return;
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("_themes"),
|
||||
&[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
],
|
||||
"Seeded _themes/ with built-in themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_vault_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("theme"),
|
||||
&[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
],
|
||||
"Seeded theme/ with built-in vault themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new vault theme note in `theme/` directory.
|
||||
/// 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 theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
|
||||
let display_name = name.unwrap_or("Untitled Theme");
|
||||
let slug = slugify(display_name);
|
||||
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
|
||||
let path = theme_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())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
if fs::create_dir_all(&themes_dir).is_err() {
|
||||
return;
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base}-{i}");
|
||||
if !dir.join(format!("{candidate}.{ext}")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME);
|
||||
let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME);
|
||||
let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME);
|
||||
log::info!("Seeded _themes/ with built-in themes");
|
||||
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 {
|
||||
// Values with '#' or spaces need quoting; others can be bare strings.
|
||||
if 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
|
||||
}
|
||||
|
||||
/// Create a new theme file by copying the active theme (or default).
|
||||
@@ -139,7 +231,7 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
|
||||
let new_id = find_available_id(&themes_dir, "untitled");
|
||||
let 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"));
|
||||
@@ -169,20 +261,6 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...).
|
||||
fn find_available_id(dir: &Path, base: &str) -> String {
|
||||
if !dir.join(format!("{base}.json")).exists() {
|
||||
return base.to_string();
|
||||
}
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base}-{i}");
|
||||
if !dir.join(format!("{candidate}.json")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Generate the default light theme JSON.
|
||||
fn default_theme_json(name: &str) -> String {
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
@@ -312,6 +390,233 @@ pub const MINIMAL_THEME: &str = r##"{
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
("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-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
("bg-primary", "#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
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -462,4 +767,88 @@ mod tests {
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2); // broken.json is skipped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_creates_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();
|
||||
|
||||
assert!(!vault.join("theme").exists());
|
||||
seed_vault_themes(vp);
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").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("theme").join("default.md").exists());
|
||||
}
|
||||
|
||||
#[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_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:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,6 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
|
||||
Some((&line[..2], line[3..].trim().to_string()))
|
||||
}
|
||||
|
||||
/// Check if a porcelain status indicates a new/untracked file.
|
||||
fn is_new_file_status(status: &str) -> bool {
|
||||
status == "??" || status.starts_with('A')
|
||||
}
|
||||
|
||||
/// Extract .md file paths from git diff --name-only output.
|
||||
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
@@ -80,11 +75,15 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
.map(|s| collect_md_paths_from_diff(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let uncommitted = run_git(vault, &["status", "--porcelain"])
|
||||
// Use ls-files for untracked files so that newly-seeded directories are picked up
|
||||
// as individual files rather than as a single "?? dirname/" entry.
|
||||
let uncommitted = git_uncommitted_files(vault);
|
||||
// Also include modified-but-unstaged files via status --porcelain.
|
||||
let modified = run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in uncommitted {
|
||||
for path in uncommitted.into_iter().chain(modified) {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
@@ -93,17 +92,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
files
|
||||
}
|
||||
|
||||
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
|
||||
let stdout = match run_git(vault, &["status", "--porcelain"]) {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(parse_porcelain_line)
|
||||
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
|
||||
.map(|(_, path)| path)
|
||||
.collect()
|
||||
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
|
||||
run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn load_cache(vault: &Path) -> Option<VaultCache> {
|
||||
@@ -157,23 +149,19 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
entries
|
||||
}
|
||||
|
||||
/// Handle same-commit cache hit: add any uncommitted new files.
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let new_files = git_uncommitted_new_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
let existing: std::collections::HashSet<String> = entries
|
||||
.iter()
|
||||
.map(|e| to_relative_path(&e.path, vault))
|
||||
.collect();
|
||||
|
||||
let new_entries = parse_files_at(vault, &new_files);
|
||||
for entry in new_entries {
|
||||
let rel = to_relative_path(&entry.path, vault);
|
||||
if !existing.contains(&rel) {
|
||||
entries.push(entry);
|
||||
}
|
||||
let changed = git_uncommitted_files(vault);
|
||||
if changed.is_empty() {
|
||||
return cache.entries;
|
||||
}
|
||||
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
finalize_and_cache(vault, entries, cache.commit_hash)
|
||||
}
|
||||
|
||||
@@ -358,4 +346,108 @@ mod tests {
|
||||
assert!(titles.contains(&"First"));
|
||||
assert!(titles.contains(&"Second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_picks_up_modified_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note without sidebar label
|
||||
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime the cache (same commit hash)
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].sidebar_label, None);
|
||||
|
||||
// User edits the type note to add sidebar label (uncommitted)
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/news.md",
|
||||
"---\ntype: Type\nsidebar label: News\n---\n# News\n",
|
||||
);
|
||||
|
||||
// Reload with same git HEAD — must pick up the modification
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].sidebar_label,
|
||||
Some("News".to_string()),
|
||||
"sidebarLabel must reflect the uncommitted edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Create new untracked file
|
||||
create_test_file(vault, "new-note.md", "# New Note\n");
|
||||
|
||||
// Cache still same commit — new untracked file must appear
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 2);
|
||||
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
|
||||
assert!(titles.contains(&"Existing"));
|
||||
assert!(titles.contains(&"New Note"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"identifier": "club.refactoring.laputa",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5201",
|
||||
"devUrl": "http://localhost:5202",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -123,7 +123,7 @@ function App() {
|
||||
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const themeManager = useThemeManager(resolvedPath)
|
||||
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
|
||||
|
||||
useMcpRegistration(resolvedPath, setToastMessage)
|
||||
|
||||
@@ -270,6 +270,9 @@ function App() {
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
@@ -290,6 +293,7 @@ function App() {
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
||||
@@ -299,7 +303,15 @@ function App() {
|
||||
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
|
||||
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
|
||||
onSwitchTheme: themeManager.switchTheme,
|
||||
onCreateTheme: async () => { await themeManager.createTheme() },
|
||||
onCreateTheme: async () => {
|
||||
await themeManager.createTheme()
|
||||
await vault.reloadVault()
|
||||
setSelection({ kind: 'sectionGroup', type: 'Theme' })
|
||||
},
|
||||
onOpenTheme: (themeId: string) => {
|
||||
const entry = vault.entries.find(e => e.path === themeId)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
},
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
})
|
||||
@@ -343,7 +355,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -389,7 +401,9 @@ function App() {
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
onTitleSync={handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
|
||||
@@ -115,3 +115,24 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Back to editor" tooltip when rawMode is on', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRaw when raw button is clicked', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
Code,
|
||||
CursorText,
|
||||
Sparkle,
|
||||
SlidersHorizontal,
|
||||
@@ -22,6 +23,8 @@ interface BreadcrumbBarProps {
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
onToggleDiff: () => void
|
||||
rawMode?: boolean
|
||||
onToggleRaw?: () => void
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
@@ -34,7 +37,23 @@ interface BreadcrumbBarProps {
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
|
||||
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleRaw}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
@@ -68,6 +87,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -129,7 +129,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
|
||||
@@ -67,6 +67,21 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
/* Override BlockNote's internal color variables so .bn-editor background
|
||||
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
|
||||
--bn-colors-editor-background: var(--bg-primary);
|
||||
--bn-colors-editor-text: var(--text-primary);
|
||||
--bn-colors-menu-background: var(--bg-card);
|
||||
--bn-colors-menu-text: var(--text-primary);
|
||||
--bn-colors-tooltip-background: var(--bg-hover);
|
||||
--bn-colors-tooltip-text: var(--text-primary);
|
||||
--bn-colors-hovered-background: var(--bg-hover);
|
||||
--bn-colors-hovered-text: var(--text-primary);
|
||||
--bn-colors-selected-background: var(--bg-selected);
|
||||
--bn-colors-selected-text: var(--text-primary);
|
||||
--bn-colors-border: var(--border-primary);
|
||||
--bn-colors-shadow: var(--border-primary);
|
||||
--bn-colors-side-menu: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-editor {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { TabBar } from './TabBar'
|
||||
import { useDiffMode } from '../hooks/useDiffMode'
|
||||
import { useRawMode } from '../hooks/useRawMode'
|
||||
import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
@@ -53,6 +54,7 @@ interface EditorProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
/** Called when H1→title sync updates the title (debounced). */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
canGoBack?: boolean
|
||||
@@ -61,6 +63,34 @@ interface EditorProps {
|
||||
onGoForward?: () => void
|
||||
leftPanelsCollapsed?: boolean
|
||||
isDarkTheme?: boolean
|
||||
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
}: {
|
||||
diffMode: boolean
|
||||
rawMode: boolean
|
||||
handleToggleDiff: () => void | Promise<void>
|
||||
handleToggleRaw: () => void
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}) {
|
||||
const handleToggleDiffExclusive = useCallback(async () => {
|
||||
if (!diffMode && rawMode) handleToggleRaw()
|
||||
await handleToggleDiff()
|
||||
}, [diffMode, rawMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
const handleToggleRawExclusive = useCallback(() => {
|
||||
if (!rawMode && diffMode) handleToggleDiff()
|
||||
handleToggleRaw()
|
||||
}, [rawMode, diffMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
useEffect(() => {
|
||||
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
|
||||
}, [rawToggleRef, handleToggleRawExclusive])
|
||||
|
||||
return { handleToggleDiffExclusive, handleToggleRawExclusive }
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
@@ -81,9 +111,10 @@ export const Editor = memo(function Editor({
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
onRenameTab, onContentChange, onSave, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme,
|
||||
rawToggleRef,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -116,6 +147,13 @@ export const Editor = memo(function Editor({
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
})
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -149,7 +187,11 @@ export const Editor = memo(function Editor({
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={handleToggleDiff}
|
||||
onToggleDiff={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
onToggleRaw={handleToggleRawExclusive}
|
||||
onRawContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
@@ -19,6 +20,10 @@ interface EditorContentProps {
|
||||
diffContent: string | null
|
||||
diffLoading: boolean
|
||||
onToggleDiff: () => void
|
||||
rawMode: boolean
|
||||
onToggleRaw: () => void
|
||||
onRawContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
activeStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
@@ -63,6 +68,28 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
)
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
entries: VaultEntry[]
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
<RawEditorView
|
||||
key={activeTab.entry.path}
|
||||
content={activeTab.content}
|
||||
path={activeTab.entry.path}
|
||||
entries={entries}
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bind an optional callback to a path, returning undefined if callback is absent */
|
||||
function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
return cb ? () => cb(path) : undefined
|
||||
@@ -70,7 +97,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
activeTab: Tab
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange'>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
const path = activeTab.entry.path
|
||||
@@ -83,6 +110,8 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
onToggleDiff={props.onToggleDiff}
|
||||
rawMode={props.rawMode}
|
||||
onToggleRaw={props.onToggleRaw}
|
||||
showAIChat={props.showAIChat}
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
@@ -98,19 +127,28 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
{activeTab && <ActiveTabBreadcrumb activeTab={activeTab} props={{ diffMode, diffContent, onToggleDiff, ...breadcrumbProps }} />}
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
{!diffMode && activeTab && (
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
143
src/components/RawEditorView.test.tsx
Normal file
143
src/components/RawEditorView.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
// Minimal VaultEntry factory
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path, filename: `${title}.md`, title, isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: 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, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
content: '---\ntitle: My Note\n---\n\n# My Note\n\nSome content.',
|
||||
path: '/vault/note/my-note.md',
|
||||
entries: [entry('Project Alpha'), entry('Meeting Notes')],
|
||||
onContentChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
// cursor at 6 = after "[[Proj" (before the space)
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders textarea with the provided content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe(defaultProps.content)
|
||||
})
|
||||
|
||||
it('calls onContentChange when user types (debounced)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onContentChange = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onContentChange={onContentChange} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: '---\ntitle: Changed\n---\n\n# Changed' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
|
||||
// After debounce
|
||||
await act(async () => { vi.advanceTimersByTime(600) })
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
defaultProps.path,
|
||||
'---\ntitle: Changed\n---\n\n# Changed'
|
||||
)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows YAML error banner for unclosed frontmatter', () => {
|
||||
render(<RawEditorView {...defaultProps} content="---\ntitle: Bad\n\n# Title" />)
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('does not show YAML error for valid content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
expect(screen.queryByTestId('raw-editor-yaml-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave when Cmd+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSave when Ctrl+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', ctrlKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has monospaced font family applied', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.style.fontFamily).toContain('monospace')
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.tsx
Normal file
276
src/components/RawEditorView.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Get approximate pixel coordinates of the cursor in a textarea. */
|
||||
function getCaretCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number,
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement('div')
|
||||
const style = getComputedStyle(textarea)
|
||||
|
||||
const props = [
|
||||
'boxSizing', 'width', 'borderTopWidth', 'borderRightWidth',
|
||||
'borderBottomWidth', 'borderLeftWidth',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'fontStyle', 'fontVariant', 'fontWeight', 'fontSize', 'lineHeight',
|
||||
'fontFamily', 'textTransform', 'letterSpacing', 'wordSpacing',
|
||||
] as const
|
||||
for (const prop of props) {
|
||||
(mirror.style as unknown as Record<string, string>)[prop] = style[prop] as string
|
||||
}
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.top = '0'
|
||||
mirror.style.left = '-9999px'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.wordWrap = 'break-word'
|
||||
mirror.style.overflow = 'hidden'
|
||||
|
||||
mirror.textContent = textarea.value.slice(0, position)
|
||||
const caret = document.createElement('span')
|
||||
caret.textContent = '\u200B'
|
||||
mirror.appendChild(caret)
|
||||
document.body.appendChild(mirror)
|
||||
|
||||
const caretRect = caret.getBoundingClientRect()
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
return {
|
||||
top: caretRect.top - mirrorRect.top + textareaRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - mirrorRect.left + textareaRect.left,
|
||||
}
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
entries: VaultEntry[]
|
||||
onContentChange: (path: string, content: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave }: RawEditorViewProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
useEffect(() => { pathRef.current = path }, [path])
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [value, setValue] = useState(content)
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
// NOTE: tab-switch reset is handled via key={path} in the parent (EditorContent)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
|
||||
/** Insert [[entryTitle]] at the current [[ trigger position */
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const cursor = textarea.selectionStart
|
||||
const text = textarea.value
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = text.slice(cursor)
|
||||
const newText = `${text.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
|
||||
setValue(newText)
|
||||
setAutocomplete(null)
|
||||
|
||||
// Flush immediately — autocomplete inserts should not be debounced
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = newCursor
|
||||
textareaRef.current.selectionEnd = newCursor
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursor = e.target.selectionStart ?? 0
|
||||
|
||||
setValue(newValue)
|
||||
setYamlError(detectYamlError(newValue))
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onContentChangeRef.current(pathRef.current, newValue)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
const query = extractWikilinkQuery(newValue, cursor)
|
||||
if (query === null || query.length < MIN_QUERY_LENGTH) {
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = e.target
|
||||
const coords = getCaretCoordinates(textarea, cursor)
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, insertWikilink)
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
}, [baseItems, typeEntryMap, insertWikilink])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Save shortcut
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, textareaRef.current?.value ?? '')
|
||||
}
|
||||
onSaveRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
if (!autocomplete) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
|
||||
: null)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
|
||||
: null)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(null)
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
|
||||
const closeAutocomplete = useCallback(() => setAutocomplete(null), [])
|
||||
|
||||
// Flush pending debounce on unmount (e.g. switching tabs while in raw mode)
|
||||
useEffect(() => {
|
||||
const pendingPath = pathRef
|
||||
const pendingChange = onContentChangeRef
|
||||
const pendingDebounce = debounceRef
|
||||
const pendingTextarea = textareaRef
|
||||
return () => {
|
||||
if (pendingDebounce.current) {
|
||||
clearTimeout(pendingDebounce.current)
|
||||
pendingChange.current(pendingPath.current, pendingTextarea.current?.value ?? '')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 20 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 4)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }}>
|
||||
{yamlError && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
|
||||
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
|
||||
role="alert"
|
||||
data-testid="raw-editor-yaml-error"
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>YAML error:</span>
|
||||
<span>{yamlError}</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={closeAutocomplete}
|
||||
className="flex-1 resize-none border-none outline-none p-8"
|
||||
style={{
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
tabSize: 2,
|
||||
minHeight: 0,
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="Raw editor"
|
||||
data-testid="raw-editor-textarea"
|
||||
/>
|
||||
{autocomplete && autocomplete.items.length > 0 && (
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
data-testid="raw-editor-wikilink-dropdown"
|
||||
>
|
||||
<NoteSearchList
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -209,7 +209,6 @@ function SearchContent({
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
{noteType && (
|
||||
|
||||
@@ -809,11 +809,67 @@ describe('Sidebar', () => {
|
||||
expect(topicsIdx).toBeLessThan(peopleIdx)
|
||||
})
|
||||
|
||||
it('renders drag handle on section headers', () => {
|
||||
it('does not render drag handle icons on section headers', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const dragHandles = screen.getAllByLabelText(/^Drag to reorder/)
|
||||
// Should have one drag handle per visible section group
|
||||
expect(dragHandles.length).toBeGreaterThan(0)
|
||||
const dragHandles = screen.queryAllByLabelText(/^Drag to reorder/)
|
||||
expect(dragHandles.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename section via context menu', () => {
|
||||
it('shows Rename section option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Rename section…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Customize icon option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Customize icon & color…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows inline input when Rename section is clicked', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inline input is pre-filled with current label', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement
|
||||
expect(input.value).toBe('Projects')
|
||||
})
|
||||
|
||||
it('calls onRenameSection with new name on Enter', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.change(input, { target: { value: 'My Projects' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects')
|
||||
})
|
||||
|
||||
it('cancels rename on Escape and hides input', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onRenameSection).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ interface SidebarProps {
|
||||
onCustomizeType?: (typeName: string, icon: string, color: string) => void
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
@@ -161,12 +162,13 @@ function applyCustomization(
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'dragHandleProps' | 'onToggle'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
|
||||
@@ -176,7 +178,10 @@ function SortableSection({ group, sectionProps }: {
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
onRenameSubmit={sectionProps.onRenameSubmit}
|
||||
onRenameCancel={sectionProps.onRenameCancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -216,16 +221,21 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: {
|
||||
pos: { x: number; y: number } | null; type: string | null
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onOpenCustomize: (type: string) => void
|
||||
onStartRename: (type: string) => void
|
||||
}) {
|
||||
if (!pos || !type) return null
|
||||
const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
|
||||
return (
|
||||
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
|
||||
<button className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" onClick={() => onOpenCustomize(type)}>
|
||||
Customize icon & color…
|
||||
<button className={btnClass} onClick={() => onStartRename(type)}>
|
||||
Rename section…
|
||||
</button>
|
||||
<button className={btnClass} onClick={() => onOpenCustomize(type)}>
|
||||
Customize icon & color…
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -258,11 +268,14 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
modifiedCount = 0, onCommitPush, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
const [renameInitialValue, setRenameInitialValue] = useState('')
|
||||
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
|
||||
@@ -303,6 +316,20 @@ export const Sidebar = memo(function Sidebar({
|
||||
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
|
||||
}, [])
|
||||
|
||||
const cancelRename = useCallback(() => setRenamingType(null), [])
|
||||
|
||||
const handleStartRename = useCallback((type: string) => {
|
||||
closeContextMenu()
|
||||
const group = allSectionGroups.find((g) => g.type === type)
|
||||
setRenameInitialValue(group?.label ?? type)
|
||||
setRenamingType(type)
|
||||
}, [closeContextMenu, allSectionGroups])
|
||||
|
||||
const handleRenameSubmit = useCallback((value: string) => {
|
||||
if (renamingType) onRenameSection?.(renamingType, value)
|
||||
setRenamingType(null)
|
||||
}, [renamingType, onRenameSection])
|
||||
|
||||
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
|
||||
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
@@ -314,6 +341,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -354,7 +382,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
</nav>
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ComponentType } from 'react'
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
@@ -75,7 +75,10 @@ export interface SectionContentProps {
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
|
||||
@@ -90,7 +93,8 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
@@ -109,7 +113,10 @@ export function SectionContent({
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
@@ -145,29 +152,67 @@ function SectionChildList({ items, type, selection, sectionColor, sectionLightCo
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: {
|
||||
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
initialValue: string
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSubmit(value.trim())}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Section name"
|
||||
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
|
||||
style={{ padding: '1px 4px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={onContextMenu}
|
||||
}} onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 4 }}>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<Icon size={16} style={{ color: sectionColor }} />
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
{isRenaming && onRenameSubmit && onRenameCancel ? (
|
||||
<InlineRenameInput
|
||||
key={`rename-${type}`}
|
||||
initialValue={renameInitialValue ?? label}
|
||||
onSubmit={onRenameSubmit}
|
||||
onCancel={onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
|
||||
@@ -16,7 +16,7 @@ export function StatusPill({ status, className }: { status: string; className?:
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
@@ -101,7 +101,7 @@ const SECTION_LABEL_STYLE = {
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ interface AppCommandsConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -50,6 +51,7 @@ interface AppCommandsConfig {
|
||||
activeThemeId?: string | null
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
onOpenVault?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
}
|
||||
@@ -87,6 +89,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -129,6 +132,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCommitPush: config.onCommitPush,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
@@ -144,6 +148,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
activeThemeId: config.activeThemeId,
|
||||
onSwitchTheme: config.onSwitchTheme,
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onOpenTheme: config.onOpenTheme,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ interface KeyboardActions {
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -63,7 +64,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -89,6 +90,7 @@ export function useAppKeyboard({
|
||||
'-': onZoomOut,
|
||||
'0': onZoomReset,
|
||||
i: () => onToggleAIChat?.(),
|
||||
'\\': () => onToggleRawEditor?.(),
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -104,5 +106,5 @@ export function useAppKeyboard({
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor])
|
||||
}
|
||||
|
||||
@@ -156,6 +156,36 @@ describe('useCommandRegistry', () => {
|
||||
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('has toggle-raw-editor command in View group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
})
|
||||
|
||||
it('disables toggle-raw-editor when no note is open', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables toggle-raw-editor when a note is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleRawEditor when toggle-raw-editor executes', () => {
|
||||
const onToggleRawEditor = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleRawEditor })),
|
||||
)
|
||||
result.current.find(c => c.id === 'toggle-raw-editor')!.execute()
|
||||
expect(onToggleRawEditor).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables commit when no modified files', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
|
||||
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
|
||||
@@ -407,6 +437,46 @@ describe('useCommandRegistry', () => {
|
||||
const groups = new Set(result.current.map(c => c.group))
|
||||
expect(groups).toContain('Appearance')
|
||||
})
|
||||
|
||||
it('generates open-theme commands for each theme when onOpenTheme is provided', () => {
|
||||
const onOpenTheme = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
|
||||
})))
|
||||
const openDefault = result.current.find(c => c.id === 'open-theme-default')
|
||||
const openDark = result.current.find(c => c.id === 'open-theme-dark')
|
||||
expect(openDefault).toBeDefined()
|
||||
expect(openDefault!.label).toBe('Edit Default Theme')
|
||||
expect(openDefault!.group).toBe('Appearance')
|
||||
expect(openDefault!.enabled).toBe(true)
|
||||
expect(openDark).toBeDefined()
|
||||
expect(openDark!.label).toBe('Edit Dark Theme')
|
||||
})
|
||||
|
||||
it('omits open-theme commands when onOpenTheme is not provided', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
themes: themeFixtures, activeThemeId: 'default',
|
||||
})))
|
||||
expect(result.current.find(c => c.id === 'open-theme-default')).toBeUndefined()
|
||||
expect(result.current.find(c => c.id === 'open-theme-dark')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onOpenTheme with correct themeId when open-theme command executes', () => {
|
||||
const onOpenTheme = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
|
||||
})))
|
||||
result.current.find(c => c.id === 'open-theme-dark')!.execute()
|
||||
expect(onOpenTheme).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
|
||||
it('open-theme command is always enabled regardless of active theme', () => {
|
||||
const onOpenTheme = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
|
||||
})))
|
||||
expect(result.current.find(c => c.id === 'open-theme-default')!.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ interface CommandRegistryConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -48,6 +49,7 @@ interface CommandRegistryConfig {
|
||||
activeThemeId?: string | null
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
@@ -102,27 +104,65 @@ export function buildTypeCommands(
|
||||
})
|
||||
}
|
||||
|
||||
export function buildViewCommands(
|
||||
hasActiveNote: boolean,
|
||||
onSetViewMode: (mode: ViewMode) => void,
|
||||
onToggleInspector: () => void,
|
||||
onToggleRawEditor: (() => void) | undefined,
|
||||
onToggleAIChat: (() => void) | undefined,
|
||||
zoomLevel: number,
|
||||
onZoomIn: () => void,
|
||||
onZoomOut: () => void,
|
||||
onZoomReset: () => void,
|
||||
): CommandAction[] {
|
||||
return [
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
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 switchCmds = (themes ?? []).map(t => ({
|
||||
id: `switch-theme-${t.id}`,
|
||||
label: `Switch to ${t.name} Theme`,
|
||||
group: 'Appearance' as CommandGroup,
|
||||
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
|
||||
enabled: t.id !== activeThemeId,
|
||||
execute: () => onSwitchTheme?.(t.id),
|
||||
}))
|
||||
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) {
|
||||
switchCmds.push({
|
||||
cmds.push({
|
||||
id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup,
|
||||
keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme,
|
||||
})
|
||||
}
|
||||
return switchCmds
|
||||
return cmds
|
||||
}
|
||||
|
||||
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
|
||||
@@ -130,11 +170,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -181,17 +221,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
|
||||
|
||||
// Appearance
|
||||
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
|
||||
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
|
||||
|
||||
// Settings
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
@@ -206,10 +239,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2,19 +2,33 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
|
||||
function makeTiptapMock(hasHeading = true) {
|
||||
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
|
||||
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
|
||||
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
|
||||
})
|
||||
return {
|
||||
state: { doc: { descendants: descendantsMock } },
|
||||
chain: vi.fn(() => chainResult),
|
||||
_chainResult: chainResult,
|
||||
_descendantsMock: descendantsMock,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEditorFocus', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function setup(isMounted: boolean) {
|
||||
const editor = { focus: vi.fn() }
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return editor
|
||||
return { editor, tiptap }
|
||||
}
|
||||
|
||||
it('focuses editor via rAF when already mounted', async () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const editor = setup(true)
|
||||
const { editor } = setup(true)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -24,7 +38,7 @@ describe('useEditorFocus', () => {
|
||||
|
||||
it('focuses editor via setTimeout when not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
const editor = setup(false)
|
||||
const { editor } = setup(false)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -35,7 +49,8 @@ describe('useEditorFocus', () => {
|
||||
})
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const editor = { focus: vi.fn() }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn() } as any
|
||||
const mountedRef = { current: true }
|
||||
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
|
||||
@@ -45,4 +60,102 @@ describe('useEditorFocus', () => {
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selectTitle behavior', () => {
|
||||
it('selects H1 text when selectTitle is true and editor is mounted', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is false (default)', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: false } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is absent from event detail', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips selection when no heading found in document', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(false)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects H1 text after timeout when editor not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus
|
||||
// runs immediately when requestAnimationFrame is invoked, keeping the test simple.
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(false, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('selection happens in second rAF (not first), allowing content swap to complete', () => {
|
||||
// Verify the double-rAF contract: focus in rAF1, selection deferred to rAF2.
|
||||
// This ensures the new note's blocks are applied (via queueMicrotask between frames)
|
||||
// before selectFirstHeading runs.
|
||||
const callbacks: FrameRequestCallback[] = []
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
callbacks.push(cb)
|
||||
return callbacks.length
|
||||
})
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
// rAF 1 is scheduled (doFocus)
|
||||
expect(callbacks.length).toBe(1)
|
||||
callbacks[0](0)
|
||||
|
||||
// After rAF 1: editor focused, but selection NOT yet triggered
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
|
||||
// rAF 2 is now scheduled (selectFirstHeading)
|
||||
expect(callbacks.length).toBe(2)
|
||||
callbacks[1](0)
|
||||
|
||||
// After rAF 2: heading is selected
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
}
|
||||
|
||||
interface TiptapEditor {
|
||||
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
||||
chain: () => TiptapChain
|
||||
}
|
||||
|
||||
/** Select all text in the first heading block via the TipTap chain API. */
|
||||
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return
|
||||
|
||||
let from = -1
|
||||
let to = -1
|
||||
|
||||
tiptap.state.doc.descendants((node, pos) => {
|
||||
if (from !== -1) return false
|
||||
if (node.type.name === 'heading') {
|
||||
from = pos + 1
|
||||
to = pos + node.nodeSize - 1
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus editor when a new note is created (signaled via custom event).
|
||||
* Uses adaptive timing: fast rAF path when editor is already mounted,
|
||||
* short timeout when waiting for first mount.
|
||||
* When selectTitle is true, also selects all text in the first H1 block.
|
||||
*/
|
||||
export function useEditorFocus(
|
||||
editor: { focus: () => void },
|
||||
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const t0 = (e as CustomEvent).detail?.t0 as number | undefined
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
|
||||
const t0 = detail?.t0
|
||||
const selectTitle = detail?.selectTitle ?? false
|
||||
const doFocus = () => {
|
||||
editor.focus()
|
||||
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
if (!selectTitle) {
|
||||
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
return
|
||||
}
|
||||
// Defer selection to the next animation frame so the new note's content
|
||||
// (applied via queueMicrotask inside a React effect triggered by the tab
|
||||
// change) is in the document before we try to select the heading.
|
||||
// Between two rAF callbacks, all pending macrotasks — including React's
|
||||
// MessageChannel re-render and the subsequent queueMicrotask content swap
|
||||
// — complete, so the heading block is guaranteed to exist by rAF 2.
|
||||
requestAnimationFrame(() => {
|
||||
selectFirstHeading(editor)
|
||||
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
})
|
||||
}
|
||||
if (editorMountedRef.current) {
|
||||
requestAnimationFrame(doFocus)
|
||||
|
||||
@@ -238,4 +238,54 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRenameSection', () => {
|
||||
it('writes sidebar label frontmatter and updates entry in memory', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', 'Recipes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
|
||||
})
|
||||
|
||||
it('trims whitespace before saving', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', ' Dishes ')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' })
|
||||
})
|
||||
|
||||
it('deletes sidebar label when label is empty', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', '')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('NonExistent', 'Label')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,5 +68,17 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
|
||||
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
const trimmed = label.trim()
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
if (trimmed) {
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
|
||||
} else {
|
||||
await handleDeleteProperty(typeEntry.path, 'sidebar label')
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
|
||||
}
|
||||
|
||||
@@ -241,8 +241,10 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
|
||||
}
|
||||
|
||||
/** Dispatch focus-editor event with perf timing marker. */
|
||||
function signalFocusEditor(): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } }))
|
||||
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
|
||||
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
|
||||
}))
|
||||
}
|
||||
|
||||
interface PersistCallbacks {
|
||||
@@ -372,7 +374,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor()
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
|
||||
87
src/hooks/useRawMode.test.ts
Normal file
87
src/hooks/useRawMode.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useRawMode } from './useRawMode'
|
||||
|
||||
describe('useRawMode', () => {
|
||||
let onFlushPending: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
onFlushPending = vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
|
||||
function renderRawHook(activeTabPath: string | null = '/note.md') {
|
||||
return renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending }),
|
||||
{ initialProps: { path: activeTabPath } },
|
||||
)
|
||||
}
|
||||
|
||||
it('starts with raw mode off', () => {
|
||||
const { result } = renderRawHook()
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles raw mode on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes pending edits when activating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not flush pending edits when deactivating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
onFlushPending.mockClear()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles raw mode off when already on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('resets raw mode when activeTabPath changes', async () => {
|
||||
const { result, rerender } = renderRawHook('/note-a.md')
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
rerender({ path: '/note-b.md' })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('works without onFlushPending callback', async () => {
|
||||
const { result } = renderHook(() => useRawMode({ activeTabPath: '/note.md' }))
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate raw mode when activeTabPath is null', async () => {
|
||||
const { result } = renderRawHook(null)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
// Cannot activate raw mode without an active tab path
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
})
|
||||
29
src/hooks/useRawMode.ts
Normal file
29
src/hooks/useRawMode.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
|
||||
onFlushPending?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages raw editor mode state.
|
||||
* Raw mode is automatically inactive when the active tab changes,
|
||||
* because rawMode is derived from whether the stored path matches the current tab.
|
||||
*/
|
||||
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
|
||||
// Track which path has raw mode active — automatically deactivates on tab switch
|
||||
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
|
||||
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
if (rawMode) {
|
||||
setRawActivePath(null)
|
||||
} else {
|
||||
await onFlushPending?.()
|
||||
setRawActivePath(activeTabPath)
|
||||
}
|
||||
}, [rawMode, activeTabPath, onFlushPending])
|
||||
|
||||
return { rawMode, handleToggleRaw }
|
||||
}
|
||||
@@ -1,356 +1,348 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { ThemeFile, VaultSettings } from '../types'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const mockThemes: ThemeFile[] = [
|
||||
{
|
||||
id: 'default', name: 'Default', description: 'Clean default theme',
|
||||
colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' },
|
||||
typography: { 'font-family': 'Inter, sans-serif' },
|
||||
spacing: { 'sidebar-width': '240px' },
|
||||
},
|
||||
{
|
||||
id: 'dark', name: 'Dark', description: 'Dark theme',
|
||||
colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' },
|
||||
typography: { 'font-family': 'Inter, sans-serif' },
|
||||
spacing: {},
|
||||
},
|
||||
]
|
||||
const THEME_PATH_DEFAULT = '/vault/theme/default.md'
|
||||
const THEME_PATH_DARK = '/vault/theme/dark.md'
|
||||
|
||||
const mockSettings: VaultSettings = { theme: 'default' }
|
||||
const DEFAULT_THEME_CONTENT = `---
|
||||
Is A: Theme
|
||||
Description: Light theme
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
primary: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
text-primary: "#37352F"
|
||||
---
|
||||
|
||||
const mockInvokeFn = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'list_themes') return mockThemes
|
||||
if (cmd === 'get_vault_settings') return mockSettings
|
||||
# Default Theme
|
||||
`
|
||||
|
||||
const DARK_THEME_CONTENT = `---
|
||||
Is A: 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,
|
||||
owner: null,
|
||||
cadence: 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,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
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_theme') return 'new-theme-id'
|
||||
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
|
||||
return null
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
}))
|
||||
|
||||
// Must import after mocks
|
||||
const { useThemeManager, isColorDark } = await import('./useThemeManager')
|
||||
const { useThemeManager, extractCssVars } = await import('./useThemeManager')
|
||||
|
||||
describe('isColorDark', () => {
|
||||
it('identifies dark colors', () => {
|
||||
expect(isColorDark('#000000')).toBe(true)
|
||||
expect(isColorDark('#0F0F23')).toBe(true)
|
||||
expect(isColorDark('#1a1a2e')).toBe(true)
|
||||
expect(isColorDark('#0f0f1a')).toBe(true)
|
||||
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('identifies light colors', () => {
|
||||
expect(isColorDark('#FFFFFF')).toBe(false)
|
||||
expect(isColorDark('#F7F6F3')).toBe(false)
|
||||
expect(isColorDark('#E0E0E0')).toBe(false)
|
||||
it('excludes metadata keys', () => {
|
||||
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
|
||||
expect('--Is A' in vars).toBe(false)
|
||||
expect('--Description' in vars).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useThemeManager', () => {
|
||||
const entries = [defaultEntry, darkEntry]
|
||||
const allContent: Record<string, string> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'list_themes') return mockThemes
|
||||
if (cmd === 'get_vault_settings') return mockSettings
|
||||
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_theme') return 'new-theme-id'
|
||||
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
|
||||
return null
|
||||
})
|
||||
// Clear any theme CSS properties from previous tests
|
||||
const root = document.documentElement
|
||||
root.style.cssText = ''
|
||||
document.documentElement.style.cssText = ''
|
||||
})
|
||||
|
||||
it('loads themes and active theme on mount', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
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.activeThemeId).toBe('default')
|
||||
expect(result.current.activeTheme?.name).toBe('Default')
|
||||
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))
|
||||
// Give it time to settle — should remain empty
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager(null, entries, allContent)
|
||||
)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(result.current.themes).toHaveLength(0)
|
||||
expect(result.current.activeThemeId).toBeNull()
|
||||
expect(result.current.activeTheme).toBeNull()
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies CSS custom properties for active theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
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.activeTheme).not.toBeNull()
|
||||
expect(result.current.themes).toHaveLength(1)
|
||||
})
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--primary')).toBe('#6366F1')
|
||||
expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF')
|
||||
expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif')
|
||||
expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px')
|
||||
})
|
||||
|
||||
it('maps sidebar-background to --sidebar', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC')
|
||||
expect(result.current.themes[0].name).toBe('Default Theme')
|
||||
})
|
||||
|
||||
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
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(result.current.themes).toHaveLength(2)
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
await result.current.switchTheme(THEME_PATH_DARK)
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vaultPath: '/vault', themeId: 'dark' })
|
||||
expect(result.current.activeThemeId).toBe('dark')
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears old theme CSS and applies new theme on switch', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme?.id).toBe('default')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
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 () => {
|
||||
await result.current.switchTheme('dark')
|
||||
newPath = await result.current.createTheme('My Theme')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--background')).toBe('#0F0F23')
|
||||
expect(newPath).toBe('/vault/theme/untitled.md')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
|
||||
vaultPath: '/vault', name: 'My Theme',
|
||||
})
|
||||
expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0')
|
||||
expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md')
|
||||
})
|
||||
|
||||
it('createTheme calls create_theme and reloads themes', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
let newId = ''
|
||||
await act(async () => {
|
||||
newId = await result.current.createTheme('default')
|
||||
})
|
||||
|
||||
expect(newId).toBe('new-theme-id')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: 'default' })
|
||||
// Should reload after creation
|
||||
const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes')
|
||||
expect(listCalls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('createTheme passes null source_id when no sourceId provided', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
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_theme', { vaultPath: '/vault', sourceId: null })
|
||||
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'))
|
||||
await waitFor(() => {
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error))
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(result.current.themes).toHaveLength(0)
|
||||
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'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
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('dark')
|
||||
await result.current.switchTheme(THEME_PATH_DARK)
|
||||
})
|
||||
|
||||
// Should not have changed the active theme
|
||||
expect(result.current.activeThemeId).toBe('default')
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error))
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles createTheme failure gracefully', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('write error'))
|
||||
|
||||
let newId = ''
|
||||
await act(async () => {
|
||||
newId = await result.current.createTheme('default')
|
||||
})
|
||||
|
||||
expect(newId).toBe('')
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error))
|
||||
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))
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager(null, entries, allContent)
|
||||
)
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
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))
|
||||
let newId = ''
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager(null, entries, allContent)
|
||||
)
|
||||
let newPath = ''
|
||||
await act(async () => {
|
||||
newId = await result.current.createTheme()
|
||||
newPath = await result.current.createTheme()
|
||||
})
|
||||
expect(newId).toBe('')
|
||||
expect(newPath).toBe('')
|
||||
})
|
||||
|
||||
it('isDark is false for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme?.id).toBe('default')
|
||||
})
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('isDark is true for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
expect(result.current.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('isDark is false when no active theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager(null))
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('derives app-specific CSS variables from theme colors', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
it('re-applies theme when active content changes in allContent', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useThemeManager('/vault', entries, content),
|
||||
{ initialProps: { content: allContent } },
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
|
||||
const newContent = {
|
||||
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
|
||||
}
|
||||
rerender({ content: newContent })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets color-scheme and data-theme-mode for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
it('reloadThemes re-reads vault settings', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
|
||||
})
|
||||
expect(root.dataset.themeMode).toBe('dark')
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
|
||||
})
|
||||
|
||||
it('sets color-scheme to light for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
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('updates derived variables when switching between themes', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('default')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
})
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
|
||||
expect(root.dataset.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('reloadThemes re-fetches theme list', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
|
||||
const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reloadThemes()
|
||||
})
|
||||
|
||||
const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
|
||||
expect(afterListCalls).toBe(initialListCalls + 1)
|
||||
const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
|
||||
expect(afterCalls).toBe(initialCalls + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,157 +1,76 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { ThemeFile, VaultSettings } from '../types'
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Color utilities for theme variable derivation ---
|
||||
/** 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',
|
||||
])
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '')
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function toHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
|
||||
function mixColors(hex1: string, hex2: string, ratio: number): string {
|
||||
const [r1, g1, b1] = parseHex(hex1)
|
||||
const [r2, g2, b2] = parseHex(hex2)
|
||||
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
|
||||
}
|
||||
|
||||
/** Check if a hex color is perceptually dark (luminance < 0.5). */
|
||||
export function isColorDark(hex: string): boolean {
|
||||
const [r, g, b] = parseHex(hex)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
|
||||
}
|
||||
|
||||
// Variables derived from theme core colors (not present in theme.colors directly)
|
||||
const DERIVED_VAR_NAMES = [
|
||||
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
|
||||
'bg-input', 'bg-button', 'bg-dialog',
|
||||
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
|
||||
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
|
||||
'link-color', 'link-hover',
|
||||
// shadcn variables that may not be in the theme
|
||||
'card', 'card-foreground', 'popover', 'popover-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'input', 'ring',
|
||||
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
|
||||
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
|
||||
]
|
||||
|
||||
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
|
||||
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
|
||||
const bg = colors.background
|
||||
const fg = colors.foreground
|
||||
if (!bg || !fg) return
|
||||
|
||||
const isDark = isColorDark(bg)
|
||||
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
|
||||
root.dataset.themeMode = isDark ? 'dark' : 'light'
|
||||
|
||||
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
|
||||
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
|
||||
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
|
||||
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
|
||||
|
||||
// App-specific variables
|
||||
root.style.setProperty('--bg-primary', bg)
|
||||
root.style.setProperty('--bg-sidebar', sidebarBg)
|
||||
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
|
||||
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-hover-subtle', muted)
|
||||
root.style.setProperty('--bg-selected', `${primary}25`)
|
||||
root.style.setProperty('--bg-input', bg)
|
||||
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
|
||||
|
||||
root.style.setProperty('--text-primary', fg)
|
||||
root.style.setProperty('--text-heading', fg)
|
||||
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
|
||||
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
|
||||
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
|
||||
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
|
||||
|
||||
root.style.setProperty('--border-primary', border)
|
||||
root.style.setProperty('--border-subtle', border)
|
||||
root.style.setProperty('--border-input', border)
|
||||
root.style.setProperty('--border-dialog', border)
|
||||
|
||||
root.style.setProperty('--link-color', primary)
|
||||
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
|
||||
|
||||
// Shadcn variables — only set if not already provided by the theme
|
||||
const setIfMissing = (name: string, value: string) => {
|
||||
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
setIfMissing('card', mixColors(bg, fg, 0.03))
|
||||
setIfMissing('card-foreground', fg)
|
||||
setIfMissing('popover', mixColors(bg, fg, 0.04))
|
||||
setIfMissing('popover-foreground', fg)
|
||||
setIfMissing('secondary', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('secondary-foreground', fg)
|
||||
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
|
||||
setIfMissing('accent', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('accent-foreground', fg)
|
||||
setIfMissing('input', border)
|
||||
setIfMissing('ring', primary)
|
||||
setIfMissing('sidebar-foreground', fg)
|
||||
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
|
||||
setIfMissing('sidebar-accent-foreground', fg)
|
||||
setIfMissing('sidebar-border', border)
|
||||
setIfMissing('sidebar-primary', primary)
|
||||
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
|
||||
setIfMissing('sidebar-ring', primary)
|
||||
return vars
|
||||
}
|
||||
|
||||
function clearDerivedVariables(root: HTMLElement): void {
|
||||
for (const name of DERIVED_VAR_NAMES) {
|
||||
root.style.removeProperty(`--${name}`)
|
||||
}
|
||||
root.style.removeProperty('color-scheme')
|
||||
delete root.dataset.themeMode
|
||||
}
|
||||
|
||||
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
|
||||
function applyThemeToDom(theme: ThemeFile): void {
|
||||
function applyVarsToDom(vars: Record<string, string>): void {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(theme.colors)) {
|
||||
root.style.setProperty(`--theme-${key}`, value)
|
||||
root.style.setProperty(`--${key}`, value)
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
for (const [key, value] of Object.entries(theme.typography)) {
|
||||
root.style.setProperty(`--theme-${key}`, value)
|
||||
}
|
||||
for (const [key, value] of Object.entries(theme.spacing)) {
|
||||
root.style.setProperty(`--theme-${key}`, value)
|
||||
}
|
||||
if (theme.colors['sidebar-background']) {
|
||||
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
|
||||
}
|
||||
deriveThemeVariables(root, theme.colors)
|
||||
}
|
||||
|
||||
function clearThemeFromDom(theme: ThemeFile): void {
|
||||
function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
const root = document.documentElement
|
||||
for (const key of Object.keys(theme.colors)) {
|
||||
root.style.removeProperty(`--theme-${key}`)
|
||||
root.style.removeProperty(`--${key}`)
|
||||
for (const key of Object.keys(vars)) {
|
||||
root.style.removeProperty(key)
|
||||
}
|
||||
for (const key of Object.keys(theme.typography)) {
|
||||
root.style.removeProperty(`--theme-${key}`)
|
||||
}
|
||||
|
||||
/** Build a ThemeFile descriptor from a vault entry (metadata only). */
|
||||
function entryToThemeFile(entry: VaultEntry): ThemeFile {
|
||||
return {
|
||||
id: entry.path,
|
||||
name: entry.title,
|
||||
description: '',
|
||||
path: entry.path,
|
||||
colors: {},
|
||||
typography: {},
|
||||
spacing: {},
|
||||
}
|
||||
for (const key of Object.keys(theme.spacing)) {
|
||||
root.style.removeProperty(`--theme-${key}`)
|
||||
}
|
||||
root.style.removeProperty('--sidebar')
|
||||
clearDerivedVariables(root)
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -160,81 +79,125 @@ export interface ThemeManager {
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme: (themeId: string) => Promise<void>
|
||||
createTheme: (sourceId?: string) => Promise<string>
|
||||
createTheme: (name?: string) => Promise<string>
|
||||
reloadThemes: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Sync CSS custom properties: clear old theme, apply new one. */
|
||||
function syncThemeDom(
|
||||
prevRef: React.MutableRefObject<ThemeFile | null>,
|
||||
theme: ThemeFile | null,
|
||||
): void {
|
||||
if (prevRef.current) clearThemeFromDom(prevRef.current)
|
||||
if (theme) {
|
||||
applyThemeToDom(theme)
|
||||
prevRef.current = theme
|
||||
} else {
|
||||
prevRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
export function useThemeManager(vaultPath: string | null): ThemeManager {
|
||||
const [themes, setThemes] = useState<ThemeFile[]>([])
|
||||
/** Manages loading and persisting the active theme path from vault settings. */
|
||||
function useThemeSetting(vaultPath: string | null) {
|
||||
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
|
||||
const prevThemeRef = useRef<ThemeFile | null>(null)
|
||||
|
||||
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
|
||||
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
|
||||
|
||||
const loadThemes = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!vaultPath) return
|
||||
try {
|
||||
const [themeList, settings] = await Promise.all([
|
||||
tauriCall<ThemeFile[]>('list_themes', { vaultPath }),
|
||||
tauriCall<VaultSettings>('get_vault_settings', { vaultPath }),
|
||||
])
|
||||
setThemes(themeList)
|
||||
setActiveThemeId(settings.theme)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load themes:', err)
|
||||
}
|
||||
const s = await tauriCall<VaultSettings>('get_vault_settings', { vaultPath })
|
||||
setActiveThemeId(s.theme)
|
||||
} catch { /* no settings file — fine, no active theme */ }
|
||||
}, [vaultPath])
|
||||
|
||||
useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
||||
useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme])
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Reload themes when window regains focus (live reload for external edits)
|
||||
useEffect(() => {
|
||||
const onFocus = () => { loadThemes() }
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [loadThemes])
|
||||
window.addEventListener('focus', load)
|
||||
return () => window.removeEventListener('focus', load)
|
||||
}, [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 apply = useCallback((content: string) => {
|
||||
const newVars = extractCssVars(content)
|
||||
clearVarsFromDom(appliedVarsRef.current)
|
||||
applyVarsToDom(newVars)
|
||||
appliedVarsRef.current = newVars
|
||||
}, [])
|
||||
|
||||
const clear = 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(() => {
|
||||
if (!activeThemeId) { clear(); return }
|
||||
if (cachedContent) { apply(cachedContent); return }
|
||||
tauriCall<string>('get_note_content', { path: activeThemeId })
|
||||
.then(apply)
|
||||
.catch(clear)
|
||||
}, [activeThemeId, cachedContent, apply, clear])
|
||||
|
||||
return { clear }
|
||||
}
|
||||
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): ThemeManager {
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
|
||||
const { clear: clearTheme } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
|
||||
const themes = useMemo(
|
||||
() => entries.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived).map(entryToThemeFile),
|
||||
[entries],
|
||||
)
|
||||
|
||||
const activeTheme = useMemo(
|
||||
() => themes.find(t => t.id === activeThemeId) ?? null,
|
||||
[themes, activeThemeId],
|
||||
)
|
||||
|
||||
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
|
||||
useEffect(() => {
|
||||
if (!activeThemeId) return
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
if (!entry || !isEntryRemoved(entry)) return
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
const switchTheme = useCallback(async (themeId: string) => {
|
||||
if (!vaultPath) return
|
||||
try {
|
||||
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
|
||||
setActiveThemeId(themeId)
|
||||
} catch (err) {
|
||||
console.error('Failed to switch theme:', err)
|
||||
}
|
||||
}, [vaultPath])
|
||||
} catch (err) { console.error('Failed to switch theme:', err) }
|
||||
}, [vaultPath, setActiveThemeId])
|
||||
|
||||
const createTheme = useCallback(async (sourceId?: string) => {
|
||||
const createTheme = useCallback(async (name?: string) => {
|
||||
if (!vaultPath) return ''
|
||||
try {
|
||||
const newId = await tauriCall<string>('create_theme', {
|
||||
vaultPath,
|
||||
sourceId: sourceId ?? null,
|
||||
})
|
||||
await loadThemes()
|
||||
await switchTheme(newId)
|
||||
return newId
|
||||
} catch (err) {
|
||||
console.error('Failed to create theme:', err)
|
||||
return ''
|
||||
}
|
||||
}, [vaultPath, loadThemes, switchTheme])
|
||||
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
|
||||
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
|
||||
setActiveThemeId(path)
|
||||
return path
|
||||
} catch (err) { console.error('Failed to create theme:', err); return '' }
|
||||
}, [vaultPath, setActiveThemeId])
|
||||
|
||||
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
|
||||
const reloadThemes = useCallback(async () => { await reload() }, [reload])
|
||||
|
||||
// Determine if the active theme is dark by checking --background CSS variable
|
||||
const isDark = useMemo(() => {
|
||||
if (!activeThemeId || !cachedThemeContent) return false
|
||||
const vars = extractCssVars(cachedThemeContent)
|
||||
const bg = vars['--background'] ?? ''
|
||||
if (!bg.startsWith('#') || bg.length < 7) return false
|
||||
const r = parseInt(bg.slice(1, 3), 16)
|
||||
const g = parseInt(bg.slice(3, 5), 16)
|
||||
const b = parseInt(bg.slice(5, 7), 16)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
|
||||
}, [activeThemeId, cachedThemeContent])
|
||||
|
||||
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
|
||||
}
|
||||
|
||||
@@ -732,5 +732,68 @@ 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': `---
|
||||
Is A: Theme
|
||||
title: Default
|
||||
primary: "#155DFF"
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
---
|
||||
|
||||
# Default
|
||||
|
||||
Light theme with warm, paper-like tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/dark.md': `---
|
||||
Is A: Theme
|
||||
title: Dark
|
||||
primary: "#155DFF"
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
sidebar: "#1a1a2e"
|
||||
border: "#2a2a4a"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#6b6b8a"
|
||||
accent: "#1a2a4a"
|
||||
accent-foreground: "#8ab4ff"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
---
|
||||
|
||||
# Dark
|
||||
|
||||
Dark variant with deep navy tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/minimal.md': `---
|
||||
Is A: Theme
|
||||
title: Minimal
|
||||
primary: "#000000"
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
sidebar: "#F5F5F5"
|
||||
border: "#E0E0E0"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#000000"
|
||||
font-family: "'SF Mono', 'Menlo', monospace"
|
||||
font-size-base: 13
|
||||
line-height-base: 1.5
|
||||
---
|
||||
|
||||
# Minimal
|
||||
|
||||
High contrast, minimal chrome.
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -1121,5 +1121,90 @@ 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,
|
||||
owner: null,
|
||||
cadence: 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,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/dark.md',
|
||||
filename: 'dark.md',
|
||||
title: 'Dark',
|
||||
isA: 'Theme',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: 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,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/minimal.md',
|
||||
filename: 'minimal.md',
|
||||
title: 'Minimal',
|
||||
isA: 'Theme',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: 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,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
)
|
||||
|
||||
// Append 9000 generated entries for realistic large-vault testing
|
||||
MOCK_ENTRIES.push(...generateBulkEntries(9000))
|
||||
|
||||
@@ -268,6 +268,32 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
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
|
||||
title: ${displayName}
|
||||
primary: "#155DFF"
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
`
|
||||
syncWindowContent()
|
||||
return path
|
||||
},
|
||||
}
|
||||
|
||||
export function addMockEntry(_entry: VaultEntry, content: string): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"editor": {
|
||||
"fontFamily": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontSize": 16,
|
||||
"fontSize": 15,
|
||||
"lineHeight": 1.5,
|
||||
"maxWidth": 720,
|
||||
"paddingHorizontal": 40,
|
||||
@@ -25,7 +25,7 @@
|
||||
"marginTop": 28,
|
||||
"marginBottom": 10,
|
||||
"color": "var(--text-heading)",
|
||||
"letterSpacing": 0
|
||||
"letterSpacing": -0.5
|
||||
},
|
||||
"h3": {
|
||||
"fontSize": 20,
|
||||
@@ -34,7 +34,7 @@
|
||||
"marginTop": 24,
|
||||
"marginBottom": 8,
|
||||
"color": "var(--text-heading)",
|
||||
"letterSpacing": 0
|
||||
"letterSpacing": -0.5
|
||||
},
|
||||
"h4": {
|
||||
"fontSize": 20,
|
||||
|
||||
@@ -120,9 +120,12 @@ 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>
|
||||
|
||||
21
src/utils/rawEditorUtils.ts
Normal file
21
src/utils/rawEditorUtils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Extract the wikilink query that the user is currently typing after [[ */
|
||||
export function extractWikilinkQuery(text: string, cursor: number): string | null {
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return null
|
||||
const afterTrigger = before.slice(triggerIdx + 2)
|
||||
// Don't trigger if the query contains ] (already closed) or a newline
|
||||
if (afterTrigger.includes(']') || afterTrigger.includes('\n')) return null
|
||||
return afterTrigger
|
||||
}
|
||||
|
||||
/** Basic YAML frontmatter structural checks. */
|
||||
export function detectYamlError(content: string): string | null {
|
||||
if (!content.startsWith('---')) return null
|
||||
const rest = content.slice(3)
|
||||
const closeIdx = rest.search(/\n---(\n|$)/)
|
||||
if (closeIdx === -1) return 'Unclosed frontmatter block — add a closing --- line'
|
||||
const block = rest.slice(0, closeIdx)
|
||||
if (/^\t/m.test(block)) return 'YAML frontmatter contains tab indentation — use spaces'
|
||||
return null
|
||||
}
|
||||
@@ -395,7 +395,7 @@ export default defineConfig({
|
||||
|
||||
// Tauri expects a fixed port
|
||||
server: {
|
||||
port: 5201,
|
||||
port: 5202,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user