diff --git a/desktop/package.json b/desktop/package.json index a1fd2e919d..ad60020246 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -57,6 +57,10 @@ "@tiptap/core": "^3.22.3", "@tiptap/extension-link": "^3.22.3", "@tiptap/extension-placeholder": "^3.22.3", + "@tiptap/extension-table": "^3.22.3", + "@tiptap/extension-table-cell": "^3.22.3", + "@tiptap/extension-table-header": "^3.22.3", + "@tiptap/extension-table-row": "^3.22.3", "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..0d852e51a5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", + "**/documents-vault-empty.spec.ts", + "**/documents-editing-autosave.spec.ts", + "**/documents-wikilinks-outline.spec.ts", "**/channels.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/channel-composer-overflow.spec.ts", @@ -46,6 +49,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/documents-screenshots.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fbaa547a03..620eb113f3 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1100,6 +1100,7 @@ dependencies = [ "mesh-llm-system", "neteq", "nostr", + "notify", "notify-rust", "objc2", "objc2-app-kit", @@ -3003,6 +3004,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -4240,6 +4250,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -4719,6 +4749,26 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "lab" version = "0.11.0" @@ -6269,6 +6319,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -6283,6 +6351,15 @@ dependencies = [ "zbus 5.17.0", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "ntapi" version = "0.4.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index bbf245e29a..72c74f6b75 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -45,6 +45,9 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre # connection is dropped, which the plugin does immediately. Default features # keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). notify-rust = "4" +# Filesystem watching for the Documents vault (distinct from notify-rust, +# which is desktop notifications). +notify = "8" # Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned # to the exact version wry links so both resolve to one webkit2gtk-sys and we # don't get duplicate symbols; bump in lockstep with wry. diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..eb918418c7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -58,6 +58,11 @@ mod social; mod team_snapshot; mod teams; mod updater; +mod vault_fs_read; +mod vault_fs_write; +pub mod vault_path; +mod vault_scope; +mod vault_watch; mod window_chrome; mod window_vibrancy; mod workflows; @@ -110,6 +115,10 @@ pub use social::*; pub use team_snapshot::*; pub use teams::*; pub use updater::*; +pub use vault_fs_read::*; +pub use vault_fs_write::*; +pub use vault_scope::*; +pub use vault_watch::*; pub use window_chrome::*; pub use window_vibrancy::*; pub use workflows::*; diff --git a/desktop/src-tauri/src/commands/vault_fs_read.rs b/desktop/src-tauri/src/commands/vault_fs_read.rs new file mode 100644 index 0000000000..2cd8141784 --- /dev/null +++ b/desktop/src-tauri/src/commands/vault_fs_read.rs @@ -0,0 +1,362 @@ +//! Read-only vault filesystem commands. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use tauri::State; + +use crate::commands::vault_path::VaultState; + +/// How deep the tree will descend. Linked folders can point anywhere, so this is +/// a backstop against a pathologically deep target as well as against cycles. +const MAX_TREE_DEPTH: usize = 32; + +/// Largest note Documents will read into memory. +/// +/// Notes are prose: the biggest one in a real 471-note vault measured 110 KB, +/// so 2 MB is roughly 18x the observed worst case. The cap exists because every +/// read path loads the whole file into a `String` and ships it across IPC as +/// JSON, and the frontend then parses it through TipTap twice — once for the +/// round-trip guard, once for the editor. A single stray export, database dump +/// or log file that happens to end in `.md` would otherwise freeze the app for +/// as long as that takes, and `read_vault_files` does it for the whole vault in +/// one call. +/// +/// Refusing with a message the user can act on is strictly better than a beach +/// ball: the file is still theirs, and the error names the size and the limit. +const MAX_NOTE_BYTES: u64 = 2 * 1024 * 1024; + +/// Reads a note, refusing anything above [`MAX_NOTE_BYTES`]. +/// +/// The size is checked before the read rather than after, so an oversized file +/// is never held in memory even briefly. +fn read_note(path: &Path) -> Result { + let size = fs::metadata(path).map_err(|e| e.to_string())?.len(); + if size > MAX_NOTE_BYTES { + return Err(format!( + "That note is {:.1} MB. Documents opens notes up to {} MB — open it in another editor.", + size as f64 / (1024.0 * 1024.0), + MAX_NOTE_BYTES / (1024 * 1024), + )); + } + fs::read_to_string(path).map_err(|e| e.to_string()) +} + +#[derive(Debug, Serialize)] +pub struct VaultEntry { + name: String, + path: String, + is_directory: bool, + /// `None` for files. Kept minimal on purpose — no stats, no mtime — because + /// the whole tree crosses IPC in one payload. + children: Option>, +} + +#[derive(Debug, Serialize)] +pub struct VaultFileContent { + path: String, + /// `None` when the file could not be read; the batch skips rather than fails + /// so one unreadable note cannot break indexing for the whole vault. + content: Option, +} + +fn build_vault_tree(path: &Path) -> Vec { + let mut visited: Vec = Vec::new(); + build_vault_tree_inner(path, &mut visited, 0) +} + +/// `visited` holds the canonical form of every directory on the current branch. +/// Because directory links are followed, `Vault/loop -> Vault` would otherwise +/// recurse until the stack ran out; re-entering a directory we are already +/// inside is the definition of a cycle, so we stop there. +fn build_vault_tree_inner( + path: &Path, + visited: &mut Vec, + depth: usize, +) -> Vec { + let mut entries: Vec = Vec::new(); + + if depth >= MAX_TREE_DEPTH { + return entries; + } + + let canonical = path.canonicalize().ok(); + if let Some(canonical) = &canonical { + if visited.contains(canonical) { + return entries; + } + visited.push(canonical.clone()); + } + + if let Ok(read_dir) = fs::read_dir(path) { + let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect(); + items.sort_by(|a, b| { + let a_is_dir = a.path().is_dir(); + let b_is_dir = b.path().is_dir(); + match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + } + }); + + for item in items { + let item_path = item.path(); + let name = item.file_name().to_string_lossy().to_string(); + + // Skip hidden files and folders (.git, .obsidian, .trash, ...). + if name.starts_with('.') { + continue; + } + + let is_dir = item_path.is_dir(); + + // Markdown and directories only. Onyx also admitted images/PDFs for + // its embed viewers; v1 has no embeds, so a narrower tree is both + // faster and less surface. + if !is_dir && !is_markdown(&name) { + continue; + } + + let children = if is_dir { + Some(build_vault_tree_inner(&item_path, visited, depth + 1)) + } else { + None + }; + + entries.push(VaultEntry { + name, + path: item_path.to_string_lossy().to_string(), + is_directory: is_dir, + children, + }); + } + } + + if canonical.is_some() { + visited.pop(); + } + + entries +} + +fn is_markdown(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") +} + +/// The whole vault tree. Takes no path — the root comes from [`VaultState`]. +#[tauri::command] +pub async fn list_vault_files(state: State<'_, VaultState>) -> Result, String> { + let root = state.require_root()?; + tokio::task::spawn_blocking(move || { + if !root.exists() { + return Err("The vault folder no longer exists.".to_string()); + } + Ok(build_vault_tree(&root)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[tauri::command] +pub async fn read_vault_file(state: State<'_, VaultState>, path: String) -> Result { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || read_note(validated.as_path())) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Batch read, for building the note index and backlink corpus. +/// +/// Onyx fanned this out as one IPC call per note (16 at a time); on a 2000-note +/// vault that is 2000 round trips. One call returns the lot. +#[tauri::command] +pub async fn read_vault_files( + state: State<'_, VaultState>, + paths: Vec, +) -> Result, String> { + let validated: Vec<(String, PathBuf)> = paths + .into_iter() + .filter_map(|path| { + state + .validate(&path) + .ok() + .map(|v| (path, v.into_path_buf())) + }) + .collect(); + + tokio::task::spawn_blocking(move || { + validated + .into_iter() + .map(|(path, resolved)| VaultFileContent { + path, + // An oversized or unreadable note is skipped rather than + // failing the batch: it simply contributes no backlinks. + content: read_note(&resolved).ok(), + }) + .collect() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} + +#[tauri::command] +pub async fn vault_entry_exists( + state: State<'_, VaultState>, + path: String, +) -> Result { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || validated.as_path().exists()) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(tag: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("buzz-vault-tree-{}-{}", std::process::id(), tag)); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("Notes")).unwrap(); + fs::write(root.join("Notes/plain.md"), "# plain").unwrap(); + fs::write(root.join("top.md"), "# top").unwrap(); + root + } + + fn names(entries: &[VaultEntry]) -> Vec { + entries.iter().map(|e| e.name.clone()).collect() + } + + #[test] + fn lists_markdown_and_directories_with_directories_first() { + let root = fixture("basic"); + let tree = build_vault_tree(&root); + assert_eq!( + names(&tree), + vec!["Notes".to_string(), "top.md".to_string()] + ); + let notes = tree + .iter() + .find(|e| e.name == "Notes") + .and_then(|e| e.children.as_ref()) + .expect("Notes must carry children"); + assert_eq!(names(notes), vec!["plain.md".to_string()]); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn skips_hidden_entries_and_non_markdown_files() { + let root = fixture("filtered"); + fs::create_dir_all(root.join(".obsidian")).unwrap(); + fs::write(root.join(".obsidian/config.json"), "{}").unwrap(); + fs::write(root.join(".hidden.md"), "x").unwrap(); + fs::write(root.join("image.png"), "x").unwrap(); + + let tree = build_vault_tree(&root); + let listed = names(&tree); + assert!(!listed.iter().any(|n| n.starts_with('.'))); + assert!(!listed.contains(&"image.png".to_string())); + assert!(listed.contains(&"top.md".to_string())); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn accepts_the_markdown_long_extension() { + let root = fixture("longext"); + fs::write(root.join("legacy.markdown"), "# legacy").unwrap(); + let tree = build_vault_tree(&root); + assert!(names(&tree).contains(&"legacy.markdown".to_string())); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn terminates_on_a_link_cycle() { + let root = fixture("cycle"); + std::os::unix::fs::symlink(&root, root.join("Notes/loop")).unwrap(); + // Would recurse forever without the visited set. + let tree = build_vault_tree(&root); + assert!(!tree.is_empty()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn terminates_on_an_ancestor_link() { + // `parent-loop -> ..` points at the vault itself; `is_dir()` follows it. + let root = fixture("ancestor"); + std::os::unix::fs::symlink("..", root.join("Notes/parent-loop")).unwrap(); + std::os::unix::fs::symlink("loop-b", root.join("Notes/loop-a")).unwrap(); + std::os::unix::fs::symlink("loop-a", root.join("Notes/loop-b")).unwrap(); + + let tree = build_vault_tree(&root); + assert!(!tree.is_empty(), "the walk must still return real entries"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn reads_an_ordinary_note() { + let root = fixture("readok"); + assert_eq!(read_note(&root.join("top.md")).unwrap(), "# top"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn refuses_a_note_larger_than_the_cap() { + let root = fixture("toobig"); + let big = root.join("huge.md"); + // One byte over is enough; writing 2 MB keeps the test quick. + fs::write(&big, vec![b'x'; (MAX_NOTE_BYTES + 1) as usize]).unwrap(); + + let error = read_note(&big).expect_err("an oversized note must be refused"); + assert!( + error.contains("MB"), + "the message must tell the user the size and the limit: {error}" + ); + + // And the boundary itself is allowed, so the check is not off by one. + fs::write(&big, vec![b'x'; MAX_NOTE_BYTES as usize]).unwrap(); + assert!( + read_note(&big).is_ok(), + "exactly at the cap must still open" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn stops_at_max_depth() { + let root = fixture("deep"); + let mut deep = root.clone(); + for i in 0..(MAX_TREE_DEPTH + 5) { + deep = deep.join(format!("d{i}")); + } + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("buried.md"), "# buried").unwrap(); + + // The walk must terminate and stay bounded rather than recursing all the + // way down; depth is counted from the vault root. + let mut node = build_vault_tree(&root); + let mut levels = 0; + while let Some(child) = node + .into_iter() + .find(|e| e.is_directory) + .and_then(|e| e.children) + { + levels += 1; + node = child; + if levels > MAX_TREE_DEPTH + 2 { + break; + } + } + assert!( + levels <= MAX_TREE_DEPTH, + "descended {levels} levels, past the {MAX_TREE_DEPTH} cap" + ); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/desktop/src-tauri/src/commands/vault_fs_write.rs b/desktop/src-tauri/src/commands/vault_fs_write.rs new file mode 100644 index 0000000000..f3915d2682 --- /dev/null +++ b/desktop/src-tauri/src/commands/vault_fs_write.rs @@ -0,0 +1,290 @@ +//! Mutating vault filesystem commands. +//! +//! Every path here is validated against the active vault before it is touched, +//! and the returned [`ValidatedVaultPath`] is the only thing the `fs::` calls +//! see — see `vault_path.rs` for why that distinction is load-bearing. + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::time::UNIX_EPOCH; + +use serde::Serialize; +use tauri::State; + +use crate::commands::vault_path::{reject_move_into_self, ValidatedVaultPath, VaultState}; + +#[derive(Serialize)] +pub struct VaultWriteResult { + /// Modification time of the file we just wrote, in milliseconds since the + /// epoch. + /// + /// The frontend records this so the filesystem watcher can tell our own + /// write apart from a genuine external edit. Without it, saving fires a + /// change event that looks exactly like someone else editing the file, and + /// the reconciler would discard keystrokes typed during the poll window. + modified_ms: u64, +} + +/// Milliseconds since the epoch for `path`'s mtime, or 0 when unavailable. +/// +/// A missing mtime is not worth failing a successful write over; it only costs +/// the echo-suppression optimisation for that one save. +fn modified_ms(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|delta| delta.as_millis() as u64) + .unwrap_or(0) +} + +/// Writes `content`, replacing the file atomically. +/// +/// Atomic because this runs on autosave over the user's real notes: a partial +/// write from a crash or a full disk would otherwise truncate a file the user +/// still has open. `atomic-write-file` writes to a sibling temp file and +/// renames over the target. +#[tauri::command] +pub async fn write_vault_file( + state: State<'_, VaultState>, + path: String, + content: String, +) -> Result { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || { + use atomic_write_file::AtomicWriteFile; + + let target = validated.as_path(); + let mut file = AtomicWriteFile::open(target) + .map_err(|e| format!("Could not open the note for writing: {e}"))?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("Could not write the note: {e}"))?; + file.commit() + .map_err(|e| format!("Could not save the note: {e}"))?; + + Ok(VaultWriteResult { + modified_ms: modified_ms(target), + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Creates an empty note. Fails if something already exists at `path`. +#[tauri::command] +pub async fn create_vault_file(state: State<'_, VaultState>, path: String) -> Result<(), String> { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || { + let target = validated.as_path(); + if target.exists() { + return Err("A file with that name already exists.".to_string()); + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Could not create the containing folder: {e}"))?; + } + fs::write(target, "").map_err(|e| format!("Could not create the note: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[tauri::command] +pub async fn create_vault_folder(state: State<'_, VaultState>, path: String) -> Result<(), String> { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || { + let target = validated.as_path(); + if target.exists() { + return Err("A folder with that name already exists.".to_string()); + } + fs::create_dir_all(target).map_err(|e| format!("Could not create the folder: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Renames or moves an entry. Both endpoints must be inside the vault. +#[tauri::command] +pub async fn rename_vault_entry( + state: State<'_, VaultState>, + old_path: String, + new_path: String, +) -> Result<(), String> { + let source = state.validate(&old_path)?; + let destination = state.validate(&new_path)?; + reject_move_into_self(&source, &destination)?; + + tokio::task::spawn_blocking(move || rename_blocking(&source, &destination)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn rename_blocking( + source: &ValidatedVaultPath, + destination: &ValidatedVaultPath, +) -> Result<(), String> { + let from = source.as_path(); + let to = destination.as_path(); + + if !from.exists() { + return Err("That file no longer exists.".to_string()); + } + if to.exists() { + return Err("Something with that name already exists.".to_string()); + } + if let Some(parent) = to.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Could not create the destination folder: {e}"))?; + } + fs::rename(from, to).map_err(|e| format!("Could not move that item: {e}")) +} + +/// Deletes a note, or a folder and everything under it. +#[tauri::command] +pub async fn delete_vault_entry(state: State<'_, VaultState>, path: String) -> Result<(), String> { + let validated = state.validate(&path)?; + tokio::task::spawn_blocking(move || { + let target = validated.as_path(); + if target.is_dir() { + fs::remove_dir_all(target).map_err(|e| format!("Could not delete the folder: {e}")) + } else { + fs::remove_file(target).map_err(|e| format!("Could not delete the note: {e}")) + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture(tag: &str) -> (PathBuf, VaultState) { + let root = + std::env::temp_dir().join(format!("buzz-vault-write-{}-{}", std::process::id(), tag)); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("Notes")).unwrap(); + fs::write(root.join("Notes/plain.md"), "# plain").unwrap(); + + let state = VaultState::default(); + state.set(root.clone()).unwrap(); + (root, state) + } + + fn validated(state: &VaultState, path: &Path) -> ValidatedVaultPath { + state.validate(&path.to_string_lossy()).unwrap() + } + + #[test] + fn rename_moves_a_note_into_another_folder() { + let (root, state) = fixture("move"); + fs::create_dir_all(root.join("Archive")).unwrap(); + + let source = validated(&state, &root.join("Notes/plain.md")); + let destination = validated(&state, &root.join("Archive/plain.md")); + rename_blocking(&source, &destination).unwrap(); + + assert!(!root.join("Notes/plain.md").exists()); + assert_eq!( + fs::read_to_string(root.join("Archive/plain.md")).unwrap(), + "# plain" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_refuses_to_clobber_an_existing_entry() { + let (root, state) = fixture("clobber"); + fs::write(root.join("Notes/other.md"), "# other").unwrap(); + + let source = validated(&state, &root.join("Notes/plain.md")); + let destination = validated(&state, &root.join("Notes/other.md")); + assert!(rename_blocking(&source, &destination).is_err()); + + // The would-be victim is untouched. + assert_eq!( + fs::read_to_string(root.join("Notes/other.md")).unwrap(), + "# other" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_reports_a_missing_source() { + let (root, state) = fixture("missing"); + let source = validated(&state, &root.join("Notes/gone.md")); + let destination = validated(&state, &root.join("Notes/new.md")); + assert!(rename_blocking(&source, &destination).is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_rejects_moving_a_folder_into_itself() { + let (root, state) = fixture("intoself"); + let source = validated(&state, &root.join("Notes")); + let nested = validated(&state, &root.join("Notes/Inner")); + let sibling = validated(&state, &root.join("Elsewhere")); + + assert!(reject_move_into_self(&source, &nested).is_err()); + assert!(reject_move_into_self(&source, &sibling).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn modified_ms_reflects_a_written_file() { + let (root, _state) = fixture("mtime"); + let stamp = modified_ms(&root.join("Notes/plain.md")); + assert!(stamp > 0, "a freshly written file must report an mtime"); + + // A path that does not exist degrades to 0 rather than failing. + assert_eq!(modified_ms(&root.join("Notes/absent.md")), 0); + let _ = fs::remove_dir_all(&root); + } + + /// Deleting a linked folder must unlink it, never empty out its target. + /// + /// Containment here is lexical, so a folder the user symlinked into their + /// vault is reachable on purpose — which means "delete folder" can be aimed + /// at a link pointing anywhere on disk. `delete_vault_entry` branches on + /// `is_dir()`, which *follows* the link, so it calls `remove_dir_all` on a + /// symlink. That is only safe because `remove_dir_all` refuses to descend + /// through one (the fix for CVE-2022-21658); if that ever stopped holding, + /// or the branch were rewritten to canonicalize first, deleting a linked + /// folder would silently destroy the real directory behind it. + #[cfg(unix)] + #[test] + fn deleting_a_linked_folder_removes_the_link_not_its_target() { + let (root, state) = fixture("unlink"); + let outside = root.parent().unwrap().join(format!( + "buzz-vault-write-outside-{}-unlink", + std::process::id() + )); + let _ = fs::remove_dir_all(&outside); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("precious.md"), "IRREPLACEABLE").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("Linked")).unwrap(); + + let target = validated(&state, &root.join("Linked")); + let path = target.as_path(); + assert!( + path.is_dir(), + "is_dir() follows the link, as the command does" + ); + assert!(fs::remove_dir_all(path).is_ok()); + + assert!( + root.join("Linked").symlink_metadata().is_err(), + "the link itself must be gone" + ); + assert_eq!( + fs::read_to_string(outside.join("precious.md")).unwrap(), + "IRREPLACEABLE", + "the link's target must be untouched" + ); + + let _ = fs::remove_dir_all(&outside); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/desktop/src-tauri/src/commands/vault_path.rs b/desktop/src-tauri/src/commands/vault_path.rs new file mode 100644 index 0000000000..3adc56f491 --- /dev/null +++ b/desktop/src-tauri/src/commands/vault_path.rs @@ -0,0 +1,399 @@ +//! Vault path validation — the security core of the Documents feature. +//! +//! Ported from the Onyx editor (`onyx/src-tauri/src/lib.rs`), with two +//! deliberate hardenings noted on [`VaultState`] and [`ValidatedVaultPath`]. + +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; + +/// A path that has passed [`validate_vault_path`]. +/// +/// Onyx enforced "use the returned `PathBuf`, never the string you passed in" +/// with a doc comment. Wrapping it in a newtype that the `fs::` helpers are the +/// only consumers of makes the same rule a compile error instead of a review +/// note — see the module docs on why that rule is load-bearing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedVaultPath(PathBuf); + +impl ValidatedVaultPath { + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +/// The active vault root, held in Rust rather than passed from the renderer. +/// +/// Onyx's commands took `(path, vault_path)` — letting a caller supply *both* +/// sides of the containment check, so `read_file("/etc/passwd", "/etc")` passed. +/// Five of its commands (`list_files`, `file_exists`, `get_file_modified_time`, +/// `get_file_stats`, `list_assets`) skipped validation entirely. +/// +/// A content security policy does not help here: CSP constrains what the page +/// may load and execute, not what the app's own code may ask the backend to do. +/// Every containment decision has to be made on this side of the IPC boundary, +/// which is why the renderer never gets to name the root. +#[derive(Default)] +pub struct VaultState { + inner: Mutex>, +} + +#[derive(Clone)] +struct VaultRoots { + /// The spelling the user chose, which may itself be a symlink. + literal: PathBuf, + /// Its canonical spelling, when that differs. + canonical: Option, +} + +impl VaultState { + /// Records `root` as the active vault. Returns the stored literal path. + pub fn set(&self, root: PathBuf) -> Result { + let canonical = root.canonicalize().ok().filter(|c| c != &root); + let roots = VaultRoots { + literal: root.clone(), + canonical, + }; + let mut guard = self.inner.lock().map_err(|e| e.to_string())?; + *guard = Some(roots); + Ok(root) + } + + pub fn clear(&self) -> Result<(), String> { + let mut guard = self.inner.lock().map_err(|e| e.to_string())?; + *guard = None; + Ok(()) + } + + /// The active vault root, or `None` when no vault has been chosen. + pub fn root(&self) -> Result, String> { + let guard = self.inner.lock().map_err(|e| e.to_string())?; + Ok(guard.as_ref().map(|roots| roots.literal.clone())) + } + + /// The active vault root, erroring when none is set. + pub fn require_root(&self) -> Result { + self.root()? + .ok_or_else(|| "No vault folder is selected.".to_string()) + } + + /// Validates `path` against the active vault. + /// + /// This is the only way to obtain a [`ValidatedVaultPath`]. + pub fn validate(&self, path: &str) -> Result { + let roots = { + let guard = self.inner.lock().map_err(|e| e.to_string())?; + guard + .clone() + .ok_or_else(|| "No vault folder is selected.".to_string())? + }; + validate_vault_path(path, &roots.literal, roots.canonical.as_deref()) + } +} + +/// Resolves `.` and `..` textually, without touching the filesystem. +fn lexically_normalize(path: &Path) -> Result { + let mut resolved = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + // `pop` returns false once we are back at the prefix/root. + if !resolved.pop() { + return Err(format!( + "Invalid path: '{}' climbs above the filesystem root", + path.display() + )); + } + } + other => resolved.push(other.as_os_str()), + } + } + Ok(resolved) +} + +/// Validates that a path is within the allowed vault directory. +/// +/// Containment is decided **lexically**, not by `canonicalize()`. Canonicalizing +/// first resolves symlinks, so a directory the user deliberately linked into +/// their vault (`Vault/Projects -> ~/Projects/onyx`) resolves to a path outside +/// the vault and every file under it gets rejected — while still being listed in +/// the tree, because the tree builder never canonicalizes. Linked folders are an +/// intentional grant by the person who created the link, so we follow them; `..` +/// escapes are still rejected, which is what the traversal guard is for. +/// +/// **Callers must use the returned path and never the string they passed in.** +/// This is the whole of the rule's safety, not hygiene. `..` after a symlink hop +/// normalizes differently from how the OS would resolve it — e.g. +/// `/links/dir-outside/../../outside/notes/x.md` is allowed and names +/// `/outside/notes/x.md`, while the OS would have resolved it against the +/// link's target and reached somewhere else entirely. A caller that validates +/// `&p` and then touches `&p` is checking one path and operating on another. +/// [`ValidatedVaultPath`] is what stops a new call site from doing that. +fn validate_vault_path( + path: &str, + vault: &Path, + canonical_vault: Option<&Path>, +) -> Result { + let path = Path::new(path); + + if path.is_relative() { + return Err(format!( + "Invalid path: '{}' must be absolute", + path.display() + )); + } + + let normalized_path = lexically_normalize(path)?; + let normalized_vault = lexically_normalize(vault)?; + + // The vault itself may live behind a symlink, in which case callers can hold + // either spelling of it. Accept both, and only the vault's own two forms — + // this is a prefix test on the vault, never on the requested path. + let mut allowed_roots = vec![normalized_vault]; + if let Some(canonical) = canonical_vault { + let normalized_canonical = lexically_normalize(canonical)?; + if !allowed_roots.contains(&normalized_canonical) { + allowed_roots.push(normalized_canonical); + } + } + + if !allowed_roots + .iter() + .any(|root| normalized_path.starts_with(root)) + { + return Err(format!( + "Access denied: path '{}' is outside the vault directory", + path.display() + )); + } + + Ok(ValidatedVaultPath(normalized_path)) +} + +/// Rejects moving a directory into its own subtree (`mv A A/B`). +/// +/// `fs::rename` happens to return EINVAL for this on Linux, but the check is +/// cheap, portable, and produces a message a user can act on. Onyx's +/// `rename_file` has no equivalent guard. +pub fn reject_move_into_self( + source: &ValidatedVaultPath, + destination: &ValidatedVaultPath, +) -> Result<(), String> { + if destination.as_path().starts_with(source.as_path()) { + return Err("Cannot move a folder into itself.".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// Builds a throwaway vault: + /// /vault/Notes/plain.md + /// /vault/Projects -> /real/onyx (the shape that was broken) + /// /real/onyx/README.md + fn fixture(tag: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("buzz-vault-test-{}-{}", std::process::id(), tag)); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("vault/Notes")).unwrap(); + fs::create_dir_all(root.join("real/onyx")).unwrap(); + fs::write(root.join("vault/Notes/plain.md"), "# plain").unwrap(); + fs::write(root.join("real/onyx/README.md"), "# linked").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(root.join("real/onyx"), root.join("vault/Projects")).unwrap(); + root + } + + /// Validates against the fixture vault, mirroring how `VaultState` calls it. + fn check(path: &str, root: &Path) -> Result { + let vault = root.join("vault"); + let canonical = vault.canonicalize().ok().filter(|c| c != &vault); + validate_vault_path(path, &vault, canonical.as_deref()) + } + + #[test] + fn accepts_an_ordinary_file_in_the_vault() { + let root = fixture("ordinary"); + let target = root.join("vault/Notes/plain.md"); + assert!(check(&target.to_string_lossy(), &root).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn accepts_a_file_inside_a_linked_folder() { + // This is the regression: canonicalize() resolved the path to + // /real/onyx/README.md and the starts_with check rejected it. + let root = fixture("linked"); + let target = root.join("vault/Projects/README.md"); + let resolved = check(&target.to_string_lossy(), &root) + .expect("a file inside a linked folder must be readable"); + assert_eq!( + resolved.as_path(), + target, + "the link path is preserved, not resolved" + ); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn accepts_the_link_itself() { + let root = fixture("linkdir"); + let target = root.join("vault/Projects"); + assert!(check(&target.to_string_lossy(), &root).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn accepts_a_file_that_does_not_exist_yet() { + // New-note creation inside a linked folder. + let root = fixture("newfile"); + let target = root.join("vault/Projects/brand-new.md"); + assert!(check(&target.to_string_lossy(), &root).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn still_rejects_dot_dot_traversal() { + let root = fixture("traversal"); + let vault = root.join("vault"); + let target = format!("{}/Notes/../../real/onyx/README.md", vault.display()); + assert!( + check(&target, &root).is_err(), + "`..` out of the vault must stay denied" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn still_rejects_an_unrelated_absolute_path() { + let root = fixture("outside"); + assert!(check("/etc/passwd", &root).is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_a_relative_path() { + let root = fixture("relative"); + assert!(check("Notes/plain.md", &root).is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_a_sibling_that_merely_shares_a_name_prefix() { + // "/vault-evil" must not pass a naive starts_with on "/vault". + let root = fixture("prefix"); + fs::create_dir_all(root.join("vault-evil")).unwrap(); + fs::write(root.join("vault-evil/secret.md"), "x").unwrap(); + let target = root.join("vault-evil/secret.md"); + assert!( + check(&target.to_string_lossy(), &root).is_err(), + "Path::starts_with is component-wise, so this must be denied" + ); + let _ = fs::remove_dir_all(&root); + } + + /// Enough `..` after a symlink hop to leave the vault. Lexical normalization + /// pops the link component rather than climbing out of the link's *target*, + /// so the two orderings disagree — but both land outside the vault here, and + /// the lexical one is denied. Deny is the safe direction. + #[cfg(unix)] + #[test] + fn dot_dot_after_a_link_hop_out_of_the_vault_is_denied() { + let root = fixture("linkhop-out"); + let vault = root.join("vault"); + fs::write(root.join("real/secret.md"), "secret").unwrap(); + let attack = format!("{}/Projects/../../real/secret.md", vault.display()); + assert!( + check(&attack, &root).is_err(), + "`..` climbing past the vault root must be denied even after a link hop" + ); + let _ = fs::remove_dir_all(&root); + } + + /// The other half: a single `..` after a link hop stays inside the vault + /// lexically, while the OS would have resolved it against the link's target + /// and landed elsewhere. That divergence is only safe because callers operate + /// on the returned path, never the raw string — this test pins the returned + /// path, which is what makes the rule sound. + #[cfg(unix)] + #[test] + fn dot_dot_after_a_link_hop_pops_the_link_not_its_target() { + let root = fixture("linkhop-pop"); + let vault = root.join("vault"); + let input = format!("{}/Projects/../Notes/plain.md", vault.display()); + + let resolved = check(&input, &root).expect("lexically this lands back inside the vault"); + assert_eq!( + resolved.as_path(), + vault.join("Notes/plain.md"), + "the returned path must pop the link component, not follow it" + ); + assert!( + resolved.as_path().exists(), + "and it must name the real in-vault file" + ); + // The OS ordering would have reached /real/Notes/plain.md. + assert!(!root.join("real/Notes/plain.md").exists()); + let _ = fs::remove_dir_all(&root); + } + + /// The returned path is the security boundary: every command operates on it, + /// so it must never point outside the vault even when the raw input would. + #[test] + fn returned_path_is_always_inside_the_vault() { + let root = fixture("returned"); + let vault = root.join("vault"); + let mut inputs = vec![format!("{}/Notes/./plain.md", vault.display())]; + #[cfg(unix)] + { + inputs.push(format!("{}/Projects/README.md", vault.display())); + inputs.push(format!("{}/Projects/../Notes/plain.md", vault.display())); + } + for input in inputs { + let resolved = check(&input, &root).expect(&input); + assert!( + resolved.as_path().starts_with(&vault), + "{} resolved outside the vault: {:?}", + input, + resolved + ); + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_every_path_when_no_vault_is_set() { + let state = VaultState::default(); + assert!(state.root().unwrap().is_none()); + assert!(state.require_root().is_err()); + assert!(state.validate("/tmp/anything.md").is_err()); + } + + #[test] + fn validate_goes_through_the_active_vault_state() { + let root = fixture("state"); + let state = VaultState::default(); + state.set(root.join("vault")).unwrap(); + + let target = root.join("vault/Notes/plain.md"); + assert!(state.validate(&target.to_string_lossy()).is_ok()); + assert!(state.validate("/etc/passwd").is_err()); + + state.clear().unwrap(); + assert!( + state.validate(&target.to_string_lossy()).is_err(), + "clearing the vault must revoke access" + ); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/desktop/src-tauri/src/commands/vault_scope.rs b/desktop/src-tauri/src/commands/vault_scope.rs new file mode 100644 index 0000000000..0554aab8dd --- /dev/null +++ b/desktop/src-tauri/src/commands/vault_scope.rs @@ -0,0 +1,176 @@ +//! Choosing and activating the Documents vault folder. +//! +//! These are the only commands that accept a path the renderer chose. Every +//! other vault command reads the root from [`VaultState`] — see its docs. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; + +use crate::commands::vault_path::VaultState; + +#[derive(Serialize)] +pub struct VaultInfo { + /// Absolute path of the active vault, in the spelling the user chose. + path: String, + /// Basename, for display in the Documents header. + name: String, +} + +/// Show a folder picker and return the chosen path, or `None` when cancelled. +/// +/// Selection only — the caller must still `set_active_vault` to grant access. +/// Mirrors the `pick_save_path` oneshot-channel bridge in `export_util.rs`. +#[tauri::command] +pub async fn pick_vault_folder(app: AppHandle) -> Result, String> { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_title("Choose a vault folder") + .pick_folder(move |path| { + let _ = tx.send(path); + }); + + let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; + let Some(folder) = selected else { + return Ok(None); + }; + + let path = folder + .as_path() + .ok_or_else(|| "Folder dialog returned an invalid path".to_string())?; + Ok(Some(path.to_string_lossy().to_string())) +} + +/// Sanity-checks a candidate vault root without mutating anything. +/// +/// Keeps Onyx's `set_vault_scope` guards (exists, is a directory, is not the +/// filesystem root, is not `$HOME` itself) and drops its `tauri-plugin-fs` +/// scope widening — Buzz has no such plugin and needs none, because every read +/// and write goes through a Rust command rather than the plugin's JS API. +fn validate_vault_root(candidate: &Path) -> Result { + if !candidate.is_absolute() { + return Err("The vault folder must be an absolute path.".to_string()); + } + if !candidate.exists() { + return Err("That folder does not exist.".to_string()); + } + if !candidate.is_dir() { + return Err("The vault must be a folder, not a file.".to_string()); + } + if candidate.parent().is_none() { + return Err("The filesystem root cannot be used as a vault.".to_string()); + } + if let Some(home) = dirs::home_dir() { + // Comparing canonical spellings so `/home/x` and a symlinked `~` agree. + let same_as_home = match (candidate.canonicalize(), home.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => candidate == home, + }; + if same_as_home { + return Err( + "Your home folder is too broad to use as a vault. Choose a subfolder.".to_string(), + ); + } + } + Ok(candidate.to_path_buf()) +} + +/// Activate `vault_path` as the vault every other vault command operates within. +#[tauri::command] +pub async fn set_active_vault( + state: State<'_, VaultState>, + vault_path: String, +) -> Result { + let candidate = PathBuf::from(vault_path.trim()); + let root = tokio::task::spawn_blocking(move || validate_vault_root(&candidate)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let stored = state.set(root)?; + Ok(vault_info(&stored)) +} + +/// Forget the active vault. Every subsequent vault command fails until one is set. +#[tauri::command] +pub async fn clear_active_vault(state: State<'_, VaultState>) -> Result<(), String> { + state.clear() +} + +/// The active vault, or `None` when none is selected. Used on boot to reconcile +/// the frontend's stored path against the backend. +#[tauri::command] +pub async fn get_active_vault(state: State<'_, VaultState>) -> Result, String> { + Ok(state.root()?.as_deref().map(vault_info)) +} + +fn vault_info(root: &Path) -> VaultInfo { + let name = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| root.to_string_lossy().to_string()); + VaultInfo { + path: root.to_string_lossy().to_string(), + name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(tag: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("buzz-vault-scope-{}-{}", std::process::id(), tag)); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn accepts_an_ordinary_folder() { + let root = temp_dir("ok"); + assert!(validate_vault_root(&root).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_a_missing_folder() { + let root = temp_dir("missing"); + assert!(validate_vault_root(&root.join("nope")).is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_a_file() { + let root = temp_dir("file"); + let file = root.join("note.md"); + fs::write(&file, "x").unwrap(); + assert!(validate_vault_root(&file).is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rejects_a_relative_path() { + assert!(validate_vault_root(Path::new("relative/vault")).is_err()); + } + + #[test] + fn rejects_the_filesystem_root() { + assert!(validate_vault_root(Path::new("/")).is_err()); + } + + #[test] + fn rejects_the_home_directory_itself() { + let Some(home) = dirs::home_dir() else { + return; + }; + assert!( + validate_vault_root(&home).is_err(), + "$HOME is too broad to grant wholesale" + ); + } +} diff --git a/desktop/src-tauri/src/commands/vault_watch.rs b/desktop/src-tauri/src/commands/vault_watch.rs new file mode 100644 index 0000000000..b34bc91486 --- /dev/null +++ b/desktop/src-tauri/src/commands/vault_watch.rs @@ -0,0 +1,189 @@ +//! Filesystem watching for the Documents vault. +//! +//! Two deliberate differences from the Onyx watcher this is based on: +//! +//! * The `vault-file-modified` payload carries each path's mtime, not just the +//! path. Our own `write_vault_file` trips the watcher, and without an mtime +//! the frontend cannot distinguish that echo from a genuine external edit — +//! it would cancel the pending autosave and discard whatever the user typed +//! during the poll window. +//! +//! * Dotted paths are filtered before emitting. Onyx skips hidden entries when +//! building its tree but watches them anyway, so a vault containing `.git` +//! or `.obsidian` thrashes the event stream during ordinary editor activity. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, UNIX_EPOCH}; + +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; + +use crate::commands::vault_path::VaultState; + +/// Emitted when a watched file's contents changed. +pub const VAULT_FILE_MODIFIED_EVENT: &str = "vault-file-modified"; +/// Emitted when the set of files changed (create / delete / rename). +pub const VAULT_FILES_CHANGED_EVENT: &str = "vault-files-changed"; + +#[derive(Clone, Serialize)] +pub struct VaultModifiedEntry { + path: String, + /// Milliseconds since the epoch, or 0 when unavailable. + modified_ms: u64, +} + +#[derive(Default)] +pub struct VaultWatcherState { + inner: Mutex>, +} + +impl VaultWatcherState { + fn replace(&self, watcher: Option) -> Result<(), String> { + let mut guard = self.inner.lock().map_err(|e| e.to_string())?; + // Dropping the previous watcher unregisters it. + *guard = watcher; + Ok(()) + } +} + +/// Whether any component of `path` starts with a dot. +/// +/// Matches the tree walker's hidden-entry rule, so the watcher never reports +/// churn the user cannot see. +fn is_hidden(path: &Path) -> bool { + path.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| name.starts_with('.')) + }) +} + +fn modified_ms(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|delta| delta.as_millis() as u64) + .unwrap_or(0) +} + +/// Splits an event's paths into the ones worth telling the frontend about. +fn visible_paths(event: &Event, vault_root: &Path) -> Vec { + event + .paths + .iter() + .filter(|path| { + // Compare relative to the vault so a dotted directory *above* the + // vault (a vault inside ~/.config, say) does not filter everything. + let relative = path.strip_prefix(vault_root).unwrap_or(path); + !is_hidden(relative) + }) + .cloned() + .collect() +} + +fn handle_event(app: &AppHandle, vault_root: &Path, event: Event) { + let paths = visible_paths(&event, vault_root); + if paths.is_empty() { + return; + } + + match event.kind { + EventKind::Modify(_) => { + let entries: Vec = paths + .iter() + .map(|path| VaultModifiedEntry { + modified_ms: modified_ms(path), + path: path.to_string_lossy().to_string(), + }) + .collect(); + let _ = app.emit(VAULT_FILE_MODIFIED_EVENT, entries); + } + EventKind::Create(_) | EventKind::Remove(_) => { + let _ = app.emit(VAULT_FILES_CHANGED_EVENT, ()); + } + _ => {} + } +} + +/// Starts watching the active vault. Replaces any existing watcher. +#[tauri::command] +pub async fn start_vault_watch( + app: AppHandle, + state: State<'_, VaultState>, + watcher_state: State<'_, VaultWatcherState>, +) -> Result<(), String> { + let root = state.require_root()?; + let event_root = root.clone(); + + let mut watcher = RecommendedWatcher::new( + move |result: notify::Result| { + if let Ok(event) = result { + handle_event(&app, &event_root, event); + } + }, + Config::default().with_poll_interval(Duration::from_secs(1)), + ) + .map_err(|e| format!("Could not watch the vault folder: {e}"))?; + + watcher + .watch(&root, RecursiveMode::Recursive) + .map_err(|e| format!("Could not watch the vault folder: {e}"))?; + + watcher_state.replace(Some(watcher)) +} + +#[tauri::command] +pub async fn stop_vault_watch(watcher_state: State<'_, VaultWatcherState>) -> Result<(), String> { + watcher_state.replace(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hidden_detection_covers_any_component() { + assert!(is_hidden(Path::new(".git"))); + assert!(is_hidden(Path::new(".git/objects/ab/cdef"))); + assert!(is_hidden(Path::new("Notes/.obsidian/workspace.json"))); + assert!(is_hidden(Path::new("Notes/.hidden.md"))); + + assert!(!is_hidden(Path::new("Notes/plain.md"))); + assert!(!is_hidden(Path::new("Notes/Sub Folder/note.md"))); + // A dot inside a name, rather than leading it, is ordinary. + assert!(!is_hidden(Path::new("Notes/v1.2.notes.md"))); + } + + #[test] + fn a_dotted_directory_above_the_vault_does_not_filter_everything() { + // A vault living under ~/.config must still report its own files. + let root = Path::new("/home/user/.config/vault"); + let event = Event { + attrs: Default::default(), + kind: EventKind::Modify(notify::event::ModifyKind::Any), + paths: vec![root.join("Notes/plain.md")], + }; + assert_eq!(visible_paths(&event, root).len(), 1); + } + + #[test] + fn hidden_paths_inside_the_vault_are_filtered_out() { + let root = Path::new("/vault"); + let event = Event { + attrs: Default::default(), + kind: EventKind::Modify(notify::event::ModifyKind::Any), + paths: vec![ + root.join(".git/index"), + root.join("Notes/plain.md"), + root.join(".obsidian/workspace.json"), + ], + }; + let visible = visible_paths(&event, root); + assert_eq!(visible, vec![root.join("Notes/plain.md")]); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d22b95224b..f680f93463 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -308,6 +308,8 @@ pub fn run() { .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) .manage(terminal_runtime::TerminalSessions::default()) + .manage(commands::vault_path::VaultState::default()) + .manage(commands::VaultWatcherState::default()) .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] @@ -578,29 +580,10 @@ pub fn run() { // Drain events the retention store flagged `pending_sync` (UI // create/edit, delete tombstones, launch reconcile) to the relay. - // One loop is the sole publisher for persona, team, and managed- - // agent writers; a relay-unreachable tick leaves rows pending for - // the next sweep. // Skipped in recovery mode — flushing under an ephemeral key would // publish events attributed to an identity the user doesn't own. if !recovery_mode { - let flush_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - use std::time::Duration; - use tauri::Manager; - loop { - let state = flush_handle.state::(); - if let Err(e) = managed_agents::persona_events::flush_active_pending_events( - &flush_handle, - &state, - ) - .await - { - eprintln!("buzz-desktop: event-flush: {e}"); - } - tokio::time::sleep(Duration::from_secs(30)).await; - } - }); + managed_agents::persona_events::spawn_flush_loop(app.handle().clone()); } Ok(()) }) @@ -645,6 +628,21 @@ pub fn run() { get_users_batch, get_user_notes, get_git_identity, + pick_vault_folder, + set_active_vault, + clear_active_vault, + get_active_vault, + list_vault_files, + read_vault_file, + read_vault_files, + vault_entry_exists, + write_vault_file, + create_vault_file, + create_vault_folder, + rename_vault_entry, + delete_vault_entry, + start_vault_watch, + stop_vault_watch, get_project_repo_snapshot, get_project_repo_diff, get_project_local_repo_diff, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0..ac4806d589 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -541,6 +541,29 @@ pub fn preview_prospective_persona_snapshot( } preview } +/// How long to wait between flush sweeps. +const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Spawns the background loop that drains `pending_sync` rows to the relay. +/// +/// One loop is the sole publisher for persona, team, and managed-agent writers; +/// a relay-unreachable tick simply leaves rows pending for the next sweep. +/// +/// Callers must skip this in recovery mode — flushing under an ephemeral key +/// would publish events attributed to an identity the user does not own. +pub fn spawn_flush_loop(handle: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + use tauri::Manager; + loop { + let state = handle.state::(); + if let Err(e) = flush_active_pending_events(&handle, &state).await { + eprintln!("buzz-desktop: event-flush: {e}"); + } + tokio::time::sleep(FLUSH_INTERVAL).await; + } + }); +} + #[cfg(test)] mod stale_pin_tests; #[cfg(test)] diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e8..c6cc7299e6 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "documents"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -181,6 +182,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/documents" || pathname.startsWith("/documents/")) { + return { + selectedChannelId: null, + selectedView: "documents", + }; + } + if (pathname === "/pulse") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3..fc43d2a320 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,7 +96,7 @@ import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; -import { LazySettingsScreen } from "@/app/LazySettingsScreen"; +import { AppShellSettingsPane } from "@/app/AppShellSettingsPane"; const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { useWebviewZoomShortcuts(); @@ -138,6 +138,7 @@ export function AppShell() { goChannel, goHome, goNewMessage, + goDocuments, goProjects, goPulse, goSettings, @@ -800,41 +801,14 @@ export function AppShell() { /> ) : null} {settingsOpen ? ( -
- - - -
+ ) : (
{!isHuddleRoom ? ( @@ -897,6 +871,7 @@ export function AppShell() { searchChannels={channels} searchFocusRequest={searchFocusRequest} onSelectHome={() => void goHome()} + onSelectDocuments={() => void goDocuments()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} diff --git a/desktop/src/app/AppShellSettingsPane.tsx b/desktop/src/app/AppShellSettingsPane.tsx new file mode 100644 index 0000000000..33c98713d0 --- /dev/null +++ b/desktop/src/app/AppShellSettingsPane.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; + +import { LazySettingsScreen } from "@/app/LazySettingsScreen"; +import type { useNotificationSettings } from "@/features/notifications/hooks"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; + +type AppShellSettingsPaneProps = { + currentPubkey?: string; + fallbackDisplayName?: string; + /** + * The whole `useNotificationSettings` result. `SettingsScreen` takes its ten + * fields as ten separate props; spreading them here keeps that fan-out out of + * `AppShell`, which is up against the 1000-line file-size gate. + */ + notificationSettings: ReturnType; + onClose: () => void; + onSectionChange: (section: SettingsSection) => void; + section: SettingsSection; +}; + +/** The settings screen branch of the app shell, including its Suspense gate. */ +export function AppShellSettingsPane({ + currentPubkey, + fallbackDisplayName, + notificationSettings, + onClose, + onSectionChange, + section, +}: AppShellSettingsPaneProps) { + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a306..bc98ac051d 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -91,6 +91,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goDocuments = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/documents", + }, + behavior, + ), + [commitNavigation], + ); + const goProjects = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -316,6 +327,7 @@ export function useAppNavigation() { closeWorkflowDetail, goAgents, goChannel, + goDocuments, goForumPost, goHome, goNewMessage, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..859bbcc7c6 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as documentsRouteImport } from "./routes/documents"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const documentsRoute = documentsRouteImport.update({ + id: "/documents", + path: "/documents", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/documents": typeof documentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/documents": typeof documentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/documents": typeof documentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/documents" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/documents" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/documents" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + documentsRoute: typeof documentsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/documents": { + id: "/documents"; + path: "/documents"; + fullPath: "/documents"; + preLoaderRoute: typeof documentsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + documentsRoute: documentsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..e21128b1f2 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -8,6 +8,7 @@ export const routes = rootRoute("root.tsx", [ route("/settings", "settings.tsx"), route("/workflows", "workflows.tsx"), route("/workflows/$workflowId", "workflows.$workflowId.tsx"), + route("/documents", "documents.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), route("/messages/new", "messages.new.tsx"), diff --git a/desktop/src/app/routes/documents.tsx b/desktop/src/app/routes/documents.tsx new file mode 100644 index 0000000000..9ff6ea8096 --- /dev/null +++ b/desktop/src/app/routes/documents.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const DocumentsScreen = React.lazy(async () => { + const module = await import("@/features/documents/ui/DocumentsScreen"); + return { default: module.DocumentsScreen }; +}); + +export const Route = createFileRoute("/documents")({ + component: DocumentsRouteComponent, +}); + +function DocumentsRouteComponent() { + usePreviewFeatureWarning("documents"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 9ced506751..9683a38093 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -8,11 +8,13 @@ import { Hash, House, Lock, + NotebookText, Zap, } from "lucide-react"; import type * as React from "react"; import { toast } from "sonner"; +import type { AppView } from "@/app/AppShell.helpers"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import { cn } from "@/shared/lib/cn"; @@ -20,6 +22,12 @@ import { channelChrome } from "@/shared/layout/chromeLayout"; import { Button } from "@/shared/ui/button"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; +/** + * Views that render a chat-style header. Every `AppView` except `messages`, + * which is a route prefix rather than a header mode. + */ +type ChatHeaderMode = Exclude; + type ChatHeaderProps = { actions?: React.ReactNode; belowSystemChrome?: boolean; @@ -30,7 +38,7 @@ type ChatHeaderProps = { channelType?: ChannelType; visibility?: ChannelVisibility; leadingContent?: React.ReactNode; - mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; + mode?: ChatHeaderMode; overlaysContent?: boolean; statusBadge?: React.ReactNode; /** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */ @@ -47,7 +55,7 @@ function ChannelIcon({ }: { channelType?: ChannelType; visibility?: ChannelVisibility; - mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; + mode?: ChatHeaderMode; }) { if (mode === "home") { return ; @@ -69,6 +77,10 @@ function ChannelIcon({ return ; } + if (mode === "documents") { + return ; + } + if (channelType === "dm") { return ; } @@ -143,17 +155,21 @@ export function ChatHeader({ > {title} - + {/* Copying the title is a channel affordance — a document's title + is its filename, and "Copy channel name" is simply wrong there. */} + {mode === "documents" ? null : ( + + )} {statusBadge ? (
{statusBadge} diff --git a/desktop/src/features/documents/hooks.ts b/desktop/src/features/documents/hooks.ts new file mode 100644 index 0000000000..35cdce9691 --- /dev/null +++ b/desktop/src/features/documents/hooks.ts @@ -0,0 +1,115 @@ +/** + * Data layer for the Documents vault. + * + * Every query key is scoped by `vaultPath`, so switching vaults (or clearing + * one) naturally invalidates without any manual cache reset. That is also why + * none of this needs wiring into `resetCommunityState()` — the vault is global + * and per-machine, not community-scoped. + */ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { + listVaultFiles, + readVaultFile, + readVaultFiles, +} from "@/shared/api/vault"; +import type { VaultEntry } from "@/shared/api/vaultTypes"; +import { collectFilePaths } from "@/features/documents/lib/treeModel"; + +export function vaultTreeQueryKey(vaultPath: string | null) { + return ["documents", "tree", vaultPath] as const; +} + +export function vaultContentsQueryKey(vaultPath: string | null) { + return ["documents", "contents", vaultPath] as const; +} + +export function vaultFileQueryKey(vaultPath: string | null, path: string) { + return ["documents", "file", vaultPath, path] as const; +} + +/** + * The whole vault tree. + * + * `staleTime: Infinity` because the filesystem watcher is the invalidation + * signal — refetching on focus would just duplicate work the watcher already + * does, and on a large vault the walk is not free. + */ +export function useVaultTreeQuery(vaultPath: string | null) { + return useQuery({ + enabled: Boolean(vaultPath), + queryFn: () => listVaultFiles(), + queryKey: vaultTreeQueryKey(vaultPath), + staleTime: Number.POSITIVE_INFINITY, + }); +} + +/** A single note's contents. */ +export function useVaultFileQuery( + vaultPath: string | null, + path: string | null, +) { + return useQuery({ + enabled: Boolean(vaultPath && path), + // `path` is non-null whenever the query is enabled. + queryFn: () => readVaultFile(path as string), + queryKey: vaultFileQueryKey(vaultPath, path ?? ""), + staleTime: Number.POSITIVE_INFINITY, + }); +} + +/** + * Every note's raw text, keyed by path — the corpus the note index and + * backlinks are built from. + * + * One batched IPC call rather than Onyx's per-file fan-out. + */ +export function useVaultContentsQuery( + vaultPath: string | null, + tree: VaultEntry[] | undefined, +) { + const paths = React.useMemo( + () => (tree ? collectFilePaths(tree) : []), + [tree], + ); + + return useQuery({ + enabled: Boolean(vaultPath) && paths.length > 0, + queryFn: async () => { + const results = await readVaultFiles(paths); + const contents = new Map(); + for (const result of results) { + if (result.content !== null) contents.set(result.path, result.content); + } + return contents; + }, + queryKey: [...vaultContentsQueryKey(vaultPath), paths.length] as const, + staleTime: Number.POSITIVE_INFINITY, + }); +} + +/** Invalidators for the filesystem watcher and vault mutations to call. */ +export function useVaultInvalidation(vaultPath: string | null) { + const queryClient = useQueryClient(); + + const invalidateTree = React.useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: vaultTreeQueryKey(vaultPath), + }); + void queryClient.invalidateQueries({ + queryKey: vaultContentsQueryKey(vaultPath), + }); + }, [queryClient, vaultPath]); + + const invalidateFile = React.useCallback( + (path: string) => { + void queryClient.invalidateQueries({ + queryKey: vaultFileQueryKey(vaultPath, path), + }); + }, + [queryClient, vaultPath], + ); + + return { invalidateFile, invalidateTree }; +} diff --git a/desktop/src/features/documents/lib/backlinks.test.mjs b/desktop/src/features/documents/lib/backlinks.test.mjs new file mode 100644 index 0000000000..670e7989e7 --- /dev/null +++ b/desktop/src/features/documents/lib/backlinks.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getBacklinks, groupMentionsBySource } from "./backlinks.ts"; +import { buildNoteIndex } from "./noteIndex.ts"; + +const VAULT = "/vault"; +const TARGET = `${VAULT}/Meeting Notes.md`; + +function fixture(entries) { + const contents = new Map(entries); + const index = buildNoteIndex(VAULT, [TARGET, ...contents.keys()]); + return { contents, index }; +} + +test("finds a linked mention and reports its line", () => { + const { contents, index } = fixture([ + [`${VAULT}/Daily.md`, "Morning.\n\nSee [[Meeting Notes]] for details.\n"], + ]); + const { linked, unlinked } = getBacklinks({ + contents, + index, + targetPath: TARGET, + }); + + assert.equal(linked.length, 1); + assert.equal(linked[0].sourcePath, `${VAULT}/Daily.md`); + assert.equal(linked[0].sourceName, "Daily"); + assert.equal(linked[0].lineNumber, 3); + assert.match(linked[0].line, /See \[\[Meeting Notes\]\]/); + assert.equal(unlinked.length, 0); +}); + +test("matches loose name variants through the index", () => { + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "[[meeting-notes]]"], + [`${VAULT}/B.md`, "[[MEETING_NOTES]]"], + ]); + const { linked } = getBacklinks({ contents, index, targetPath: TARGET }); + assert.deepEqual(linked.map((m) => m.sourceName).sort(), ["A", "B"]); +}); + +test("finds unlinked mentions and keeps them separate", () => { + const { contents, index } = fixture([ + [`${VAULT}/Plain.md`, "I discussed Meeting Notes with the team.\n"], + ]); + const { linked, unlinked } = getBacklinks({ + contents, + index, + targetPath: TARGET, + }); + + assert.equal(linked.length, 0); + assert.equal(unlinked.length, 1); + assert.equal(unlinked[0].kind, "unlinked"); + assert.equal(unlinked[0].sourceName, "Plain"); +}); + +test("a linked line is not also reported as unlinked", () => { + // The name appears inside the wikilink; counting it twice would double every + // backlink in the panel. + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "See [[Meeting Notes]] today.\n"], + ]); + const { linked, unlinked } = getBacklinks({ + contents, + index, + targetPath: TARGET, + }); + assert.equal(linked.length, 1); + assert.equal(unlinked.length, 0); +}); + +test("unlinked matching respects word boundaries", () => { + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "Meeting Notesworthy things happened.\n"], + ]); + const { unlinked } = getBacklinks({ contents, index, targetPath: TARGET }); + assert.equal( + unlinked.length, + 0, + "a substring inside a longer word is not a mention", + ); +}); + +test("unlinked matching is case-insensitive", () => { + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "we reviewed meeting notes yesterday\n"], + ]); + const { unlinked } = getBacklinks({ contents, index, targetPath: TARGET }); + assert.equal(unlinked.length, 1); +}); + +test("a note is never its own backlink", () => { + const contents = new Map([ + [TARGET, "# Meeting Notes\n\nMeeting Notes again.\n"], + ]); + const index = buildNoteIndex(VAULT, [TARGET]); + const { linked, unlinked } = getBacklinks({ + contents, + index, + targetPath: TARGET, + }); + assert.equal(linked.length, 0); + assert.equal(unlinked.length, 0); +}); + +test("a link to a different note is not a backlink here", () => { + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "See [[Something Else]].\n"], + ]); + const { linked } = getBacklinks({ contents, index, targetPath: TARGET }); + assert.equal(linked.length, 0); +}); + +test("works without an index by comparing names", () => { + // Backlinks stay useful while the corpus is still loading. + const contents = new Map([[`${VAULT}/A.md`, "See [[Meeting Notes]].\n"]]); + const { linked } = getBacklinks({ + contents, + index: null, + targetPath: TARGET, + }); + assert.equal(linked.length, 1); +}); + +test("groups multiple mentions from one note together", () => { + const { contents, index } = fixture([ + [`${VAULT}/A.md`, "[[Meeting Notes]]\n\nand [[Meeting Notes]] again\n"], + [`${VAULT}/B.md`, "[[Meeting Notes]]\n"], + ]); + const { linked } = getBacklinks({ contents, index, targetPath: TARGET }); + const groups = groupMentionsBySource(linked); + + assert.equal(groups.length, 2); + const a = groups.find((g) => g.sourceName === "A"); + assert.equal(a.mentions.length, 2); +}); diff --git a/desktop/src/features/documents/lib/backlinks.ts b/desktop/src/features/documents/lib/backlinks.ts new file mode 100644 index 0000000000..3cafab3dd3 --- /dev/null +++ b/desktop/src/features/documents/lib/backlinks.ts @@ -0,0 +1,180 @@ +/** + * Backlinks: which notes point at this one, and which merely name it. + * + * Two kinds, following Obsidian: + * + * - **Linked mentions** — an actual `[[wikilink]]` that resolves here. + * - **Unlinked mentions** — the note's name appearing as plain text, which is + * usually a link the author forgot to make. + */ +import { + baseName, + stripMarkdownExtension, +} from "@/features/documents/lib/treeModel"; +import { + normalizeName, + resolveWikilink, + type NoteIndex, +} from "@/features/documents/lib/noteIndex"; +import { parseWikilinks } from "@/features/documents/lib/wikilinkSyntax"; + +export type MentionKind = "linked" | "unlinked"; + +export type Mention = { + /** Absolute path of the note containing the mention. */ + sourcePath: string; + /** Display name of that note. */ + sourceName: string; + kind: MentionKind; + /** Line the mention sits on, for preview. */ + line: string; + /** 1-based line number. */ + lineNumber: number; +}; + +export type Backlinks = { + linked: Mention[]; + unlinked: Mention[]; +}; + +/** Escapes a string for literal use inside a regex. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Whether `line` mentions `name` as plain text, outside any wikilink. + * + * Word boundaries prevent "Note" matching inside "Notebook" — the noise that + * makes an unlinked-mentions panel useless. + * + * `pattern` is supplied by the caller and reused across every line in the + * vault, so its `lastIndex` must be reset here rather than relying on a fresh + * object. `links` is likewise passed in: the caller has already parsed them to + * test for linked mentions, and parsing each line twice was most of the cost of + * a backlinks pass. + */ +function hasUnlinkedMention( + line: string, + pattern: RegExp, + links: readonly { index: number; raw: string }[], +): boolean { + pattern.lastIndex = 0; + let match: RegExpExecArray | null = pattern.exec(line); + while (match !== null) { + const start = match.index; + const insideLink = links.some( + (link) => start >= link.index && start < link.index + link.raw.length, + ); + if (!insideLink) return true; + match = pattern.exec(line); + } + return false; +} + +/** + * Collects mentions of `targetPath` across the vault. + * + * `contents` maps absolute path → raw note text. + */ +export function getBacklinks({ + contents, + index, + targetPath, +}: { + contents: ReadonlyMap; + index: NoteIndex | null; + targetPath: string; +}): Backlinks { + const linked: Mention[] = []; + const unlinked: Mention[] = []; + + const targetName = stripMarkdownExtension(baseName(targetPath)); + const normalizedTarget = normalizeName(targetName); + // Compiled once for the whole vault rather than once per line. At ~75k lines + // in a real vault, per-line compilation was the single largest cost here. + const namePattern = new RegExp(`\\b${escapeRegExp(targetName)}\\b`, "gi"); + const lowerTarget = targetName.toLowerCase(); + + for (const [sourcePath, text] of contents) { + // A note is not its own backlink. + if (sourcePath === targetPath) continue; + + // A note can only mention this one by linking to it or by naming it. Two + // native substring scans rule out most of the vault before it is split into + // lines at all, which is far cheaper than the per-line work below. + const hasAnyLink = text.includes("[["); + const lowerText = text.toLowerCase(); + if (!hasAnyLink && !lowerText.includes(lowerTarget)) continue; + + const sourceName = stripMarkdownExtension(baseName(sourcePath)); + const lines = text.split(/\r?\n/); + + for (const [offset, line] of lines.entries()) { + // Same reasoning as above, one level down. + const lineHasLink = line.includes("[["); + if (!lineHasLink && !line.toLowerCase().includes(lowerTarget)) continue; + + const lineNumber = offset + 1; + const links = lineHasLink ? parseWikilinks(line) : []; + + const resolvesHere = links.some((link) => { + if (!link.target) return false; + const resolved = resolveWikilink(link.target, sourcePath, index); + // Fall back to name comparison when there is no index, so backlinks + // still work before the corpus finishes loading. + return resolved + ? resolved.path === targetPath + : normalizeName(link.target) === normalizedTarget; + }); + + if (resolvesHere) { + linked.push({ + kind: "linked", + line, + lineNumber, + sourceName, + sourcePath, + }); + continue; + } + + if (hasUnlinkedMention(line, namePattern, links)) { + unlinked.push({ + kind: "unlinked", + line, + lineNumber, + sourceName, + sourcePath, + }); + } + } + } + + return { linked, unlinked }; +} + +/** Groups mentions by their source note, preserving first-seen order. */ +export function groupMentionsBySource( + mentions: readonly Mention[], +): Array<{ sourceName: string; sourcePath: string; mentions: Mention[] }> { + const groups = new Map< + string, + { sourceName: string; sourcePath: string; mentions: Mention[] } + >(); + + for (const mention of mentions) { + const group = groups.get(mention.sourcePath); + if (group) { + group.mentions.push(mention); + } else { + groups.set(mention.sourcePath, { + mentions: [mention], + sourceName: mention.sourceName, + sourcePath: mention.sourcePath, + }); + } + } + + return [...groups.values()]; +} diff --git a/desktop/src/features/documents/lib/documentSession.test.mjs b/desktop/src/features/documents/lib/documentSession.test.mjs new file mode 100644 index 0000000000..357937e865 --- /dev/null +++ b/desktop/src/features/documents/lib/documentSession.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { parseSessionSnapshot } from "./documentSession.ts"; + +const VAULT = "/vault"; + +function snapshot(overrides = {}) { + return JSON.stringify({ + activePath: `${VAULT}/a.md`, + expandedPaths: [`${VAULT}/Notes`], + openPaths: [`${VAULT}/a.md`, `${VAULT}/b.md`], + vaultPath: VAULT, + ...overrides, + }); +} + +test("parses a well-formed snapshot", () => { + assert.deepEqual(parseSessionSnapshot(snapshot(), VAULT), { + activePath: `${VAULT}/a.md`, + expandedPaths: [`${VAULT}/Notes`], + openPaths: [`${VAULT}/a.md`, `${VAULT}/b.md`], + vaultPath: VAULT, + }); +}); + +test("discards a snapshot from a different vault", () => { + // Its paths do not exist here, so filtering would leave a misleading + // half-session; dropping it is correct. + assert.equal(parseSessionSnapshot(snapshot(), "/other-vault"), null); +}); + +test("drops an active path that is not open", () => { + const parsed = parseSessionSnapshot( + snapshot({ activePath: `${VAULT}/not-open.md` }), + VAULT, + ); + assert.equal(parsed.activePath, null); + assert.deepEqual(parsed.openPaths, [`${VAULT}/a.md`, `${VAULT}/b.md`]); +}); + +test("returns null for missing or malformed data", () => { + assert.equal(parseSessionSnapshot(null, VAULT), null); + assert.equal(parseSessionSnapshot("", VAULT), null); + assert.equal(parseSessionSnapshot("not json", VAULT), null); + assert.equal(parseSessionSnapshot("[]", VAULT), null); + assert.equal(parseSessionSnapshot('{"vaultPath":123}', VAULT), null); +}); + +test("rejects non-string path arrays rather than trusting them", () => { + assert.equal( + parseSessionSnapshot(snapshot({ openPaths: [1, 2] }), VAULT), + null, + ); + assert.equal( + parseSessionSnapshot(snapshot({ expandedPaths: [{}] }), VAULT), + null, + ); + assert.equal( + parseSessionSnapshot(snapshot({ openPaths: "not-an-array" }), VAULT), + null, + ); +}); + +test("an empty session is valid", () => { + const parsed = parseSessionSnapshot( + snapshot({ activePath: null, expandedPaths: [], openPaths: [] }), + VAULT, + ); + assert.deepEqual(parsed.openPaths, []); + assert.equal(parsed.activePath, null); +}); diff --git a/desktop/src/features/documents/lib/documentSession.ts b/desktop/src/features/documents/lib/documentSession.ts new file mode 100644 index 0000000000..1dfd4c0887 --- /dev/null +++ b/desktop/src/features/documents/lib/documentSession.ts @@ -0,0 +1,85 @@ +/** + * Persisted Documents session: which notes were open, and which folders were + * expanded. + * + * Note *content* is deliberately not persisted. Restoring a stale buffer over a + * file that changed on disk would be a silent overwrite the moment autosave + * fired — the same class of bug the round-trip guard and the watcher + * reconciliation exist to prevent. Paths are re-read from disk on restore. + */ + +export const DOCUMENT_SESSION_KEY = "buzz.documents.session.v1"; + +export type DocumentSessionSnapshot = { + /** The vault this session belongs to. */ + vaultPath: string; + /** Absolute paths of open tabs, in order. */ + openPaths: string[]; + activePath: string | null; + expandedPaths: string[]; +}; + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +/** + * Parses a stored snapshot, rejecting anything that does not belong to + * `vaultPath`. + * + * Restoring another vault's paths would open tabs for files that do not exist + * here, so a mismatch discards rather than filters. + */ +export function parseSessionSnapshot( + raw: string | null, + vaultPath: string, +): DocumentSessionSnapshot | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.vaultPath !== "string") return null; + if (parsed.vaultPath !== vaultPath) return null; + if (!isStringArray(parsed.openPaths)) return null; + if (!isStringArray(parsed.expandedPaths)) return null; + + const activePath = + typeof parsed.activePath === "string" && + parsed.openPaths.includes(parsed.activePath) + ? parsed.activePath + : null; + + return { + activePath, + expandedPaths: parsed.expandedPaths, + openPaths: parsed.openPaths, + vaultPath, + }; + } catch { + return null; + } +} + +export function readSessionSnapshot( + vaultPath: string, +): DocumentSessionSnapshot | null { + try { + return parseSessionSnapshot( + window.localStorage.getItem(DOCUMENT_SESSION_KEY), + vaultPath, + ); + } catch { + return null; + } +} + +export function writeSessionSnapshot(snapshot: DocumentSessionSnapshot): void { + try { + window.localStorage.setItem(DOCUMENT_SESSION_KEY, JSON.stringify(snapshot)); + } catch { + // A full or unavailable store costs the user their tab layout, nothing + // more; never let it break editing. + } +} diff --git a/desktop/src/features/documents/lib/documentTabs.test.mjs b/desktop/src/features/documents/lib/documentTabs.test.mjs new file mode 100644 index 0000000000..4336d1de89 --- /dev/null +++ b/desktop/src/features/documents/lib/documentTabs.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + activateTab, + activeTab, + closeTab, + closeTabsUnder, + dirtyPaths, + emptyTabsState, + hasDirtyTabs, + markTabSaved, + openTab, + reloadTabFromDisk, + renameTabPath, + setTabContent, + setTabViewMode, + tabLabel, +} from "./documentTabs.ts"; + +function makeTab(path, overrides = {}) { + return { + content: "body", + diskContent: "body", + frontmatter: null, + isDirty: false, + name: tabLabel(path), + path, + roundTrip: "stable", + viewMode: "live", + ...overrides, + }; +} + +function withTabs(...paths) { + return paths.reduce( + (state, path) => openTab(state, makeTab(path)), + emptyTabsState, + ); +} + +test("tabLabel strips the directory and the markdown extension", () => { + assert.equal(tabLabel("/vault/Notes/Meeting notes.md"), "Meeting notes"); + assert.equal(tabLabel("/vault/legacy.markdown"), "legacy"); +}); + +test("opening tabs appends and focuses the new one", () => { + const state = withTabs("/v/a.md", "/v/b.md"); + assert.deepEqual( + state.tabs.map((t) => t.path), + ["/v/a.md", "/v/b.md"], + ); + assert.equal(state.activePath, "/v/b.md"); +}); + +test("re-opening an open file focuses it without clobbering its buffer", () => { + let state = withTabs("/v/a.md", "/v/b.md"); + state = setTabContent(state, "/v/a.md", "edited", "edited"); + state = openTab(state, makeTab("/v/a.md")); + + assert.equal(state.activePath, "/v/a.md"); + assert.equal(state.tabs.length, 2, "must not duplicate the tab"); + const reopened = state.tabs.find((t) => t.path === "/v/a.md"); + assert.equal(reopened.content, "edited", "unsaved edits must survive"); + assert.equal(reopened.isDirty, true); +}); + +test("closing the active tab focuses its right-hand neighbour", () => { + let state = withTabs("/v/a.md", "/v/b.md", "/v/c.md"); + state = activateTab(state, "/v/b.md"); + state = closeTab(state, "/v/b.md"); + assert.equal(state.activePath, "/v/c.md"); +}); + +test("closing the last tab falls back to the left", () => { + let state = withTabs("/v/a.md", "/v/b.md", "/v/c.md"); + // /v/c.md is already active as the most recently opened. + state = closeTab(state, "/v/c.md"); + assert.equal(state.activePath, "/v/b.md"); +}); + +test("closing an inactive tab leaves focus alone", () => { + let state = withTabs("/v/a.md", "/v/b.md", "/v/c.md"); + state = closeTab(state, "/v/a.md"); + assert.equal(state.activePath, "/v/c.md"); + assert.equal(state.tabs.length, 2); +}); + +test("closing the only tab empties the state", () => { + const state = closeTab(withTabs("/v/a.md"), "/v/a.md"); + assert.deepEqual(state, emptyTabsState); + assert.equal(activeTab(state), null); +}); + +test("closing an unknown path is a no-op", () => { + const state = withTabs("/v/a.md"); + assert.equal(closeTab(state, "/v/missing.md"), state); +}); + +test("dirtiness is derived from the disk projection, not latched", () => { + let state = withTabs("/v/a.md"); + state = setTabContent(state, "/v/a.md", "edited", "edited"); + assert.equal(activeTab(state).isDirty, true); + assert.deepEqual(dirtyPaths(state), ["/v/a.md"]); + + // Undoing back to the on-disk text clears the flag rather than leaving the + // tab permanently dirty. + state = setTabContent(state, "/v/a.md", "body", "body"); + assert.equal(activeTab(state).isDirty, false); + assert.equal(hasDirtyTabs(state), false); +}); + +test("the disk projection drives dirtiness, not the editor buffer", () => { + // Frontmatter lives outside `content`, so the projection can differ from it. + let state = openTab( + emptyTabsState, + makeTab("/v/a.md", { + content: "body", + diskContent: "---\na: 1\n---\n\nbody", + frontmatter: "---\na: 1\n---\n\n", + }), + ); + // Same body → same bytes on disk → clean. + state = setTabContent(state, "/v/a.md", "body", "---\na: 1\n---\n\nbody"); + assert.equal(activeTab(state).isDirty, false); +}); + +test("markTabSaved adopts the written bytes and clears dirtiness", () => { + let state = withTabs("/v/a.md"); + state = setTabContent(state, "/v/a.md", "edited", "edited"); + state = markTabSaved(state, "/v/a.md", "edited"); + const tab = activeTab(state); + assert.equal(tab.isDirty, false); + assert.equal(tab.diskContent, "edited"); +}); + +test("reloadTabFromDisk discards local edits", () => { + let state = withTabs("/v/a.md"); + state = setTabContent(state, "/v/a.md", "mine", "mine"); + state = reloadTabFromDisk(state, "/v/a.md", { + content: "theirs", + diskContent: "theirs", + frontmatter: null, + roundTrip: "stable", + }); + const tab = activeTab(state); + assert.equal(tab.content, "theirs"); + assert.equal(tab.isDirty, false); +}); + +test("view mode is per tab", () => { + let state = withTabs("/v/a.md", "/v/b.md"); + state = setTabViewMode(state, "/v/a.md", "source"); + assert.equal(state.tabs.find((t) => t.path === "/v/a.md").viewMode, "source"); + assert.equal(state.tabs.find((t) => t.path === "/v/b.md").viewMode, "live"); +}); + +test("renaming a file follows its open tab and keeps focus", () => { + let state = withTabs("/v/a.md", "/v/b.md"); + state = activateTab(state, "/v/a.md"); + state = renameTabPath(state, "/v/a.md", "/v/renamed.md"); + + assert.equal(state.activePath, "/v/renamed.md"); + const tab = state.tabs.find((t) => t.path === "/v/renamed.md"); + assert.equal(tab.name, "renamed"); + assert.equal( + state.tabs.some((t) => t.path === "/v/a.md"), + false, + ); +}); + +test("renaming an unopened file is a no-op", () => { + const state = withTabs("/v/a.md"); + assert.equal(renameTabPath(state, "/v/other.md", "/v/x.md"), state); +}); + +test("deleting a folder closes every tab beneath it", () => { + let state = withTabs("/v/Notes/a.md", "/v/Notes/deep/b.md", "/v/top.md"); + state = closeTabsUnder(state, "/v/Notes"); + + assert.deepEqual( + state.tabs.map((t) => t.path), + ["/v/top.md"], + ); + assert.equal(state.activePath, "/v/top.md"); +}); + +test("closeTabsUnder does not match a sibling sharing a name prefix", () => { + let state = withTabs("/v/Notes/a.md", "/v/Notes-archive/b.md"); + state = closeTabsUnder(state, "/v/Notes"); + assert.deepEqual( + state.tabs.map((t) => t.path), + ["/v/Notes-archive/b.md"], + ); +}); diff --git a/desktop/src/features/documents/lib/documentTabs.ts b/desktop/src/features/documents/lib/documentTabs.ts new file mode 100644 index 0000000000..da2da97e73 --- /dev/null +++ b/desktop/src/features/documents/lib/documentTabs.ts @@ -0,0 +1,230 @@ +/** + * Pure tab-state model for the Documents editor. + * + * Kept free of React and Tauri so the index arithmetic — the part that + * historically goes wrong — can be tested directly. + */ +import { + baseName, + stripMarkdownExtension, +} from "@/features/documents/lib/treeModel"; +import type { RoundTripStatus } from "@/features/documents/lib/roundTripGuard"; + +export type DocumentViewMode = "live" | "source"; + +export type DocumentTab = { + /** Absolute path. The identity key everywhere — never the index. */ + path: string; + /** Basename without the markdown extension, for the tab label. */ + name: string; + /** Live editor content: the body only, with frontmatter split off. */ + content: string; + /** The frontmatter block, re-attached verbatim on save. */ + frontmatter: string | null; + /** Exact bytes last read from or written to disk — the dirty comparand. */ + diskContent: string; + isDirty: boolean; + roundTrip: RoundTripStatus; + viewMode: DocumentViewMode; +}; + +export type DocumentTabsState = { + tabs: DocumentTab[]; + /** Path of the active tab, or `null` when none are open. */ + activePath: string | null; +}; + +export const emptyTabsState: DocumentTabsState = { + activePath: null, + tabs: [], +}; + +export function tabLabel(path: string): string { + return stripMarkdownExtension(baseName(path)); +} + +export function findTab( + state: DocumentTabsState, + path: string, +): DocumentTab | null { + return state.tabs.find((tab) => tab.path === path) ?? null; +} + +export function activeTab(state: DocumentTabsState): DocumentTab | null { + return state.activePath ? findTab(state, state.activePath) : null; +} + +export function hasDirtyTabs(state: DocumentTabsState): boolean { + return state.tabs.some((tab) => tab.isDirty); +} + +export function dirtyPaths(state: DocumentTabsState): string[] { + return state.tabs.filter((tab) => tab.isDirty).map((tab) => tab.path); +} + +/** + * Opens `tab`, or focuses it when already open. + * + * Re-opening never clobbers an existing tab's buffer: a dirty tab whose file is + * clicked again in the tree must keep the user's unsaved edits. + */ +export function openTab( + state: DocumentTabsState, + tab: DocumentTab, +): DocumentTabsState { + const existing = findTab(state, tab.path); + if (existing) { + return { ...state, activePath: existing.path }; + } + return { activePath: tab.path, tabs: [...state.tabs, tab] }; +} + +/** + * Closes a tab and picks the next active one. + * + * When the closed tab was active, focus moves to its right-hand neighbour, + * falling back to the left when it was last. Computed from the *new* array + * rather than a stale length — the index bug this model exists to avoid. + */ +export function closeTab( + state: DocumentTabsState, + path: string, +): DocumentTabsState { + const index = state.tabs.findIndex((tab) => tab.path === path); + if (index === -1) return state; + + const tabs = state.tabs.filter((tab) => tab.path !== path); + if (tabs.length === 0) { + return emptyTabsState; + } + + if (state.activePath !== path) { + return { ...state, tabs }; + } + + const nextIndex = Math.min(index, tabs.length - 1); + return { activePath: tabs[nextIndex].path, tabs }; +} + +export function activateTab( + state: DocumentTabsState, + path: string, +): DocumentTabsState { + return findTab(state, path) ? { ...state, activePath: path } : state; +} + +/** Applies `update` to one tab, leaving the rest untouched. */ +export function updateTab( + state: DocumentTabsState, + path: string, + update: (tab: DocumentTab) => DocumentTab, +): DocumentTabsState { + let changed = false; + const tabs = state.tabs.map((tab) => { + if (tab.path !== path) return tab; + const next = update(tab); + if (next !== tab) changed = true; + return next; + }); + return changed ? { ...state, tabs } : state; +} + +/** + * Records an edit from the editor. + * + * Dirtiness is derived by comparing against `diskContent` rather than latched, + * so undoing back to the on-disk text correctly clears the flag instead of + * leaving a permanently dirty tab. + */ +export function setTabContent( + state: DocumentTabsState, + path: string, + content: string, + /** The exact bytes this content would produce on disk. */ + diskProjection: string, +): DocumentTabsState { + return updateTab(state, path, (tab) => { + if (tab.content === content) return tab; + return { + ...tab, + content, + isDirty: diskProjection !== tab.diskContent, + }; + }); +} + +/** Marks a tab saved, adopting the bytes that were written. */ +export function markTabSaved( + state: DocumentTabsState, + path: string, + diskContent: string, +): DocumentTabsState { + return updateTab(state, path, (tab) => ({ + ...tab, + diskContent, + isDirty: false, + })); +} + +/** Replaces a tab's buffer from disk, discarding local edits. */ +export function reloadTabFromDisk( + state: DocumentTabsState, + path: string, + next: Pick< + DocumentTab, + "content" | "diskContent" | "frontmatter" | "roundTrip" + >, +): DocumentTabsState { + return updateTab(state, path, (tab) => ({ + ...tab, + ...next, + isDirty: false, + })); +} + +export function setTabViewMode( + state: DocumentTabsState, + path: string, + viewMode: DocumentViewMode, +): DocumentTabsState { + return updateTab(state, path, (tab) => + tab.viewMode === viewMode ? tab : { ...tab, viewMode }, + ); +} + +/** + * Rewrites a tab's identity after its file is renamed or moved on disk, so the + * open buffer follows the file instead of pointing at a path that no longer + * exists. + */ +export function renameTabPath( + state: DocumentTabsState, + fromPath: string, + toPath: string, +): DocumentTabsState { + if (!findTab(state, fromPath)) return state; + + const tabs = state.tabs.map((tab) => + tab.path === fromPath + ? { ...tab, name: tabLabel(toPath), path: toPath } + : tab, + ); + return { + activePath: state.activePath === fromPath ? toPath : state.activePath, + tabs, + }; +} + +/** + * Closes every tab under `prefix` — used when a folder is deleted, so buffers + * for files that no longer exist do not linger. + */ +export function closeTabsUnder( + state: DocumentTabsState, + prefix: string, +): DocumentTabsState { + const doomed = state.tabs.filter( + (tab) => tab.path === prefix || tab.path.startsWith(`${prefix}/`), + ); + return doomed.reduce((current, tab) => closeTab(current, tab.path), state); +} diff --git a/desktop/src/features/documents/lib/editor/documentJsonCache.test.mjs b/desktop/src/features/documents/lib/editor/documentJsonCache.test.mjs new file mode 100644 index 0000000000..748d8627d6 --- /dev/null +++ b/desktop/src/features/documents/lib/editor/documentJsonCache.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { + cacheDocument, + clearDocumentCache, + forgetCachedDocument, + getCachedDocument, +} from "./documentJsonCache.ts"; + +const doc = (text) => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +beforeEach(() => { + clearDocumentCache(); +}); + +test("a document is returned only for the markdown it was parsed from", () => { + cacheDocument("/vault/a.md", "# One", doc("One")); + + assert.deepEqual(getCachedDocument("/vault/a.md", "# One"), doc("One")); + // This is the whole safety argument: any difference in the source text is a + // miss, so a stale entry cannot be installed into the editor. + assert.equal(getCachedDocument("/vault/a.md", "# One edited"), null); + assert.equal(getCachedDocument("/vault/b.md", "# One"), null); +}); + +test("re-caching a path replaces its entry rather than accumulating", () => { + cacheDocument("/vault/a.md", "# One", doc("One")); + cacheDocument("/vault/a.md", "# Two", doc("Two")); + + assert.equal(getCachedDocument("/vault/a.md", "# One"), null); + assert.deepEqual(getCachedDocument("/vault/a.md", "# Two"), doc("Two")); +}); + +test("forgetting a path drops just that entry", () => { + cacheDocument("/vault/a.md", "a", doc("a")); + cacheDocument("/vault/b.md", "b", doc("b")); + + forgetCachedDocument("/vault/a.md"); + + assert.equal(getCachedDocument("/vault/a.md", "a"), null); + assert.deepEqual(getCachedDocument("/vault/b.md", "b"), doc("b")); +}); + +test("clearing drops everything, as a vault switch requires", () => { + cacheDocument("/vault/a.md", "a", doc("a")); + cacheDocument("/vault/b.md", "b", doc("b")); + + clearDocumentCache(); + + assert.equal(getCachedDocument("/vault/a.md", "a"), null); + assert.equal(getCachedDocument("/vault/b.md", "b"), null); +}); + +test("the cache is bounded, evicting least-recently-used entries", () => { + for (let i = 0; i < 20; i += 1) { + cacheDocument(`/vault/${i}.md`, `note ${i}`, doc(`note ${i}`)); + } + + // The oldest are gone; the most recent survive. + assert.equal(getCachedDocument("/vault/0.md", "note 0"), null); + assert.deepEqual( + getCachedDocument("/vault/19.md", "note 19"), + doc("note 19"), + ); +}); + +test("reading an entry keeps it in the working set", () => { + cacheDocument("/vault/keep.md", "keep", doc("keep")); + + // Fill past the cap, touching `keep.md` along the way so it stays hot. + for (let i = 0; i < 20; i += 1) { + assert.deepEqual(getCachedDocument("/vault/keep.md", "keep"), doc("keep")); + cacheDocument(`/vault/${i}.md`, `note ${i}`, doc(`note ${i}`)); + } + + assert.deepEqual(getCachedDocument("/vault/keep.md", "keep"), doc("keep")); +}); diff --git a/desktop/src/features/documents/lib/editor/documentJsonCache.ts b/desktop/src/features/documents/lib/editor/documentJsonCache.ts new file mode 100644 index 0000000000..19f2f9921b --- /dev/null +++ b/desktop/src/features/documents/lib/editor/documentJsonCache.ts @@ -0,0 +1,76 @@ +/** + * Parsed-document cache for the Documents editor. + * + * The live editor is deliberately remounted per file (see the `key` in + * `DocumentEditorPane`) so undo can never resurrect a different note's text. + * Creating the editor is cheap — measured at ~10ms — but the `setContent` that + * follows re-parses the whole note through markdown-it every single time, and + * that is not cheap at all: 249ms for a 110KB note, 62ms for a 22KB one. + * Switching tabs paid it on every switch. + * + * Handing `setContent` the ProseMirror JSON it produced last time skips the + * markdown parse entirely, which measured **30–47x faster** across a range of + * real notes (249ms → 8ms on that 110KB note). + * + * The cache is keyed by path *and* the exact markdown it was built from, so a + * stale entry is impossible: any difference in the source text is a miss, and a + * miss simply re-parses. + */ + +import type { JSONContent } from "@tiptap/core"; + +/** Enough for a working set of open tabs without holding a whole vault. */ +const MAX_ENTRIES = 12; + +type CachedDocument = { + /** The exact markdown this JSON was parsed from. */ + markdown: string; + /** ProseMirror document JSON, as returned by `editor.getJSON()`. */ + json: JSONContent; +}; + +const cache = new Map(); + +/** The parsed form of `markdown`, or null when it was never parsed here. */ +export function getCachedDocument( + path: string, + markdown: string, +): JSONContent | null { + const entry = cache.get(path); + if (!entry || entry.markdown !== markdown) return null; + + // Refresh insertion order so the working set survives eviction. + cache.delete(path); + cache.set(path, entry); + return entry.json; +} + +export function cacheDocument( + path: string, + markdown: string, + json: JSONContent, +): void { + cache.delete(path); + cache.set(path, { json, markdown }); + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next(); + if (oldest.done) break; + cache.delete(oldest.value); + } +} + +/** Drops one entry — a closed tab, or a file that changed underneath us. */ +export function forgetCachedDocument(path: string): void { + cache.delete(path); +} + +/** + * Drops everything. Called when leaving a vault. + * + * Keys are absolute paths, so entries from another vault can never be served by + * mistake; this only avoids holding a closed vault's documents until they age + * out of the cap. + */ +export function clearDocumentCache(): void { + cache.clear(); +} diff --git a/desktop/src/features/documents/lib/editor/obsidianSyntaxExtension.ts b/desktop/src/features/documents/lib/editor/obsidianSyntaxExtension.ts new file mode 100644 index 0000000000..6d317307c8 --- /dev/null +++ b/desktop/src/features/documents/lib/editor/obsidianSyntaxExtension.ts @@ -0,0 +1,231 @@ +/** + * Decorations for Obsidian's inline and block syntax, plus heading extraction + * for the outline panel and click-to-toggle task checkboxes. + * + * All of it is decoration-driven. Nothing here becomes a schema node, so the + * underlying markdown is unchanged and notes using these constructs still pass + * the round-trip guard. That is the whole design constraint — see + * `obsidianSyntax.ts`. + */ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { + findBlockId, + findComments, + findHighlights, + findTags, + parseCallout, + type OutlineHeading, +} from "@/features/documents/lib/obsidianSyntax"; + +export const obsidianSyntaxKey = new PluginKey("documentsObsidianSyntax"); + +export type ObsidianSyntaxStorage = { + /** Headings in document order, refreshed on every doc change. */ + headings: OutlineHeading[]; + /** + * Called when a `#tag` is clicked. + * + * Nothing supplies this yet — a tag click has nowhere to lead until the vault + * gains a search, which v1 deliberately leaves out. It stays as the seam that + * search will plug into; the click handler already falls through when it is + * null, and the CSS does not advertise tags as clickable in the meantime. + */ + onTagClick: ((tag: string) => void) | null; + /** Notified whenever `headings` changes, so React can re-render. */ + onHeadingsChange: ((headings: OutlineHeading[]) => void) | null; +}; + +/** `- [ ]` / `- [x]` at the start of a paragraph inside a list item. */ +const TASK_PATTERN = /^(\s*(?:[-*+]|\d+\.)\s+)\[([ xX])\]\s/; + +function decorateText( + text: string, + from: number, + decorations: Decoration[], +): void { + for (const match of findHighlights(text)) { + decorations.push( + Decoration.inline( + from + match.index, + from + match.index + match.raw.length, + { + class: "obsidian-highlight", + }, + ), + ); + } + for (const match of findComments(text)) { + decorations.push( + Decoration.inline( + from + match.index, + from + match.index + match.raw.length, + { + class: "obsidian-comment", + }, + ), + ); + } + const blockId = findBlockId(text); + if (blockId) { + decorations.push( + Decoration.inline( + from + blockId.index, + from + blockId.index + blockId.raw.length, + { class: "obsidian-block-id", "data-block-id": blockId.content }, + ), + ); + } + for (const match of findTags(text)) { + decorations.push( + Decoration.inline( + from + match.index, + from + match.index + match.raw.length, + { + class: "obsidian-tag", + "data-tag": match.content, + }, + ), + ); + } +} + +function build( + doc: ProseMirrorNode, + storage: ObsidianSyntaxStorage, +): { decorations: DecorationSet; headings: OutlineHeading[] } { + const decorations: Decoration[] = []; + const headings: OutlineHeading[] = []; + + doc.descendants((node, position) => { + if (node.type.name === "heading") { + headings.push({ + level: Number(node.attrs.level ?? 1), + position, + text: node.textContent, + }); + return; + } + + if (node.type.name === "blockquote") { + // Only the first line of a blockquote carries the callout marker. + const [firstLine = ""] = node.textContent.split("\n"); + const callout = parseCallout(`> ${firstLine}`); + if (callout) { + decorations.push( + Decoration.node(position, position + node.nodeSize, { + class: `callout callout-${callout.canonical}`, + "data-callout": callout.type, + }), + ); + } + return; + } + + if (node.isText && node.text) { + decorateText(node.text, position, decorations); + return; + } + + if (node.type.name === "paragraph" && TASK_PATTERN.test(node.textContent)) { + decorations.push( + Decoration.node(position, position + node.nodeSize, { + class: "obsidian-task", + }), + ); + } + }); + + storage.headings = headings; + return { decorations: DecorationSet.create(doc, decorations), headings }; +} + +export const ObsidianSyntaxExtension = Extension.create({ + name: "documentsObsidianSyntax", + + addStorage(): ObsidianSyntaxStorage { + return { headings: [], onHeadingsChange: null, onTagClick: null }; + }, + + addProseMirrorPlugins() { + const extension = this; + + return [ + new Plugin({ + key: obsidianSyntaxKey, + props: { + decorations(state) { + return obsidianSyntaxKey.getState(state) as + | DecorationSet + | undefined; + }, + handleClick(view, position, event) { + const element = event.target as HTMLElement | null; + const storage = extension.storage as ObsidianSyntaxStorage; + + if (element?.classList.contains("obsidian-tag")) { + const tag = element.getAttribute("data-tag"); + // Without a handler, fall through so the click still places the + // caret — swallowing it would make tagged text unselectable. + if (tag && storage.onTagClick) { + event.preventDefault(); + storage.onTagClick(tag); + return true; + } + } + + // Toggle a task checkbox by rewriting its marker text. + // + // Onyx hit-tests this with a hardcoded `clickX > 30`, measured + // against its own CSS — a number that breaks under Cmd +/- zoom. + // Matching the rendered marker element instead survives any + // font size. + const taskElement = element?.closest(".obsidian-task"); + if (!taskElement) return false; + + const resolved = view.state.doc.resolve(position); + const paragraph = resolved.parent; + if (!paragraph.isTextblock) return false; + + const match = TASK_PATTERN.exec(paragraph.textContent); + if (!match) return false; + + // Only the checkbox itself toggles; clicking the label text should + // place the cursor as normal. + const start = resolved.start(); + const boxFrom = start + match[1].length + 1; + if (position < start + match[1].length || position > boxFrom + 2) { + return false; + } + + const next = match[2] === " " ? "x" : " "; + event.preventDefault(); + view.dispatch(view.state.tr.insertText(next, boxFrom, boxFrom + 1)); + return true; + }, + }, + state: { + init(_config, state) { + return build(state.doc, extension.storage as ObsidianSyntaxStorage) + .decorations; + }, + apply(transaction, previous) { + if ( + !transaction.docChanged && + !transaction.getMeta(obsidianSyntaxKey) + ) { + return previous; + } + const storage = extension.storage as ObsidianSyntaxStorage; + const { decorations, headings } = build(transaction.doc, storage); + storage.onHeadingsChange?.(headings); + return decorations; + }, + }, + }), + ]; + }, +}); diff --git a/desktop/src/features/documents/lib/editor/useVaultEditor.ts b/desktop/src/features/documents/lib/editor/useVaultEditor.ts new file mode 100644 index 0000000000..3241a79010 --- /dev/null +++ b/desktop/src/features/documents/lib/editor/useVaultEditor.ts @@ -0,0 +1,224 @@ +/** + * The live-preview markdown editor for a vault note. + * + * Modelled on `features/messages/lib/useRichTextEditor.ts`, but with one rule + * that composer does not need: **a note the user has not edited must never be + * written**. `onUpdate` therefore bails unless the transaction actually changed + * the document, and every programmatic content swap passes `emitUpdate: false`. + * + * Onyx wires its listener to an event that also fires on load, which is exactly + * how "open a file, touch nothing, and it silently rewrites on disk" happens. + */ +import * as React from "react"; +import { useEditor } from "@tiptap/react"; + +import { + cacheDocument, + getCachedDocument, +} from "@/features/documents/lib/editor/documentJsonCache"; +import { vaultEditorExtensions } from "@/features/documents/lib/editor/vaultEditorExtensions"; +import { + wikilinkKey, + type WikilinkClickHandler, + type WikilinkStorage, +} from "@/features/documents/lib/editor/wikilinkExtension"; +import { + obsidianSyntaxKey, + type ObsidianSyntaxStorage, +} from "@/features/documents/lib/editor/obsidianSyntaxExtension"; +import { toDiskMarkdown } from "@/features/documents/lib/markdownEscapes"; +import type { NoteIndex } from "@/features/documents/lib/noteIndex"; +import type { OutlineHeading } from "@/features/documents/lib/obsidianSyntax"; + +export type UseVaultEditorOptions = { + /** Path of the note being edited, for same-folder wikilink resolution. */ + currentPath: string; + /** Vault-wide index; `null` while the corpus is still loading. */ + noteIndex: NoteIndex | null; + /** Called only for genuine user edits, with the disk-ready markdown. */ + onChange: (markdown: string) => void; + /** Ctrl/Cmd+S. */ + onSave: () => void; + onWikilinkClick: WikilinkClickHandler; + /** Receives the heading list whenever the document changes. */ + onHeadingsChange?: (headings: OutlineHeading[]) => void; + onTagClick?: (tag: string) => void; +}; + +function readMarkdown(editor: { + storage: unknown; + state: { doc: { textContent: string } }; +}): string { + const storage = editor.storage as { + markdown?: { getMarkdown?: () => string }; + }; + const raw = storage.markdown?.getMarkdown?.(); + if (typeof raw !== "string") { + // Falling back to plain text would silently flatten the document; refusing + // is safer than writing a degraded version over the user's note. + throw new Error("tiptap-markdown storage is unavailable"); + } + return toDiskMarkdown(raw); +} + +export function useVaultEditor({ + currentPath, + noteIndex, + onChange, + onHeadingsChange, + onSave, + onTagClick, + onWikilinkClick, +}: UseVaultEditorOptions) { + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + const onSaveRef = React.useRef(onSave); + onSaveRef.current = onSave; + + /** + * Suppresses `onChange` while we are loading a document into the editor. + * `emitUpdate: false` covers most of it, but input rules and paste handling + * can still dispatch during a swap. + */ + const isLoadingRef = React.useRef(false); + + const editor = useEditor({ + extensions: vaultEditorExtensions(), + content: "", + editorProps: { + attributes: { + class: "documents-editor focus:outline-none", + // Notes are prose; code spans opt out individually. + spellcheck: "true", + }, + handleKeyDown: (_view, event) => { + const isSaveChord = + (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s"; + if (!isSaveChord) return false; + event.preventDefault(); + onSaveRef.current(); + return true; + }, + }, + onUpdate: ({ editor: instance, transaction }) => { + // The load path must not mark the note dirty. + if (isLoadingRef.current) return; + if (!transaction.docChanged) return; + try { + onChangeRef.current(readMarkdown(instance)); + } catch { + // A serializer failure must not take the editor down mid-keystroke. + // The buffer stays as the user typed it; the next save attempt surfaces + // the error where it can be shown. + } + }, + }); + + // Keep wikilink decorations in sync with the index. Mutating + // `editor.storage.` directly is required: the extension instance's + // `.storage` getter returns a fresh spread-copy per access, so writes through + // it are silently lost (see the note in useRichTextEditor.ts). + React.useEffect(() => { + if (!editor) return; + const storage = (editor.storage as unknown as Record) + .documentsWikilink as WikilinkStorage | undefined; + if (!storage) return; + + storage.currentPath = currentPath; + storage.noteIndex = noteIndex; + storage.onWikilinkClick = onWikilinkClick; + // Force a re-decoration; the document itself has not changed. + editor.view.dispatch(editor.state.tr.setMeta(wikilinkKey, true)); + }, [currentPath, editor, noteIndex, onWikilinkClick]); + + React.useEffect(() => { + if (!editor) return; + const storage = (editor.storage as unknown as Record) + .documentsObsidianSyntax as ObsidianSyntaxStorage | undefined; + if (!storage) return; + + storage.onHeadingsChange = onHeadingsChange ?? null; + storage.onTagClick = onTagClick ?? null; + // Publish the current headings immediately; the plugin only emits on + // change, so a freshly loaded document would otherwise show no outline. + onHeadingsChange?.(storage.headings); + editor.view.dispatch(editor.state.tr.setMeta(obsidianSyntaxKey, true)); + }, [editor, onHeadingsChange, onTagClick]); + + /** Loads a document without marking it dirty. */ + const loadDocument = React.useCallback( + (markdown: string) => { + if (!editor) return; + isLoadingRef.current = true; + try { + // Re-parsing markdown dominates the cost of opening or switching to a + // note; the identical parsed document is 30-47x cheaper to install. + const cached = getCachedDocument(currentPath, markdown); + if (cached !== null) { + editor.commands.setContent(cached, { emitUpdate: false }); + return; + } + editor.commands.setContent(markdown, { emitUpdate: false }); + cacheDocument(currentPath, markdown, editor.getJSON()); + } finally { + isLoadingRef.current = false; + } + }, + [currentPath, editor], + ); + + /** + * Moves the caret to a document position and scrolls it into view. Used by + * the outline panel, which knows heading positions but not the editor. + */ + const scrollToPosition = React.useCallback( + (position: number) => { + if (!editor) return; + editor.chain().focus().setTextSelection(position).scrollIntoView().run(); + }, + [editor], + ); + + /** + * Vertical offsets of `positions` relative to the scroll container, for + * scroll-spy. Returns an empty list when the view is not laid out yet. + */ + const measureOffsets = React.useCallback( + (positions: readonly number[]): number[] => { + if (!editor?.view.dom.isConnected) return []; + const container = editor.view.dom.parentElement; + if (!container) return []; + const containerTop = container.getBoundingClientRect().top; + return positions.map((position) => { + try { + return ( + editor.view.coordsAtPos(position).top - + containerTop + + container.scrollTop + ); + } catch { + // A position can briefly be out of range mid-update. + return Number.POSITIVE_INFINITY; + } + }); + }, + [editor], + ); + + const getMarkdown = React.useCallback((): string | null => { + if (!editor) return null; + try { + return readMarkdown(editor); + } catch { + return null; + } + }, [editor]); + + return { + editor, + getMarkdown, + loadDocument, + measureOffsets, + scrollToPosition, + }; +} diff --git a/desktop/src/features/documents/lib/editor/vaultEditorExtensions.ts b/desktop/src/features/documents/lib/editor/vaultEditorExtensions.ts new file mode 100644 index 0000000000..812c26cfaf --- /dev/null +++ b/desktop/src/features/documents/lib/editor/vaultEditorExtensions.ts @@ -0,0 +1,69 @@ +/** + * The extension set for vault notes. + * + * Shared by the live editor and the round-trip probe. They must agree: if the + * probe measured a different schema than the editor uses, the guard would bless + * files the editor then reformats. + * + * Kept deliberately close to CommonMark. Wikilinks are decoration-only — they + * stay plain text in the document and so serialize back byte-identically. + * Constructs that would need a real node (callouts, tables, footnotes) are + * absent on purpose: until an extension can both render *and* serialize one, + * the round-trip guard correctly routes those files to source mode rather than + * silently eating them. + */ +import type { Extensions } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { Table } from "@tiptap/extension-table"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { TableRow } from "@tiptap/extension-table-row"; +import { Markdown } from "tiptap-markdown"; + +import { ObsidianSyntaxExtension } from "@/features/documents/lib/editor/obsidianSyntaxExtension"; +import { WikilinkExtension } from "@/features/documents/lib/editor/wikilinkExtension"; + +export function vaultEditorExtensions(): Extensions { + return [ + StarterKit.configure({ + // Notes are documents, not chat: headings and horizontal rules are + // wanted here, unlike in the message composer. + codeBlock: { + HTMLAttributes: { spellcheck: "false" }, + }, + code: { + HTMLAttributes: { spellcheck: "false" }, + }, + // StarterKit's trailing-node plugin appends an empty paragraph after + // block nodes. In a file-backed document that is a phantom blank line + // that would be written to disk. + trailingNode: false, + // Configured separately below is unnecessary here — the default Link + // behaviour is right for notes, but autolinking would rewrite bare URLs + // the user typed as plain text, so it stays off. + link: { + autolink: false, + openOnClick: false, + }, + }), + // Without these, a GFM table has no schema node: markdown-it still parses + // one, the nodes are dropped, and the table serializes back as its bare + // concatenated cell text. `tiptap-markdown` ships serialization for them. + Table.configure({ resizable: false }), + TableRow, + TableHeader, + TableCell, + Markdown.configure({ + // Preserve the source as closely as the serializer allows. + breaks: false, + html: false, + linkify: false, + transformPastedText: false, + }), + // Both decoration-only: wikilinks, callouts, highlights, comments and tags + // stay plain text in the document, so they serialize back byte-identically + // and the round-trip guard still passes. + WikilinkExtension, + ObsidianSyntaxExtension, + ]; +} diff --git a/desktop/src/features/documents/lib/editor/wikilinkExtension.ts b/desktop/src/features/documents/lib/editor/wikilinkExtension.ts new file mode 100644 index 0000000000..a8496619b7 --- /dev/null +++ b/desktop/src/features/documents/lib/editor/wikilinkExtension.ts @@ -0,0 +1,148 @@ +/** + * Renders `[[wikilinks]]` in the editor and routes clicks on them. + * + * Onyx keeps the note index and the click handler in module-level mutable + * globals with setter functions, which makes the editor a singleton — two + * instances would overwrite each other's state. Here both live in extension + * `storage`, which is per-instance. + * + * `storage` rather than `options` on purpose: options are baked at + * `configure()` time and changing one means recreating the editor, whereas the + * note index changes every time a file is created or renamed. + */ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { + resolveWikilink, + type NoteIndex, +} from "@/features/documents/lib/noteIndex"; +import { parseWikilinks } from "@/features/documents/lib/wikilinkSyntax"; + +export const wikilinkKey = new PluginKey("documentsWikilink"); + +export type WikilinkClickHandler = (input: { + target: string; + heading: string | null; + blockId: string | null; + /** Whether the note exists; `false` means the link is broken. */ + exists: boolean; + /** Absolute path the link resolves to, or would create. */ + path: string | null; +}) => void; + +export type WikilinkStorage = { + noteIndex: NoteIndex | null; + /** Path of the note being edited, for same-folder link resolution. */ + currentPath: string | null; + onWikilinkClick: WikilinkClickHandler | null; +}; + +function buildDecorations( + doc: ProseMirrorNode, + storage: WikilinkStorage, +): DecorationSet { + const decorations: Decoration[] = []; + + doc.descendants((node, position) => { + if (!node.isText || !node.text) return; + + for (const link of parseWikilinks(node.text)) { + const from = position + link.index; + const to = from + link.raw.length; + + // Same-note anchors always "exist"; a named target might not. + const resolved = link.target + ? resolveWikilink( + link.target, + storage.currentPath ?? "", + storage.noteIndex, + ) + : null; + const isBroken = Boolean(link.target) && resolved?.exists === false; + + decorations.push( + Decoration.inline(from, to, { + class: isBroken ? "wikilink wikilink-broken" : "wikilink", + "data-block": link.blockId ?? "", + "data-heading": link.heading ?? "", + "data-target": link.target, + }), + ); + } + }); + + return DecorationSet.create(doc, decorations); +} + +export const WikilinkExtension = Extension.create({ + name: "documentsWikilink", + + addStorage(): WikilinkStorage { + return { currentPath: null, noteIndex: null, onWikilinkClick: null }; + }, + + addProseMirrorPlugins() { + const extension = this; + + return [ + new Plugin({ + key: wikilinkKey, + props: { + decorations(state) { + return wikilinkKey.getState(state) as DecorationSet | undefined; + }, + handleClick(_view, _pos, event) { + const element = event.target as HTMLElement | null; + if (!element?.classList.contains("wikilink")) return false; + + const target = element.getAttribute("data-target") ?? ""; + const heading = element.getAttribute("data-heading") || null; + const blockId = element.getAttribute("data-block") || null; + const storage = extension.storage as WikilinkStorage; + + const resolved = target + ? resolveWikilink( + target, + storage.currentPath ?? "", + storage.noteIndex, + ) + : null; + + event.preventDefault(); + storage.onWikilinkClick?.({ + blockId, + exists: resolved?.exists ?? false, + heading, + path: resolved?.path ?? null, + target, + }); + return true; + }, + }, + state: { + init: (_config, state) => + buildDecorations(state.doc, extension.storage as WikilinkStorage), + apply(transaction, previous) { + // The note index changed (a file was created, renamed or deleted), + // so broken-link styling must be recomputed even though the + // document itself did not change. + if (transaction.getMeta(wikilinkKey)) { + return buildDecorations( + transaction.doc, + extension.storage as WikilinkStorage, + ); + } + if (!transaction.docChanged) return previous; + return buildDecorations( + transaction.doc, + extension.storage as WikilinkStorage, + ); + }, + }, + }), + ]; + }, +}); diff --git a/desktop/src/features/documents/lib/editorSecurity.test.mjs b/desktop/src/features/documents/lib/editorSecurity.test.mjs new file mode 100644 index 0000000000..79b7fdc9e1 --- /dev/null +++ b/desktop/src/features/documents/lib/editorSecurity.test.mjs @@ -0,0 +1,184 @@ +/** + * Hostile markdown through the real editor pipeline. + * + * A vault is not necessarily trusted input. Notes arrive from git repos, sync + * clients, shared team folders and downloads. These tests feed the editor the + * payloads an attacker would put in a `.md` file and assert that none of them + * reach the DOM as anything but text. + * + * Buzz does ship a CSP whose `script-src` omits `'unsafe-inline'`, so an + * injected `"], + ["img onerror", ''], + ["svg onload", ''], + ["iframe", ''], + ["body onload", ''], + ["style tag", ""], + [ + "script inside a fenced block", + "```\n\n```", + ], +]; + +test("raw HTML in a note never becomes live markup", () => { + for (const [label, payload] of SCRIPT_PAYLOADS) { + const container = renderDom(payload); + + assert.equal( + globalThis.pwned, + undefined, + `${label}: a payload executed during parsing`, + ); + + const dangerous = container.querySelector( + "script, iframe, object, embed, style, svg, link, meta, form", + ); + assert.equal( + dangerous, + null, + `${label}: a <${dangerous?.tagName.toLowerCase()}> element materialised`, + ); + + const handler = allAttributes(container).find((attribute) => + attribute.name.startsWith("on"), + ); + assert.equal( + handler, + undefined, + `${label}: ${handler?.element} carried ${handler?.name}="${handler?.value}"`, + ); + + // The payload should still be visible to the reader, as literal text. + assert.ok( + container.textContent.includes("<") || payload.startsWith("```"), + `${label}: the payload vanished entirely rather than being escaped`, + ); + } +}); + +test("dangerous link protocols do not survive into an href", () => { + // `openOnClick` is false so nothing follows these, but a `javascript:` href + // sitting in the DOM of a CSP-less app is one stray handler away from being a + // real problem. TipTap's own protocol validation is what stops it; this + // pins that we depend on it. + for (const payload of [ + "[click me](javascript:globalThis.pwned=true)", + "[click me](JaVaScRiPt:globalThis.pwned=true)", + "[click me](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", + "[click me](vbscript:msgbox)", + ]) { + const container = renderDom(payload); + const hrefs = allAttributes(container).filter( + (attribute) => attribute.name === "href" || attribute.name === "src", + ); + for (const { name, value } of hrefs) { + assert.ok( + !/^\s*(?:javascript|data|vbscript):/i.test(value), + `a dangerous protocol reached ${name}\n ${payload}\n ${value}`, + ); + } + } +}); + +test("ordinary links still work, so the guard above is not vacuous", () => { + const html = renderHtml("[docs](https://example.com/page)"); + assert.ok( + html.includes('href="https://example.com/page"'), + `an ordinary link must survive, otherwise this file proves nothing\n ${html}`, + ); +}); + +test("a note cannot smuggle markup through an image URL", () => { + const container = renderDom( + '![alt](https://example.com/x.png"onerror="alert(1))', + ); + const handler = allAttributes(container).find((attribute) => + attribute.name.startsWith("on"), + ); + assert.equal( + handler, + undefined, + `an event handler escaped through an image URL: ${handler?.name}`, + ); +}); diff --git a/desktop/src/features/documents/lib/frontmatter.test.mjs b/desktop/src/features/documents/lib/frontmatter.test.mjs new file mode 100644 index 0000000000..6d91334661 --- /dev/null +++ b/desktop/src/features/documents/lib/frontmatter.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { joinFrontmatter, splitFrontmatter } from "./frontmatter.ts"; + +test("splits a frontmatter block off the body", () => { + const raw = "---\ntitle: Note\ntags: [a, b]\n---\n\n# Body\n"; + const split = splitFrontmatter(raw); + // The blank separator line belongs to the frontmatter side: the editor drops + // a leading blank line, so keeping it on the body would fail the round-trip + // guard over pure cosmetics. + assert.equal(split.frontmatter, "---\ntitle: Note\ntags: [a, b]\n---\n\n"); + assert.equal(split.body, "# Body\n"); +}); + +test("absorbs however many blank lines follow the closing fence", () => { + const { body, frontmatter } = splitFrontmatter( + "---\na: 1\n---\n\n\n\n# Body", + ); + assert.equal(frontmatter, "---\na: 1\n---\n\n\n\n"); + assert.equal(body, "# Body"); +}); + +test("a body starting immediately after the fence keeps no separator", () => { + const { body, frontmatter } = splitFrontmatter("---\na: 1\n---\n# Body"); + assert.equal(frontmatter, "---\na: 1\n---\n"); + assert.equal(body, "# Body"); +}); + +test("round-trips byte-for-byte through join", () => { + for (const raw of [ + "---\ntitle: Note\n---\n\n# Body\n", + "# No frontmatter\n", + "---\ntitle: Note\n---\n", + "---\r\ntitle: CRLF\r\n---\r\n\r\n# Body\r\n", + "---\ntricky: 'value with --- inside'\n---\n\nbody", + ]) { + const { body, frontmatter } = splitFrontmatter(raw); + assert.equal(joinFrontmatter(frontmatter, body), raw, raw); + } +}); + +test("a file with no frontmatter is all body", () => { + const raw = "# Just a heading\n\nSome text.\n"; + assert.deepEqual(splitFrontmatter(raw), { body: raw, frontmatter: null }); +}); + +test("a thematic break partway down the file is not frontmatter", () => { + // The opening fence must be the very first line. + const raw = "# Heading\n\n---\n\nMore text.\n"; + assert.deepEqual(splitFrontmatter(raw), { body: raw, frontmatter: null }); +}); + +test("an unterminated block is treated as body, not swallowed", () => { + // Losing the file because someone typed `---` on line 1 would be far worse + // than declining to split. + const raw = "---\ntitle: never closed\n\n# Body\n"; + assert.deepEqual(splitFrontmatter(raw), { body: raw, frontmatter: null }); +}); + +test("tolerates CRLF delimiters", () => { + const raw = "---\r\ntitle: Note\r\n---\r\n\r\n# Body\r\n"; + const { body, frontmatter } = splitFrontmatter(raw); + assert.equal(frontmatter, "---\r\ntitle: Note\r\n---\r\n\r\n"); + assert.equal(body, "# Body\r\n"); +}); + +test("an empty frontmatter block still splits", () => { + const raw = "---\n---\n\nbody\n"; + const { body, frontmatter } = splitFrontmatter(raw); + assert.equal(frontmatter, "---\n---\n\n"); + assert.equal(body, "body\n"); +}); + +test("joinFrontmatter with no frontmatter returns the body unchanged", () => { + assert.equal(joinFrontmatter(null, "# Body"), "# Body"); +}); diff --git a/desktop/src/features/documents/lib/frontmatter.ts b/desktop/src/features/documents/lib/frontmatter.ts new file mode 100644 index 0000000000..c9f95b9e33 --- /dev/null +++ b/desktop/src/features/documents/lib/frontmatter.ts @@ -0,0 +1,77 @@ +/** + * YAML frontmatter splitting. + * + * This is not a parser and does not want to be — v1 has no Properties UI. The + * only job is to keep the frontmatter block *out* of the editor and put it back + * byte-for-byte on save. + * + * That matters more than it sounds. Round-tripping `---\ntitle: Note\n---` + * through tiptap-markdown yields `---\n\n## title: Note`: the opening fence + * becomes a thematic break and every YAML line becomes a heading. Splitting it + * off before the editor ever sees it removes the single largest source of + * silent corruption in a real Obsidian vault. + */ + +export type SplitDocument = { + /** + * The frontmatter block including its delimiters and trailing newline, or + * `null` when the file has none. Preserved verbatim — never reformatted. + */ + frontmatter: string | null; + /** Everything after the frontmatter block. */ + body: string; +}; + +/** + * A frontmatter block must start on the very first line, and the delimiter is + * exactly three dashes on a line of their own. Obsidian and Jekyll both allow a + * trailing `\r`, so tolerate CRLF. + */ +const OPENING_FENCE = /^---[ \t]*\r?\n/; + +export function splitFrontmatter(raw: string): SplitDocument { + const opening = OPENING_FENCE.exec(raw); + if (!opening) { + return { body: raw, frontmatter: null }; + } + + // Find the closing fence, starting the search after the opening one. + const searchFrom = opening[0].length; + const closing = /^---[ \t]*(\r?\n|$)/m.exec(raw.slice(searchFrom)); + if (!closing) { + // An unterminated block is not frontmatter — treat the whole file as body + // rather than swallowing it. + return { body: raw, frontmatter: null }; + } + + let end = searchFrom + closing.index + closing[0].length; + + // Absorb the blank lines that conventionally separate the block from the + // body. The editor drops a leading blank line, so leaving it on the body + // side would make every note with frontmatter fail the round-trip guard and + // open in source mode — for a purely cosmetic difference. + const separator = /^(?:[ \t]*\r?\n)+/.exec(raw.slice(end)); + if (separator) { + end += separator[0].length; + } + + return { + body: raw.slice(end), + frontmatter: raw.slice(0, end), + }; +} + +/** + * Re-attaches a frontmatter block to an edited body. + * + * `splitFrontmatter` keeps the block's own trailing newline, so this is a plain + * concatenation — which is exactly the point: whatever the user's YAML looked + * like, byte-for-byte, is what goes back to disk. + */ +export function joinFrontmatter( + frontmatter: string | null, + body: string, +): string { + if (!frontmatter) return body; + return `${frontmatter}${body}`; +} diff --git a/desktop/src/features/documents/lib/markdownEscapes.test.mjs b/desktop/src/features/documents/lib/markdownEscapes.test.mjs new file mode 100644 index 0000000000..da2a02a079 --- /dev/null +++ b/desktop/src/features/documents/lib/markdownEscapes.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + normalizeHardBreaks, + stripMarkdownEscapes, + toDiskMarkdown, +} from "./markdownEscapes.ts"; + +test("unescapes the characters prosemirror-markdown escapes", () => { + // The case that motivates the whole function: a wikilink must survive. + assert.equal( + stripMarkdownEscapes("A \\[\\[wikilink\\]\\] here."), + "A [[wikilink]] here.", + ); + assert.equal(stripMarkdownEscapes("\\*not bold\\*"), "*not bold*"); + assert.equal(stripMarkdownEscapes("\\`code\\`"), "`code`"); + assert.equal(stripMarkdownEscapes("\\~\\~strike\\~\\~"), "~~strike~~"); + assert.equal(stripMarkdownEscapes("snake\\_case"), "snake_case"); + assert.equal(stripMarkdownEscapes("\\!\\[alt\\]"), "![alt]"); +}); + +test("leaves unescaped text alone", () => { + const plain = "Nothing to undo here — [[link]] *bold* `code`."; + assert.equal(stripMarkdownEscapes(plain), plain); +}); + +test("strips exactly one level of escaping", () => { + // `\\[` is an escaped backslash followed by a bracket. Removing the first + // backslash is correct; the bracket must not also lose its escape. + assert.equal(stripMarkdownEscapes("\\\\["), "\\["); +}); + +test("known false positive: a deliberate literal escape is lost", () => { + // Documented, not accidental. A user who typed `\*` to mean a literal + // asterisk gets `*` back, which will render as emphasis next time. The + // round-trip guard is what actually protects such files -- they classify + // lossy and open in source mode, never reaching this function. + assert.equal(stripMarkdownEscapes("2 \\* 3"), "2 * 3"); +}); + +test("collapses CommonMark hard line breaks", () => { + assert.equal( + normalizeHardBreaks("line one\\\nline two"), + "line one\nline two", + ); +}); + +test("toDiskMarkdown applies both passes", () => { + assert.equal( + toDiskMarkdown("A \\[\\[link\\]\\]\\\nnext line"), + "A [[link]]\nnext line", + ); +}); diff --git a/desktop/src/features/documents/lib/markdownEscapes.ts b/desktop/src/features/documents/lib/markdownEscapes.ts new file mode 100644 index 0000000000..61b70084af --- /dev/null +++ b/desktop/src/features/documents/lib/markdownEscapes.ts @@ -0,0 +1,45 @@ +/** + * Undoing prosemirror-markdown's text escaping. + * + * `prosemirror-markdown`'s serializer backslash-escapes markdown special + * characters in text nodes so they can't be reinterpreted as formatting. For + * chat that is correct. For a vault note it is not: `[[wikilink]]` comes back + * as `\[\[wikilink\]\]`, and writing that to disk corrupts the link in + * Obsidian. + * + * Onyx patched this in three separate places with three slightly different + * regexes; Buzz's chat composer has a fourth. This is the one owner. + */ + +/** + * The union of what Onyx stripped (`[ ] _ !`) and what Buzz's composer strips + * (`` ` * \ ~ [ ] _ ``). + */ +const ESCAPED_MARKDOWN_CHARACTER = /\\([`*\\~[\]_!])/g; + +/** + * Strips one level of backslash escaping from markdown special characters. + * + * **This is lossy in one direction and that is a known trade.** A note + * containing a deliberate literal `\*not bold\*` becomes `*not bold*` and will + * render as emphasis the next time it is opened — a silent, compounding + * rewrite. The round-trip guard is the real backstop: a file with deliberate + * escapes fails the parse/serialize comparison, opens in source mode, and never + * reaches this function. + */ +export function stripMarkdownEscapes(markdown: string): string { + return markdown.replace(ESCAPED_MARKDOWN_CHARACTER, "$1"); +} + +/** + * tiptap-markdown emits CommonMark hard line breaks as a trailing backslash. + * Vault notes use plain newlines, so collapse them. + */ +export function normalizeHardBreaks(markdown: string): string { + return markdown.replace(/\\\n/g, "\n"); +} + +/** The full editor-output → on-disk text pipeline. */ +export function toDiskMarkdown(editorMarkdown: string): string { + return stripMarkdownEscapes(normalizeHardBreaks(editorMarkdown)); +} diff --git a/desktop/src/features/documents/lib/markdownRoundTrip.test.mjs b/desktop/src/features/documents/lib/markdownRoundTrip.test.mjs new file mode 100644 index 0000000000..a1a9662f48 --- /dev/null +++ b/desktop/src/features/documents/lib/markdownRoundTrip.test.mjs @@ -0,0 +1,188 @@ +/** + * The corpus test: real Obsidian constructs through the real TipTap pipeline. + * + * This is the regression net for the round-trip guard. Its job is not to prove + * TipTap is lossless -- it demonstrably is not -- but to pin down *which* + * constructs survive, so that a future extension that changes the answer fails + * here loudly instead of silently rewriting someone's vault. + * + * Runs under jsdom because a ProseMirror editor needs a DOM. + */ +import assert from "node:assert/strict"; +import { before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +import { splitFrontmatter } from "./frontmatter.ts"; +import { isRoundTripStable } from "./roundTripGuard.ts"; + +let reserializeMarkdown; +let destroyMarkdownProbe; + +before(async () => { + const dom = new JSDOM(""); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.Element = dom.window.Element; + globalThis.Node = dom.window.Node; + globalThis.DocumentFragment = dom.window.DocumentFragment; + globalThis.getComputedStyle = dom.window.getComputedStyle; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + + ({ destroyMarkdownProbe, reserializeMarkdown } = await import( + "./markdownRoundTrip.ts" + )); +}); + +/** + * Constructs the editor must not touch. A regression here is a real bug. + * + * The Obsidian entries survive because they are plain text to this schema and + * `toDiskMarkdown` undoes the serializer's escaping of `[`, `]`, `~` and `_`. + * That is exactly why the escape-stripping exists. + */ +const MUST_BE_STABLE = [ + ["heading and paragraph", "# Title\n\nA paragraph."], + ["emphasis and strong", "Some *emphasis* and **bold**."], + ["inline code", "Call `useVaultEditor()` first."], + ["fenced code with language", "```js\nconst a = 1;\n```"], + ["fenced code containing dashes", "```\n---\nnot frontmatter\n---\n```"], + ["bullet list", "- one\n- two"], + ["star bullet marker", "* item"], + ["plus bullet marker", "+ item"], + ["soft-wrapped blockquote", "> Prose that wraps\n> across two lines."], + ["nested bullet list", "- one\n- two\n - nested"], + ["ordered list", "1. first\n2. second"], + ["task list", "- [ ] todo\n- [x] done"], + ["blockquote", "> quoted text"], + // These two pin the exceptions in `normalizeBlockSeparation`. A blank line + // between same-family blocks is content -- it makes a list loose, and it is + // the only thing keeping two quotes from merging. Both survive the editor + // intact, so collapsing them as "block separation" would hide a real change. + ["loose list", "- one\n\n- two"], + ["adjacent blockquotes", "> first\n\n> second"], + ["link", "See [the docs](https://example.com)."], + ["thematic break", "before\n\n---\n\nafter"], + ["gfm table", "| a | b |\n| --- | --- |\n| 1 | 2 |"], + [ + "gfm table with hand-aligned delimiters", + "| Version | Supported |\n| ------- | --------- |\n| main | Active |", + ], + ["multiple paragraphs", "one\n\ntwo\n\nthree"], + ["strikethrough", "~~gone~~"], + // Differences a reader cannot see. Each of these was measured against a real + // 470-note vault; together they took the pass rate from 4% to 63%. They are + // tolerated by `normalizeForComparison`, not by the editor -- saving still + // rewrites them into canonical form, which is the accepted trade. + ["underscore emphasis", "_emphasis_"], + ["underscore strong", "__strong__"], + ["intraword underscores", "_See weekly_report.py for details._"], + ["snake_case left alone", "Call load_user_profile() first."], + ["single trailing space", "A line with one trailing space. "], + ["list directly under a heading", "## Today\n- one\n- two"], + ["paragraph directly under a heading", "## Today\nSome prose."], + ["several blank lines between paragraphs", "one\n\n\n\ntwo"], + ["star thematic break", "before\n\n***\n\nafter"], + ["underscore thematic break", "before\n\n___\n\nafter"], + ["unpadded table", "|a|b|\n|---|---|\n|1|2|"], + ["double spaces inside a line", "Two spaces between words."], + ["wikilink", "A [[Note Title]] reference."], + ["wikilink with alias", "See [[Note|the note]]."], + ["embed", "![[Some Note]]"], + ["block reference", "A claim. ^block-id"], + ["tag", "A #tag here."], + ["highlight", "==highlight=="], + ["comment", "%%comment%%"], + ["math", "$$x^2$$"], +]; + +/** + * Constructs the editor mangles. These are the reason the guard exists: each + * must be *detected* so the file opens in source mode, never silently + * rewritten. + * + * Tables used to head this list — without the table extensions a GFM table + * serialized down to its concatenated cell text (`| a | b |…` became `ab12`). + * They now round-trip, and moved to MUST_BE_STABLE. + */ +const MUST_BE_DETECTED_LOSSY = [ + ["callout", "> [!info] Title\n> body"], + ["footnote", "Text[^1]\n\n[^1]: note"], + ["raw html", "
raw
"], + ["setext heading", "Title\n====="], + ["four-space nesting", "- a\n - b"], + ["two-space hard break", "line one \nline two"], + ["repeated ordered marker", "1. a\n1. b"], +]; + +test("stable constructs survive the editor untouched", () => { + for (const [label, source] of MUST_BE_STABLE) { + assert.equal( + isRoundTripStable(source, reserializeMarkdown), + true, + `${label} should round-trip cleanly but did not.\n` + + ` in: ${JSON.stringify(source)}\n` + + ` out: ${JSON.stringify(reserializeMarkdown(source))}`, + ); + } +}); + +test("lossy constructs are detected rather than silently rewritten", () => { + for (const [label, source] of MUST_BE_DETECTED_LOSSY) { + assert.equal( + isRoundTripStable(source, reserializeMarkdown), + false, + `${label} round-tripped cleanly — if an extension now handles it, move ` + + `it into MUST_BE_STABLE.`, + ); + } +}); + +test("frontmatter is destroyed by the editor, which is why we split it off", () => { + // Pinning the exact failure mode: the opening `---` becomes a thematic break + // and the YAML becomes a heading. This is the single largest corruption + // source in a real vault. + const raw = "---\ntitle: Note\n---\n\n# Body"; + assert.equal( + isRoundTripStable(raw, reserializeMarkdown), + false, + "raw frontmatter must not be considered safe to live-edit", + ); + + // Split first, and the body alone is perfectly safe. + const { body, frontmatter } = splitFrontmatter(raw); + assert.equal(frontmatter, "---\ntitle: Note\n---\n\n"); + assert.equal( + isRoundTripStable(body, reserializeMarkdown), + true, + "the body below frontmatter should round-trip cleanly", + ); +}); + +test("a realistic note with frontmatter and prose is editable after splitting", () => { + const raw = [ + "---", + "title: Meeting notes", + "tags: [work, buzz]", + "---", + "", + "# Meeting notes", + "", + "- Ship Documents", + "- Then wikilinks", + "", + "Some **bold** and a [link](https://example.com).", + ].join("\n"); + + const { body } = splitFrontmatter(raw); + assert.equal(isRoundTripStable(body, reserializeMarkdown), true); +}); + +test("the probe can be destroyed and lazily rebuilt", () => { + destroyMarkdownProbe(); + assert.equal(isRoundTripStable("# Title", reserializeMarkdown), true); + destroyMarkdownProbe(); +}); diff --git a/desktop/src/features/documents/lib/markdownRoundTrip.ts b/desktop/src/features/documents/lib/markdownRoundTrip.ts new file mode 100644 index 0000000000..2d79807d07 --- /dev/null +++ b/desktop/src/features/documents/lib/markdownRoundTrip.ts @@ -0,0 +1,54 @@ +/** + * A headless TipTap editor used purely to answer "does this markdown survive + * the editor?". + * + * This is the production side of the round-trip guard. It must use the *same* + * extension set the real editor does, or the guard measures the wrong thing — + * so both take their extensions from `vaultEditorExtensions()`. + * + * The instance is created lazily and reused: constructing a ProseMirror editor + * costs real time, and opening a vault checks one note per tab. + */ +import { Editor } from "@tiptap/core"; + +import { vaultEditorExtensions } from "@/features/documents/lib/editor/vaultEditorExtensions"; +import { toDiskMarkdown } from "@/features/documents/lib/markdownEscapes"; + +let probeEditor: Editor | null = null; + +function getProbeEditor(): Editor { + if (!probeEditor) { + probeEditor = new Editor({ + content: "", + extensions: vaultEditorExtensions(), + }); + } + return probeEditor; +} + +/** + * Parses `body` and serializes it straight back, applying the same + * disk-normalization the save path uses so the comparison reflects what would + * actually be written. + */ +export function reserializeMarkdown(body: string): string { + const editor = getProbeEditor(); + editor.commands.setContent(body, { emitUpdate: false }); + const storage = editor.storage as { + markdown?: { getMarkdown?: () => string }; + }; + const output = storage.markdown?.getMarkdown?.(); + if (typeof output !== "string") { + throw new Error("tiptap-markdown storage is unavailable"); + } + return toDiskMarkdown(output); +} + +/** + * Releases the probe editor. Called from the Documents view on unmount so a + * ProseMirror instance and its DOM do not outlive the feature. + */ +export function destroyMarkdownProbe(): void { + probeEditor?.destroy(); + probeEditor = null; +} diff --git a/desktop/src/features/documents/lib/noteIndex.test.mjs b/desktop/src/features/documents/lib/noteIndex.test.mjs new file mode 100644 index 0000000000..9eec32f3de --- /dev/null +++ b/desktop/src/features/documents/lib/noteIndex.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildNoteIndex, normalizeName, resolveWikilink } from "./noteIndex.ts"; + +const VAULT = "/vault"; + +test("normalizeName treats case, extension, dashes and underscores alike", () => { + const canonical = normalizeName("Meeting Notes"); + for (const variant of [ + "meeting notes", + "Meeting-Notes", + "meeting_notes", + "Meeting Notes.md", + "MEETING-NOTES.MARKDOWN", + " meeting notes ", + ]) { + assert.equal(normalizeName(variant), canonical, variant); + } +}); + +test("normalizeName keeps genuinely different names apart", () => { + assert.notEqual(normalizeName("Meeting Notes"), normalizeName("Meetings")); +}); + +test("resolves a unique note by loose name", () => { + const index = buildNoteIndex(VAULT, [`${VAULT}/Notes/Meeting Notes.md`]); + for (const target of ["Meeting Notes", "meeting-notes", "MEETING_NOTES.md"]) { + const resolved = resolveWikilink(target, `${VAULT}/other.md`, index); + assert.deepEqual( + resolved, + { exists: true, path: `${VAULT}/Notes/Meeting Notes.md` }, + target, + ); + } +}); + +test("prefers a note in the same folder as the linking file", () => { + const index = buildNoteIndex(VAULT, [ + `${VAULT}/A/Shared.md`, + `${VAULT}/B/Shared.md`, + ]); + assert.equal( + resolveWikilink("Shared", `${VAULT}/B/other.md`, index).path, + `${VAULT}/B/Shared.md`, + ); + assert.equal( + resolveWikilink("Shared", `${VAULT}/A/other.md`, index).path, + `${VAULT}/A/Shared.md`, + ); +}); + +test("falls back to the shortest path when no sibling matches", () => { + const index = buildNoteIndex(VAULT, [ + `${VAULT}/deep/nested/Shared.md`, + `${VAULT}/Shared.md`, + ]); + assert.equal( + resolveWikilink("Shared", `${VAULT}/elsewhere/other.md`, index).path, + `${VAULT}/Shared.md`, + ); +}); + +test("ties break deterministically rather than by insertion order", () => { + const paths = [`${VAULT}/b/Shared.md`, `${VAULT}/a/Shared.md`]; + const forward = buildNoteIndex(VAULT, paths); + const reversed = buildNoteIndex(VAULT, [...paths].reverse()); + const from = `${VAULT}/z/other.md`; + assert.equal( + resolveWikilink("Shared", from, forward).path, + resolveWikilink("Shared", from, reversed).path, + ); +}); + +test("a target containing a slash resolves as a vault-relative path", () => { + const index = buildNoteIndex(VAULT, [ + `${VAULT}/Notes/Meeting Notes.md`, + `${VAULT}/Archive/Meeting Notes.md`, + ]); + assert.equal( + resolveWikilink("Archive/Meeting Notes", `${VAULT}/x.md`, index).path, + `${VAULT}/Archive/Meeting Notes.md`, + ); +}); + +test("an unresolved target reports the path it would create", () => { + const index = buildNoteIndex(VAULT, [`${VAULT}/Existing.md`]); + const resolved = resolveWikilink("Brand New", `${VAULT}/x.md`, index); + assert.deepEqual(resolved, { exists: false, path: `${VAULT}/Brand New.md` }); + + const nested = resolveWikilink("Folder/Brand New", `${VAULT}/x.md`, index); + assert.deepEqual(nested, { + exists: false, + path: `${VAULT}/Folder/Brand New.md`, + }); +}); + +test("returns null without an index or target", () => { + const index = buildNoteIndex(VAULT, [`${VAULT}/A.md`]); + assert.equal(resolveWikilink("A", `${VAULT}/x.md`, null), null); + assert.equal(resolveWikilink(" ", `${VAULT}/x.md`, index), null); +}); diff --git a/desktop/src/features/documents/lib/noteIndex.ts b/desktop/src/features/documents/lib/noteIndex.ts new file mode 100644 index 0000000000..cda3d23fad --- /dev/null +++ b/desktop/src/features/documents/lib/noteIndex.ts @@ -0,0 +1,125 @@ +/** + * Resolving a wikilink target to a file in the vault. + * + * Obsidian's matching is looser than exact filename equality, and notes rely on + * that: `[[meeting notes]]`, `[[Meeting-Notes]]` and `[[Meeting_Notes.md]]` all + * point at `Meeting Notes.md`. Ported from Onyx's note index. + */ +import { + baseName, + joinPath, + parentOf, + relativeTo, + stripMarkdownExtension, +} from "@/features/documents/lib/treeModel"; + +export type NoteIndex = { + /** Normalized note name → every absolute path that matches it. */ + byName: ReadonlyMap; + /** Vault-relative path (lowercased, no extension) → absolute path. */ + byRelativePath: ReadonlyMap; + vaultRoot: string; +}; + +export type ResolvedWikilink = { + /** Absolute path, or the path the note *would* take if created. */ + path: string; + /** Whether that file exists today. */ + exists: boolean; +}; + +/** + * Obsidian-compatible name normalization: case-insensitive, extension-blind, + * and treating `-`, `_` and space as the same character. + */ +export function normalizeName(name: string): string { + return stripMarkdownExtension(name) + .toLowerCase() + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function buildNoteIndex( + vaultRoot: string, + filePaths: readonly string[], +): NoteIndex { + const byName = new Map(); + const byRelativePath = new Map(); + + for (const path of filePaths) { + const name = normalizeName(baseName(path)); + const existing = byName.get(name); + if (existing) { + existing.push(path); + } else { + byName.set(name, [path]); + } + + const relative = stripMarkdownExtension( + relativeTo(vaultRoot, path), + ).toLowerCase(); + // First writer wins, so resolution is stable rather than dependent on + // filesystem enumeration order. + if (!byRelativePath.has(relative)) { + byRelativePath.set(relative, path); + } + } + + return { byName, byRelativePath, vaultRoot }; +} + +/** + * Resolves `target` against the index. + * + * Priority, matching Obsidian: + * 1. A target containing a slash is a vault-relative path. + * 2. Otherwise match by normalized name. + * 3. On ties, prefer a note in the same folder as the linking file. + * 4. Then prefer the shortest path — the one closest to the vault root. + * + * An unresolved target still returns a path: the file the link *would* create. + * The caller decides whether to offer that, but the link needs somewhere to + * point either way. + */ +export function resolveWikilink( + target: string, + fromPath: string, + index: NoteIndex | null, +): ResolvedWikilink | null { + const trimmed = target.trim(); + if (!index || !trimmed) return null; + + const withoutExtension = stripMarkdownExtension(trimmed); + + if (/[/\\]/.test(withoutExtension)) { + const exact = index.byRelativePath.get(withoutExtension.toLowerCase()); + if (exact) return { exists: true, path: exact }; + return { + exists: false, + path: joinPath(index.vaultRoot, `${withoutExtension}.md`), + }; + } + + const matches = index.byName.get(normalizeName(withoutExtension)); + if (!matches || matches.length === 0) { + return { + exists: false, + path: joinPath(index.vaultRoot, `${withoutExtension}.md`), + }; + } + if (matches.length === 1) { + return { exists: true, path: matches[0] }; + } + + const currentFolder = parentOf(fromPath); + const sameFolder = matches.find((path) => parentOf(path) === currentFolder); + if (sameFolder) return { exists: true, path: sameFolder }; + + // Shortest path wins; ties break alphabetically so the result is stable + // rather than dependent on insertion order. + const sorted = [...matches].sort( + (a, b) => a.length - b.length || a.localeCompare(b), + ); + return { exists: true, path: sorted[0] }; +} diff --git a/desktop/src/features/documents/lib/obsidianSyntax.test.mjs b/desktop/src/features/documents/lib/obsidianSyntax.test.mjs new file mode 100644 index 0000000000..8d5fc65dd6 --- /dev/null +++ b/desktop/src/features/documents/lib/obsidianSyntax.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + activeHeadingIndex, + findBlockId, + findComments, + findHighlights, + findTags, + parseCallout, +} from "./obsidianSyntax.ts"; + +const raws = (matches) => matches.map((m) => m.raw); +const contents = (matches) => matches.map((m) => m.content); + +test("finds highlights", () => { + assert.deepEqual(contents(findHighlights("a ==marked== b")), ["marked"]); + assert.deepEqual(contents(findHighlights("==one== and ==two==")), [ + "one", + "two", + ]); +}); + +test("ignores highlight lookalikes", () => { + // A setext underline and an empty pair are not highlights. + assert.deepEqual(findHighlights("===="), []); + assert.deepEqual(findHighlights("== =="), []); + assert.deepEqual(findHighlights("a = b == c"), []); +}); + +test("finds comments", () => { + assert.deepEqual(contents(findComments("text %%hidden%% more")), ["hidden"]); + assert.deepEqual(findComments("100%% done"), []); +}); + +test("finds tags including nested ones", () => { + assert.deepEqual(raws(findTags("a #work and #work/buzz")), [ + "#work", + "#work/buzz", + ]); + assert.deepEqual(raws(findTags("#start-of-line")), ["#start-of-line"]); +}); + +test("does not mistake headings, colours or fragments for tags", () => { + // A markdown heading has a space after the hashes. + assert.deepEqual(findTags("# Heading"), []); + assert.deepEqual(findTags("### Sub heading"), []); + // A bare hash, and a hex colour, are not tags. + assert.deepEqual(findTags("just # alone"), []); + assert.deepEqual(findTags("colour #123"), []); + // Mid-word hashes (including URL fragments) are left alone. + assert.deepEqual(findTags("see https://x.com/a#frag"), []); + assert.deepEqual(findTags("foo#bar"), []); +}); + +test("parses callouts and their aliases", () => { + assert.deepEqual(parseCallout("> [!info] Heads up"), { + canonical: "info", + title: "Heads up", + type: "info", + }); + // Aliases collapse onto a canonical style. + assert.equal(parseCallout("> [!tldr]").canonical, "summary"); + assert.equal(parseCallout("> [!caution]").canonical, "warning"); + assert.equal(parseCallout("> [!help]").canonical, "question"); + // Case-insensitive, and the fold marker is tolerated. + assert.equal(parseCallout("> [!WARNING]-").canonical, "warning"); + assert.equal(parseCallout("> [!note]+ Title").title, "Title"); +}); + +test("an untitled callout has a null title", () => { + assert.equal(parseCallout("> [!info]").title, null); + assert.equal(parseCallout("> [!info] ").title, null); +}); + +test("an unknown callout type still renders as a callout", () => { + // Obsidian falls back to default styling rather than dropping to a quote. + assert.deepEqual(parseCallout("> [!bogus] x"), { + canonical: "note", + title: "x", + type: "bogus", + }); +}); + +test("a plain blockquote is not a callout", () => { + assert.equal(parseCallout("> just a quote"), null); + assert.equal(parseCallout("not a quote at all"), null); + assert.equal(parseCallout("> [not a callout]"), null); +}); + +test("finds a trailing block-id anchor", () => { + assert.equal(findBlockId("A claim. ^my-block").content, "my-block"); + assert.equal(findBlockId("Trailing space. ^abc123 ").content, "abc123"); +}); + +test("a caret inside a wikilink is not a block anchor", () => { + // `[[Note^id]]` is a block *reference*; the anchor form only ends a line. + assert.equal(findBlockId("See [[Note^id]] here."), null); + assert.equal(findBlockId("See [[Note^id]]"), null); +}); + +test("ignores carets that are not anchors", () => { + assert.equal(findBlockId("2^10 is 1024"), null); + assert.equal(findBlockId("no caret at all"), null); + assert.equal(findBlockId("^leading-only"), null, "needs preceding space"); +}); + +test("scroll-spy picks the last heading at or above the viewport", () => { + const offsets = [0, 100, 250]; + assert.equal(activeHeadingIndex(offsets, 0), 0); + assert.equal(activeHeadingIndex(offsets, 50), 0); + assert.equal(activeHeadingIndex(offsets, 100), 1); + assert.equal(activeHeadingIndex(offsets, 200), 1); + assert.equal(activeHeadingIndex(offsets, 1000), 2); +}); + +test("scroll-spy activates a heading just before it reaches the top", () => { + // The 8px tolerance stops the active item flickering when a heading sits + // exactly on the viewport edge, so it engages slightly early by design. + const offsets = [0, 100]; + assert.equal(activeHeadingIndex(offsets, 91), 0, "outside the tolerance"); + assert.equal(activeHeadingIndex(offsets, 92), 1, "inside the tolerance"); +}); + +test("scroll-spy reports nothing active above the first heading", () => { + assert.equal(activeHeadingIndex([200, 400], 0), -1); + assert.equal(activeHeadingIndex([], 0), -1); +}); diff --git a/desktop/src/features/documents/lib/obsidianSyntax.ts b/desktop/src/features/documents/lib/obsidianSyntax.ts new file mode 100644 index 0000000000..2b19a3c5d0 --- /dev/null +++ b/desktop/src/features/documents/lib/obsidianSyntax.ts @@ -0,0 +1,179 @@ +/** + * Obsidian inline and block syntax that the editor decorates but does not own. + * + * Everything here is recognised by pattern and styled with a ProseMirror + * decoration, never converted into a schema node. That is a deliberate + * constraint, not a shortcut: a real node would have to serialize itself back, + * and any imperfection there means the round-trip guard starts routing + * perfectly good notes into source mode. Decorations leave the text untouched, + * so `==highlight==` on disk is still `==highlight==` after a save. + */ + +export type InlineMatch = { + /** Offset within the searched text. */ + index: number; + /** Full matched text, including delimiters. */ + raw: string; + /** The content between the delimiters. */ + content: string; +}; + +export type CalloutType = { + /** Lowercased type from `> [!type]`. */ + type: string; + /** The canonical type this aliases to. */ + canonical: string; + /** Title text after the marker, if the author supplied one. */ + title: string | null; +}; + +/** + * `==highlight==`. Requires non-space at both inner edges so `== ==` and a + * stray `====` separator are not treated as highlights. + */ +const HIGHLIGHT_PATTERN = /==(?!\s)((?:[^=]|=(?!=))+?)(? = { + abstract: "summary", + attention: "warning", + bug: "bug", + caution: "warning", + check: "success", + cite: "quote", + danger: "danger", + done: "success", + error: "danger", + example: "example", + fail: "failure", + failure: "failure", + faq: "question", + help: "question", + hint: "tip", + important: "tip", + info: "info", + missing: "failure", + note: "note", + question: "question", + quote: "quote", + success: "success", + summary: "summary", + tip: "tip", + todo: "todo", + tldr: "summary", + warning: "warning", +}; + +/** `> [!info] Optional title` — only valid on the first line of a blockquote. */ +const CALLOUT_PATTERN = /^>\s*\[!([A-Za-z]+)\][+-]?\s*(.*)$/; + +/** Parses a callout marker, or returns `null` when the line is not one. */ +export function parseCallout(line: string): CalloutType | null { + const match = CALLOUT_PATTERN.exec(line); + if (!match) return null; + + const type = match[1].toLowerCase(); + const canonical = CALLOUT_ALIASES[type]; + // An unknown type is still a callout — Obsidian renders it with default + // styling rather than as a plain quote. + return { + canonical: canonical ?? "note", + title: match[2].trim() || null, + type, + }; +} + +export type OutlineHeading = { + /** 1-6. */ + level: number; + text: string; + /** ProseMirror document position of the heading node. */ + position: number; +}; + +/** + * Picks the active outline entry for a scroll offset. + * + * The last heading at or above the viewport top wins; before the first + * heading, nothing is active. + */ +export function activeHeadingIndex( + offsets: readonly number[], + scrollTop: number, +): number { + let active = -1; + for (const [index, offset] of offsets.entries()) { + // A small tolerance stops the active item flickering when a heading sits + // exactly on the viewport edge. + if (offset - 8 <= scrollTop) { + active = index; + } else { + break; + } + } + return active; +} diff --git a/desktop/src/features/documents/lib/roundTripGuard.test.mjs b/desktop/src/features/documents/lib/roundTripGuard.test.mjs new file mode 100644 index 0000000000..e5fe5cd9f2 --- /dev/null +++ b/desktop/src/features/documents/lib/roundTripGuard.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + classifyRoundTrip, + initialViewModeFor, + isRoundTripStable, +} from "./roundTripGuard.ts"; + +/** A reserializer that returns its input — the "perfectly faithful" case. */ +const faithful = (body) => body; + +test("identical output is stable", () => { + assert.equal(isRoundTripStable("# Title\n\nBody.", faithful), true); +}); + +test("changed output is lossy", () => { + const mangles = () => "# Different"; + assert.equal(isRoundTripStable("# Title", mangles), false); +}); + +test("a reserializer that throws is treated as lossy, not stable", () => { + // Failing open here would autosave a file we could not even parse. + const explodes = () => { + throw new Error("parse failure"); + }; + assert.equal(isRoundTripStable("# Title", explodes), false); +}); + +test("empty and whitespace-only notes are stable without reserializing", () => { + const explodes = () => { + throw new Error("must not be called"); + }; + for (const body of ["", " ", "\n\n", "\t\n "]) { + assert.equal(isRoundTripStable(body, explodes), true, JSON.stringify(body)); + } +}); + +test("trailing-newline differences are tolerated", () => { + assert.equal( + isRoundTripStable("# Title\n\n", () => "# Title"), + true, + ); + assert.equal( + isRoundTripStable("# Title", () => "# Title\n"), + true, + ); +}); + +test("CRLF vs LF is tolerated", () => { + assert.equal( + isRoundTripStable("# Title\r\n\r\nBody.", () => "# Title\n\nBody."), + true, + ); +}); + +test("interior whitespace changes are NOT tolerated", () => { + // Collapsing a blank line between paragraphs is a real edit to the file. + assert.equal( + isRoundTripStable("para one\n\npara two", () => "para one\npara two"), + false, + ); + // Nor is re-indenting a nested list. + assert.equal( + isRoundTripStable("- a\n - b", () => "- a\n - b"), + false, + ); +}); + +test("classifyRoundTrip maps onto the status vocabulary", () => { + assert.equal(classifyRoundTrip("# Title", faithful), "stable"); + assert.equal( + classifyRoundTrip("# Title", () => "changed"), + "lossy", + ); +}); + +test("only stable notes open in live preview", () => { + assert.equal(initialViewModeFor("stable"), "live"); + assert.equal(initialViewModeFor("lossy"), "source"); + assert.equal(initialViewModeFor("unknown"), "source"); +}); + +test("soft-wrapped prose is tolerated", () => { + // Hard-wrapping at ~80 columns is ubiquitous, and a single newline inside a + // paragraph is a soft break the serializer legitimately joins. Treating that + // as lossy pushed almost every real note into source mode. + const wrapped = "This guide is for agents. It covers\nconventions and setup."; + const joined = "This guide is for agents. It covers conventions and setup."; + assert.equal( + isRoundTripStable(wrapped, () => joined), + true, + ); +}); + +test("soft-wrap tolerance does not mask real losses", () => { + // A dropped link, escaped HTML, or a destroyed table must still be caught + // even though they also change line structure. + assert.equal( + isRoundTripStable("A [`hash`](https://x)", () => "A `hash`"), + false, + ); + assert.equal( + isRoundTripStable( + '

T

', + () => "<h1>T</h1>", + ), + false, + ); + assert.equal( + isRoundTripStable("| a | b |\n| --- | --- |\n| 1 | 2 |", () => "ab12"), + false, + ); +}); + +test("block starts are never joined into the previous line", () => { + // A list following a paragraph must stay a list, not be absorbed into it. + assert.equal( + isRoundTripStable("Intro text\n- item one\n- item two", () => "joined"), + false, + ); + // An explicit two-space hard break is preserved, not treated as soft. + assert.equal( + isRoundTripStable("line one \nline two", () => "line one line two"), + false, + ); +}); + +test("fenced code keeps its line structure", () => { + // Joining lines inside a fence would hide real corruption of code blocks. + const code = "```js\nconst a = 1;\nconst b = 2;\n```"; + assert.equal( + isRoundTripStable(code, () => "```js\nconst a = 1; const b = 2;\n```"), + false, + ); +}); + +test("table delimiter dash counts are cosmetic", () => { + // GFM ignores delimiter width, and the serializer emits a canonical three + // dashes, so a hand-aligned source row must not read as lossy. + assert.equal( + isRoundTripStable( + "| a | b |\n| ------- | --------- |\n| 1 | 2 |", + () => "| a | b |\n| --- | --- |\n| 1 | 2 |", + ), + true, + ); +}); + +test("alignment colons in a delimiter row are preserved", () => { + // Colons change column alignment, so losing one is a real change. + assert.equal( + isRoundTripStable("| a |\n| :--- |\n| 1 |", () => "| a |\n| --- |\n| 1 |"), + false, + ); +}); + +test("soft-wrapped prose inside a blockquote is tolerated", () => { + // Prose wraps inside `>` blocks the same as outside, and the serializer + // joins it the same way. + const wrapped = + "> Grounded in an audit.\n> The app is more complete\n> than assumed."; + const joined = + "> Grounded in an audit. The app is more complete than assumed."; + assert.equal( + isRoundTripStable(wrapped, () => joined), + true, + ); +}); + +test("a callout's line break is NOT joined", () => { + // Load-bearing: a callout's first line is its title. Joining it into the body + // would change the rendered callout, so callouts must keep failing the guard. + const callout = "> [!info] Title\n> body text"; + assert.equal( + isRoundTripStable(callout, () => "> [!info] Title body text"), + false, + ); +}); + +test("bullet marker style is cosmetic, inside and outside quotes", () => { + assert.equal( + isRoundTripStable("* one\n* two", () => "- one\n- two"), + true, + ); + assert.equal( + isRoundTripStable("+ one", () => "- one"), + true, + ); + assert.equal( + isRoundTripStable("> * one\n> * two", () => "> - one\n> - two"), + true, + ); +}); + +test("normalizing markers does not hide a lost list item", () => { + assert.equal( + isRoundTripStable("* one\n* two", () => "- one"), + false, + ); +}); + +test("a lone trailing space is cosmetic, two are a hard break", () => { + // One trailing space is invisible to every renderer, and was the *only* + // difference in a real 64-line note. Two are an explicit line break. + assert.equal( + isRoundTripStable( + "Uploads with blurhash. ", + () => "Uploads with blurhash.", + ), + true, + ); + assert.equal( + isRoundTripStable("line one \nline two", () => "line one\nline two"), + false, + ); +}); + +test("blank lines separating different blocks are cosmetic", () => { + // Writing a list or prose straight under its heading is how every daily-note + // template in the measured vault is written; the serializer adds the blank. + assert.equal( + isRoundTripStable("## Today\n- one", () => "## Today\n\n- one"), + true, + ); + assert.equal( + isRoundTripStable("## Today\nSome prose.", () => "## Today\n\nSome prose."), + true, + ); + // Several blank lines are just vertical whitespace. + assert.equal( + isRoundTripStable("one\n\n\n\ntwo", () => "one\n\ntwo"), + true, + ); +}); + +test("blank lines that carry meaning are NOT collapsed", () => { + // Between two paragraphs, the blank line is the only separator. + assert.equal( + isRoundTripStable("para one\n\npara two", () => "para one\npara two"), + false, + ); + // Between list items it makes the list loose, which renders differently. + assert.equal( + isRoundTripStable("- one\n\n- two", () => "- one\n- two"), + false, + ); + // Between blockquotes it is the only thing preventing a merge. + assert.equal( + isRoundTripStable("> first\n\n> second", () => "> first\n> second"), + false, + ); +}); + +test("thematic break spelling is cosmetic", () => { + for (const source of ["***", "___", "- - -"]) { + assert.equal( + isRoundTripStable( + `before\n\n${source}\n\nafter`, + () => "before\n\n---\n\nafter", + ), + true, + source, + ); + } +}); + +test("underscore emphasis is cosmetic, and respects the intraword rule", () => { + assert.equal( + isRoundTripStable("_emphasis_", () => "*emphasis*"), + true, + ); + assert.equal( + isRoundTripStable("__strong__", () => "**strong**"), + true, + ); + // The underscore inside `weekly_report` is intraword, so it cannot close the + // emphasis opened at the start of the line. Pairing it there would normalize + // to something the parser never produces. + assert.equal( + isRoundTripStable( + "_See weekly_report.py for details._", + () => "*See weekly_report.py for details.*", + ), + true, + ); + // Identifiers are left completely alone. + assert.equal( + isRoundTripStable("Call load_user_profile() now.", (body) => body), + true, + ); +}); + +test("emphasis normalization does not hide dropped emphasis", () => { + assert.equal( + isRoundTripStable("_emphasis_", () => "emphasis"), + false, + ); +}); + +test("table column padding is cosmetic", () => { + assert.equal( + isRoundTripStable( + "|a|b|\n|---|---|\n|1|2|", + () => "| a | b |\n| --- | --- |\n| 1 | 2 |", + ), + true, + ); +}); + +test("runs of spaces inside a line are cosmetic, except in code", () => { + assert.equal( + isRoundTripStable("Two spaces here.", () => "Two spaces here."), + true, + ); + // Inside a fence, run length is content — alignment and indentation matter. + assert.equal( + isRoundTripStable("```\ncol1 col2\n```", () => "```\ncol1 col2\n```"), + false, + ); +}); diff --git a/desktop/src/features/documents/lib/roundTripGuard.ts b/desktop/src/features/documents/lib/roundTripGuard.ts new file mode 100644 index 0000000000..934318aa32 --- /dev/null +++ b/desktop/src/features/documents/lib/roundTripGuard.ts @@ -0,0 +1,424 @@ +/** + * The gate that decides whether a note is safe to edit in live preview. + * + * A WYSIWYG editor autosaving over a real Obsidian vault is a data-destruction + * feature unless proven otherwise: tiptap-markdown is markdown-it plus + * prosemirror-markdown, and anything outside the TipTap schema does not + * survive the trip. Measured against a real vault it will, among other things, + * turn `[[link]]` into `\[\[link\]\]`, normalize bullet markers and list + * indentation, rewrite `_em_` as `*em*`, and drop callouts and footnotes. + * + * Rather than guess which constructs are safe, we simply ask: does + * `serialize(parse(x))` return `x`? If not, the file opens in source mode -- + * a raw textarea that never touches the serializer -- and the user is told + * why. Editing is still possible; silent reformatting is not. + */ + +import { parseCallout } from "@/features/documents/lib/obsidianSyntax"; + +export type RoundTripStatus = "stable" | "lossy" | "unknown"; + +/** Lines that begin a block and must never be joined to the previous one. */ +const BLOCK_START = + /^(?:\s{4,}|\t)|^ {0,3}(?:#{1,6}\s|>|[-*+]\s|\d+[.)]\s|\||`{3,}|~{3,}|---|===|\[\^)/; + +/** + * Joins soft-wrapped paragraph lines, the way a CommonMark serializer does. + * + * Hard-wrapping prose at ~80 columns is extremely common, and a single newline + * inside a paragraph is a *soft* break: it carries no meaning, and the + * serializer legitimately re-emits the paragraph as one line. Comparing raw + * bytes therefore flagged almost every real-world note as lossy — measured at + * 35 of 40 files in this repo — which pushed everything into source mode and + * made live preview pointless. + * + * The join is deliberately conservative. Any line that could begin a block + * (list item, heading, quote, table row, fence, indented code) stops the join, + * and fenced regions are skipped entirely. Failing to join something merely + * routes that file to source mode, which is the safe direction. + */ +/** + * Canonicalises `*` and `+` bullet markers to `-`, including inside + * blockquotes. + * + * All three are the same list in CommonMark; the serializer emits `-`. Treating + * the choice of marker as a content change flagged files that differ only in + * typing habit. + */ +function normalizeBulletMarkers(text: string): string { + return text + .split("\n") + .map((line) => line.replace(/^(\s*(?:>\s*)*)[*+](\s)/, "$1-$2")) + .join("\n"); +} + +/** Two or more trailing spaces: a markdown hard break, and therefore content. */ +const HARD_BREAK_SUFFIX = / {2,}$/; + +/** + * Drops a lone trailing space from each line. + * + * One trailing space is invisible, means nothing to any markdown renderer, and + * is left behind constantly by ordinary typing. The serializer drops it, so + * comparing bytes flagged whole files over a single character — this was the + * only difference in one real 64-line note. + * + * Two or more trailing spaces are a hard break and stay untouched, so a file + * that uses them keeps failing the guard until the editor can represent them. + */ +function stripLoneTrailingSpace(text: string): string { + return text + .split("\n") + .map((line) => + HARD_BREAK_SUFFIX.test(line) ? line : line.replace(/ $/, ""), + ) + .join("\n"); +} + +/** A blockquote line, capturing its `>` prefix and the content after it. */ +const QUOTE_LINE = /^( {0,3}>\s?)(.*)$/; + +/** + * Joins soft-wrapped lines inside a blockquote, unless it is a callout. + * + * Prose inside a `>` block wraps just like prose outside one, and the + * serializer joins it the same way. The exception is load-bearing: an Obsidian + * callout puts its title on the first line and its body on the next, so joining + * those two would change the rendered callout. Callouts must keep failing the + * guard until an extension can round-trip them. + */ +function joinSoftWrappedQuotes(text: string): string { + const lines = text.split("\n"); + const out: string[] = []; + let inCallout = false; + + for (const line of lines) { + const match = QUOTE_LINE.exec(line); + if (!match) { + inCallout = false; + out.push(line); + continue; + } + + const [, , content] = match; + const previous = out.at(-1); + const previousMatch = + previous === undefined ? null : QUOTE_LINE.exec(previous); + + if (!previousMatch) { + // First line of a blockquote decides whether the whole block is a + // callout and therefore off-limits for joining. + inCallout = parseCallout(`> ${content}`) !== null; + out.push(line); + continue; + } + + const canJoin = + !inCallout && + content.trim() !== "" && + previousMatch[2].trim() !== "" && + !BLOCK_START.test(content) && + !BLOCK_START.test(previousMatch[2]) && + !/ {2}$/.test(previousMatch[2]); + + if (canJoin) { + out[out.length - 1] = + `${previousMatch[1]}${previousMatch[2]} ${content.trim()}`; + } else { + out.push(line); + } + } + + return out.join("\n"); +} + +function joinSoftWrappedLines(text: string): string { + const lines = text.split("\n"); + const joined: string[] = []; + let inFence = false; + + for (const line of lines) { + if (/^\s*(?:`{3,}|~{3,})/.test(line)) { + inFence = !inFence; + joined.push(line); + continue; + } + + const previous = joined.at(-1); + const canJoin = + !inFence && + previous !== undefined && + previous.trim() !== "" && + line.trim() !== "" && + !BLOCK_START.test(line) && + !BLOCK_START.test(previous) && + // Two trailing spaces are an explicit hard break; preserve it. + !/ {2}$/.test(previous); + + if (canJoin) { + joined[joined.length - 1] = `${previous} ${line.trim()}`; + } else { + joined.push(line); + } + } + + return joined.join("\n"); +} + +/** A list item at any of the three markers CommonMark allows. */ +const LIST_ITEM = /^ {0,3}(?:[-*+]|\d+[.)])\s/; +/** Any blockquote line, callout or not. */ +const QUOTE_START = /^ {0,3}>/; +/** An opening or closing code fence. */ +const FENCE = /^\s*(?:`{3,}|~{3,})/; + +/** + * Removes blank lines that only separate one block from the next. + * + * Writing a list or a paragraph directly under its heading, with no blank + * line, is an extremely common habit — it is how every daily-note template in + * the vault I measured is written. CommonMark parses it identically either way + * and the serializer always emits the blank line, so byte comparison failed on + * 250+ files over invisible vertical whitespace. Runs of several blank lines + * collapse to one for the same reason. + * + * Two exceptions keep meaning intact: + * + * - Between two list items, a blank line makes the list *loose*, which really + * does render differently (each item gains a `

`). + * - Between two blockquotes, a blank line is the only thing keeping them from + * merging into one quote. + * + * In both cases the blank lines are preserved, so a change there still fails + * the guard. Fenced code is skipped entirely — blank lines are content there. + */ +function normalizeBlockSeparation(text: string): string { + const lines = text.split("\n"); + const out: string[] = []; + let inFence = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (FENCE.test(line)) { + inFence = !inFence; + out.push(line); + continue; + } + if (inFence || line.trim() !== "") { + out.push(line); + continue; + } + + // A run of blank lines. Collapse it, then decide whether one survives. + let end = i; + while (end < lines.length && lines[end].trim() === "") end += 1; + i = end - 1; + + const previous = out.at(-1); + const next = lines[end]; + // Blank lines at either end of the document separate nothing. + if (previous === undefined || next === undefined) continue; + + const betweenBlocks = BLOCK_START.test(previous) || BLOCK_START.test(next); + const sameFamily = + (LIST_ITEM.test(previous) && LIST_ITEM.test(next)) || + (QUOTE_START.test(previous) && QUOTE_START.test(next)); + + // Between two paragraphs a blank line is the only thing keeping them + // apart, so it always survives — collapsed to one, since a longer run is + // just vertical whitespace. + if (!betweenBlocks || sameFamily) out.push(""); + } + + return out.join("\n"); +} + +/** A thematic break in any of its three spellings, e.g. `***`, `- - -`, `___`. */ +const THEMATIC_BREAK = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/; + +/** + * Canonicalises every thematic break to `---`. + * + * `***`, `___` and `---` are the same horizontal rule; the serializer emits + * `---`. This was the second-largest source of failures in the measured vault, + * behind block separation. + */ +function normalizeThematicBreaks(text: string): string { + return text + .split("\n") + .map((line) => (THEMATIC_BREAK.test(line) ? "---" : line)) + .join("\n"); +} + +/** Any pipe-delimited table row, header, delimiter or body. */ +const TABLE_ROW = /^ {0,3}\|.*\|\s*$/; +/** A GFM table delimiter row, e.g. `| :--- | ---: |`. */ +const TABLE_DELIMITER_ROW = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/; + +/** + * Reduces every table row to `|cell|cell|` with cells trimmed. + * + * Column padding is how humans keep a table readable in source form, and dash + * counts in the delimiter row are pure alignment; the serializer discards both + * for a canonical `| --- |`. Neither is visible once rendered. Alignment colons + * are content and survive, because they are part of the cell text being + * trimmed rather than the padding around it. + */ +function normalizeTableRows(text: string): string { + return text + .split("\n") + .map((line) => { + if (!TABLE_ROW.test(line)) return line; + const canonical = TABLE_DELIMITER_ROW.test(line) + ? line.replace(/-{2,}/g, "---") + : line; + return canonical + .trim() + .split("|") + .map((cell) => cell.trim()) + .join("|"); + }) + .join("\n"); +} + +/** + * Rewrites `_em_` as `*em*` and `__strong__` as `**strong**`. + * + * CommonMark treats the two spellings as identical and the serializer emits + * the asterisk form. This was the single largest remaining cause of failures + * in the measured vault. + * + * The lookarounds implement CommonMark's intraword rule: an underscore only + * delimits emphasis when it does not sit between two word characters. Without + * them `_Generated by weekly_report.py_` pairs its first two underscores and + * normalizes to something the parser never produces. `snake_case_name` is left + * alone entirely, which is the same reason. + * + * Emphasis the editor *drops* still fails the guard, since the markers then + * survive on one side only. + */ +const INTRAWORD_UNDERSCORE = /(?<=\w)_(?=\w)/; + +function normalizeEmphasisMarkers(text: string): string { + return text + .replace(/(? { + if (FENCE.test(line)) { + inFence = !inFence; + return line; + } + return inFence ? line : line.replace(/(\S) {2,}(?=\S)/g, "$1 "); + }) + .join("\n"); +} + +/** + * Differences that are cosmetic rather than corrupting, and are normalized away + * before comparison: + * + * - CRLF vs LF. Writing back LF is a real change, but a benign and universal + * one, and refusing to live-edit every file authored on Windows would make + * the guard useless. + * - A trailing newline. Serializers routinely add or drop the final one. + * - Soft-wrapped paragraph lines, per `joinSoftWrappedLines`. + * - Soft-wrapped prose inside non-callout blockquotes. + * - `*` and `+` bullet markers, which mean the same as `-`. + * - A single trailing space, per `stripLoneTrailingSpace`. + * - Blank lines that only separate blocks, per `normalizeBlockSeparation`. + * - `***` and `___` thematic breaks, which mean the same as `---`. + * - Table column padding and delimiter dash counts, per `normalizeTableRows`. + * - `_em_` versus `*em*`, per `normalizeEmphasisMarkers`. + * - Runs of spaces inside a line, per `collapseInnerSpaces`. + * + * The property they share: **a reader cannot see any of them.** Differences a + * reader *would* see — dropped links, escaped HTML, destroyed tables, merged + * callouts, tight versus loose lists, two-space hard breaks — still count as + * lossy and still send the file to source mode. + * + * Each entry is also a promise that saving may rewrite the file that way, which + * shows up in `git diff` even though nothing rendered changed. That is the + * deliberate trade: byte-exact comparison left live preview usable on 4% of a + * real 470-note vault, which is indistinguishable from not shipping it. + */ +function normalizeForComparison(text: string): string { + const base = collapseInnerSpaces( + stripLoneTrailingSpace(text.replace(/\r\n/g, "\n").replace(/\n+$/, "")), + ); + return normalizeEmphasisMarkers( + normalizeTableRows( + normalizeBulletMarkers( + joinSoftWrappedQuotes( + joinSoftWrappedLines( + normalizeBlockSeparation(normalizeThematicBreaks(base)), + ), + ), + ), + ), + ); +} + +/** + * Whether `body` survives a parse/serialize cycle unchanged. + * + * `reserialize` is injected rather than imported so this stays pure and + * unit-testable; production passes the TipTap-backed implementation from + * `markdownRoundTrip.ts`. + */ +export function isRoundTripStable( + body: string, + reserialize: (body: string) => string, +): boolean { + // An empty (or whitespace-only) note has nothing to corrupt. Serializers + // disagree about what empty output looks like, so short-circuit. + if (body.trim() === "") return true; + + let output: string; + try { + output = reserialize(body); + } catch { + // If we cannot even round-trip it, we certainly cannot autosave it. + return false; + } + + return normalizeForComparison(output) === normalizeForComparison(body); +} + +export function classifyRoundTrip( + body: string, + reserialize: (body: string) => string, +): RoundTripStatus { + return isRoundTripStable(body, reserialize) ? "stable" : "lossy"; +} + +/** + * The view mode a freshly-opened note should use. + * + * Lossy notes open in source mode. The user can still switch to live preview + * deliberately — the guard informs the default, it does not forbid the choice. + */ +export function initialViewModeFor(status: RoundTripStatus): "live" | "source" { + return status === "stable" ? "live" : "source"; +} diff --git a/desktop/src/features/documents/lib/treeModel.test.mjs b/desktop/src/features/documents/lib/treeModel.test.mjs new file mode 100644 index 0000000000..c035bf32aa --- /dev/null +++ b/desktop/src/features/documents/lib/treeModel.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + ancestorFolderPaths, + baseName, + canMoveInto, + collectFilePaths, + findEntry, + flattenVisibleRows, + joinPath, + parentOf, + relativeTo, + stripMarkdownExtension, +} from "./treeModel.ts"; + +/** /Notes/{plain.md,Deep/buried.md} plus /top.md */ +function fixture() { + return [ + { + name: "Notes", + path: "/vault/Notes", + isDirectory: true, + children: [ + { + name: "Deep", + path: "/vault/Notes/Deep", + isDirectory: true, + children: [ + { + name: "buried.md", + path: "/vault/Notes/Deep/buried.md", + isDirectory: false, + children: null, + }, + ], + }, + { + name: "plain.md", + path: "/vault/Notes/plain.md", + isDirectory: false, + children: null, + }, + ], + }, + { + name: "top.md", + path: "/vault/top.md", + isDirectory: false, + children: null, + }, + ]; +} + +test("strips both markdown extensions, case-insensitively", () => { + assert.equal(stripMarkdownExtension("plain.md"), "plain"); + assert.equal(stripMarkdownExtension("legacy.MARKDOWN"), "legacy"); + assert.equal(stripMarkdownExtension("no-extension"), "no-extension"); + assert.equal(stripMarkdownExtension("dotted.name.md"), "dotted.name"); +}); + +test("baseName and parentOf handle both separators and trailing slashes", () => { + assert.equal(baseName("/vault/Notes/plain.md"), "plain.md"); + assert.equal(baseName("/vault/Notes/"), "Notes"); + assert.equal(baseName("C:\\vault\\Notes\\plain.md"), "plain.md"); + assert.equal(parentOf("/vault/Notes/plain.md"), "/vault/Notes"); + assert.equal(parentOf("C:\\vault\\Notes\\plain.md"), "C:\\vault\\Notes"); + assert.equal(parentOf("/top.md"), "/"); +}); + +test("joinPath preserves the parent's separator", () => { + assert.equal(joinPath("/vault/Notes", "new.md"), "/vault/Notes/new.md"); + assert.equal(joinPath("/vault/Notes/", "new.md"), "/vault/Notes/new.md"); + assert.equal(joinPath("C:\\vault", "new.md"), "C:\\vault\\new.md"); +}); + +test("relativeTo strips the root, and passes through outside paths", () => { + assert.equal(relativeTo("/vault", "/vault/Notes/plain.md"), "Notes/plain.md"); + assert.equal(relativeTo("/vault/", "/vault/top.md"), "top.md"); + assert.equal(relativeTo("/vault", "/elsewhere/x.md"), "/elsewhere/x.md"); +}); + +test("flattenVisibleRows omits the subtrees of collapsed folders", () => { + const tree = fixture(); + + const collapsed = flattenVisibleRows(tree, new Set()); + assert.deepEqual( + collapsed.map((row) => row.entry.path), + ["/vault/Notes", "/vault/top.md"], + ); + + const oneOpen = flattenVisibleRows(tree, new Set(["/vault/Notes"])); + assert.deepEqual( + oneOpen.map((row) => row.entry.path), + [ + "/vault/Notes", + "/vault/Notes/Deep", + "/vault/Notes/plain.md", + "/vault/top.md", + ], + ); + + const bothOpen = flattenVisibleRows( + tree, + new Set(["/vault/Notes", "/vault/Notes/Deep"]), + ); + assert.deepEqual( + bothOpen.map((row) => row.entry.path), + [ + "/vault/Notes", + "/vault/Notes/Deep", + "/vault/Notes/Deep/buried.md", + "/vault/Notes/plain.md", + "/vault/top.md", + ], + ); + assert.deepEqual( + bothOpen.map((row) => row.depth), + [0, 1, 2, 1, 0], + ); +}); + +test("ancestorFolderPaths lists the folders to expand to reveal a file", () => { + assert.deepEqual( + ancestorFolderPaths("/vault", "/vault/Notes/Deep/buried.md"), + ["/vault/Notes", "/vault/Notes/Deep"], + ); + assert.deepEqual(ancestorFolderPaths("/vault", "/vault/top.md"), []); +}); + +test("collectFilePaths walks depth-first and skips directories", () => { + assert.deepEqual(collectFilePaths(fixture()), [ + "/vault/Notes/Deep/buried.md", + "/vault/Notes/plain.md", + "/vault/top.md", + ]); +}); + +test("findEntry locates nested entries and reports misses", () => { + const tree = fixture(); + assert.equal( + findEntry(tree, "/vault/Notes/Deep/buried.md")?.name, + "buried.md", + ); + assert.equal(findEntry(tree, "/vault/Notes")?.isDirectory, true); + assert.equal(findEntry(tree, "/vault/missing.md"), null); +}); + +test("canMoveInto rejects no-ops and moves into a folder's own subtree", () => { + // Moving a folder into itself or a descendant would orphan it. + assert.equal(canMoveInto("/vault/Notes", "/vault/Notes"), false); + assert.equal(canMoveInto("/vault/Notes", "/vault/Notes/Deep"), false); + // Already in the destination — nothing to do. + assert.equal(canMoveInto("/vault/Notes/plain.md", "/vault/Notes"), false); + // Legitimate moves. + assert.equal(canMoveInto("/vault/Notes/plain.md", "/vault"), true); + assert.equal(canMoveInto("/vault/Notes/Deep", "/vault"), true); + // A sibling that merely shares a name prefix is not a descendant. + assert.equal(canMoveInto("/vault/Notes", "/vault/Notes-archive"), true); +}); diff --git a/desktop/src/features/documents/lib/treeModel.ts b/desktop/src/features/documents/lib/treeModel.ts new file mode 100644 index 0000000000..ceb29e6345 --- /dev/null +++ b/desktop/src/features/documents/lib/treeModel.ts @@ -0,0 +1,149 @@ +/** + * Pure helpers for the Documents file tree. + * + * Everything here is filesystem-free and framework-free so it can be unit + * tested without Tauri. + */ +import type { VaultEntry } from "@/shared/api/vaultTypes"; + +/** One rendered row: a tree node plus where it sits. */ +export type VaultTreeRow = { + entry: VaultEntry; + depth: number; + isExpanded: boolean; +}; + +/** Strips a trailing `.md` / `.markdown`, for display and wikilink matching. */ +export function stripMarkdownExtension(name: string): string { + return name.replace(/\.(?:md|markdown)$/i, ""); +} + +/** The path separator used by `path`, defaulting to `/`. */ +function separatorFor(path: string): string { + return path.includes("\\") && !path.includes("/") ? "\\" : "/"; +} + +/** The final segment of a path. */ +export function baseName(path: string): string { + const normalized = path.replace(/[/\\]+$/, ""); + const index = Math.max( + normalized.lastIndexOf("/"), + normalized.lastIndexOf("\\"), + ); + return index === -1 ? normalized : normalized.slice(index + 1); +} + +/** The containing directory of a path, or `""` when there is none. */ +export function parentOf(path: string): string { + const normalized = path.replace(/[/\\]+$/, ""); + const index = Math.max( + normalized.lastIndexOf("/"), + normalized.lastIndexOf("\\"), + ); + if (index <= 0) return index === 0 ? "/" : ""; + return normalized.slice(0, index); +} + +/** Joins a directory and a child segment using the parent's separator. */ +export function joinPath(directory: string, segment: string): string { + const separator = separatorFor(directory); + const trimmed = directory.replace(/[/\\]+$/, ""); + return `${trimmed}${separator}${segment}`; +} + +/** `path` relative to `root`, or `path` unchanged when it sits outside. */ +export function relativeTo(root: string, path: string): string { + const trimmedRoot = root.replace(/[/\\]+$/, ""); + if (!path.startsWith(trimmedRoot)) return path; + return path.slice(trimmedRoot.length).replace(/^[/\\]+/, ""); +} + +/** + * Flattens the tree to the rows that are actually visible. + * + * Collapsed folders contribute a row but not their subtree, so a 10k-note vault + * renders only what is on screen. Onyx rendered the whole tree recursively. + */ +export function flattenVisibleRows( + entries: VaultEntry[], + expandedPaths: ReadonlySet, + depth = 0, +): VaultTreeRow[] { + const rows: VaultTreeRow[] = []; + for (const entry of entries) { + const isExpanded = entry.isDirectory && expandedPaths.has(entry.path); + rows.push({ depth, entry, isExpanded }); + if (isExpanded && entry.children) { + rows.push( + ...flattenVisibleRows(entry.children, expandedPaths, depth + 1), + ); + } + } + return rows; +} + +/** Every folder path on the way from `root` down to `path`, exclusive of `path`. */ +export function ancestorFolderPaths(root: string, path: string): string[] { + const relative = relativeTo(root, path); + if (!relative || relative === path) return []; + const segments = relative.split(/[/\\]+/).filter(Boolean); + segments.pop(); + + const ancestors: string[] = []; + let current = root.replace(/[/\\]+$/, ""); + for (const segment of segments) { + current = joinPath(current, segment); + ancestors.push(current); + } + return ancestors; +} + +/** Depth-first walk yielding every file entry (directories excluded). */ +export function collectFilePaths(entries: VaultEntry[]): string[] { + const paths: string[] = []; + const walk = (nodes: VaultEntry[]) => { + for (const node of nodes) { + if (node.isDirectory) { + if (node.children) walk(node.children); + } else { + paths.push(node.path); + } + } + }; + walk(entries); + return paths; +} + +/** Finds an entry by exact path. */ +export function findEntry( + entries: VaultEntry[], + path: string, +): VaultEntry | null { + for (const entry of entries) { + if (entry.path === path) return entry; + if (entry.children) { + const found = findEntry(entry.children, path); + if (found) return found; + } + } + return null; +} + +/** + * Whether moving `sourcePath` to sit inside `destinationDir` is legal. + * + * Rejects a no-op (already there) and a move into the source's own subtree, + * which would otherwise orphan the folder. The Rust side re-checks the second + * case; this exists so the UI can refuse the drop rather than round-trip. + */ +export function canMoveInto( + sourcePath: string, + destinationDir: string, +): boolean { + if (sourcePath === destinationDir) return false; + if (parentOf(sourcePath) === destinationDir) return false; + + const sourcePrefix = `${sourcePath.replace(/[/\\]+$/, "")}/`; + const normalizedDestination = `${destinationDir.replace(/[/\\]+$/, "")}/`; + return !normalizedDestination.startsWith(sourcePrefix); +} diff --git a/desktop/src/features/documents/lib/wikilinkSyntax.test.mjs b/desktop/src/features/documents/lib/wikilinkSyntax.test.mjs new file mode 100644 index 0000000000..5b53c762a2 --- /dev/null +++ b/desktop/src/features/documents/lib/wikilinkSyntax.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + extractLinkTargets, + parseWikilinks, + wikilinkDisplayText, +} from "./wikilinkSyntax.ts"; + +/** Compact shape for assertions. */ +function shape(link) { + return [link.target, link.heading, link.blockId, link.alias]; +} + +test("parses all six Obsidian wikilink forms", () => { + const cases = [ + ["[[Note]]", ["Note", null, null, null]], + ["[[Note|alias]]", ["Note", null, null, "alias"]], + ["[[Note#Heading]]", ["Note", "Heading", null, null]], + ["[[Note#Heading|alias]]", ["Note", "Heading", null, "alias"]], + ["[[Note^blockid]]", ["Note", null, "blockid", null]], + ["[[Note#^blockid]]", ["Note", null, "blockid", null]], + ]; + for (const [source, expected] of cases) { + const [link] = parseWikilinks(source); + assert.ok(link, `${source} did not parse`); + assert.deepEqual(shape(link), expected, source); + } +}); + +test("parses same-note anchors with an empty target", () => { + assert.deepEqual(shape(parseWikilinks("[[#Heading]]")[0]), [ + "", + "Heading", + null, + null, + ]); + assert.deepEqual(shape(parseWikilinks("[[^blockid]]")[0]), [ + "", + null, + "blockid", + null, + ]); +}); + +test("#^ is read as a block reference, not a heading named ^id", () => { + // Ordering bug bait: matching bare `#` first would capture "^blockid". + const [link] = parseWikilinks("[[Note#^blockid]]"); + assert.equal(link.blockId, "blockid"); + assert.equal(link.heading, null); +}); + +test("tolerates the escaped brackets the serializer emits", () => { + // prosemirror-markdown escapes `[` and `]` in text nodes, so a link read + // back out of the editor arrives looking like this. + const [link] = parseWikilinks("A \\[\\[Note Title\\]\\] reference."); + assert.deepEqual(shape(link), ["Note Title", null, null, null]); + + const [aliased] = parseWikilinks( + "\\[\\[Note\\|alias\\]\\]".replace("\\|", "|"), + ); + assert.equal(aliased.target, "Note"); +}); + +test("does not match embeds", () => { + // `![[...]]` is transclusion, a different construct. + assert.deepEqual(parseWikilinks("![[Some Note]]"), []); + assert.deepEqual(parseWikilinks("!\\[\\[Some Note\\]\\]"), []); + // But an embed adjacent to a real link must not suppress the link. + const links = parseWikilinks("![[Embed]] and [[Real]]"); + assert.deepEqual( + links.map((link) => link.target), + ["Real"], + ); +}); + +test("finds several links in one line and reports their offsets", () => { + const source = "See [[One]] and [[Two|second]]."; + const links = parseWikilinks(source); + assert.deepEqual( + links.map((link) => link.target), + ["One", "Two"], + ); + assert.equal( + source.slice(links[0].index, links[0].index + links[0].raw.length), + "[[One]]", + ); + assert.equal(links[1].alias, "second"); +}); + +test("does not span newlines", () => { + // An unclosed `[[` must not swallow the rest of the document. + assert.deepEqual( + parseWikilinks("[[Unclosed\n\nOther [[Real]]").map((l) => l.target), + ["Real"], + ); +}); + +test("ignores a link with no destination at all", () => { + assert.deepEqual(parseWikilinks("[[]]"), []); + assert.deepEqual(parseWikilinks("[[ ]]"), []); +}); + +test("trims incidental whitespace inside the brackets", () => { + const [link] = parseWikilinks("[[ Note Title | alias ]]"); + assert.equal(link.target, "Note Title"); + assert.equal(link.alias, "alias"); +}); + +test("extractLinkTargets dedupes and drops same-note anchors", () => { + const source = "[[One]] [[One]] [[Two]] [[#Heading]] [[^block]]"; + // A note must not appear to link to itself just for having anchors. + assert.deepEqual(extractLinkTargets(source), ["One", "Two"]); +}); + +test("repeated calls do not leak regex state", () => { + // A shared global-flagged regex would return [] on the second call. + const source = "[[One]] [[Two]]"; + assert.equal(parseWikilinks(source).length, 2); + assert.equal(parseWikilinks(source).length, 2); +}); + +test("display text prefers the alias, then target and heading", () => { + assert.equal( + wikilinkDisplayText(parseWikilinks("[[Note|shown]]")[0]), + "shown", + ); + assert.equal(wikilinkDisplayText(parseWikilinks("[[Note]]")[0]), "Note"); + assert.equal( + wikilinkDisplayText(parseWikilinks("[[Note#Heading]]")[0]), + "Note › Heading", + ); + assert.equal( + wikilinkDisplayText(parseWikilinks("[[#Heading]]")[0]), + "Heading", + ); + assert.equal(wikilinkDisplayText(parseWikilinks("[[^block]]")[0]), "block"); +}); diff --git a/desktop/src/features/documents/lib/wikilinkSyntax.ts b/desktop/src/features/documents/lib/wikilinkSyntax.ts new file mode 100644 index 0000000000..35f7eebdcb --- /dev/null +++ b/desktop/src/features/documents/lib/wikilinkSyntax.ts @@ -0,0 +1,107 @@ +/** + * Obsidian wikilink syntax — the single owner. + * + * Onyx carries two different regexes for this: one in the editor plugin that + * splits `[[Note#Heading]]` into a target and a heading, and one in the note + * index that captures `Note#Heading` whole. They disagree, so the editor + * renders a link the graph never records and the backlink silently goes + * missing. Everything here parses through one pattern. + */ + +export type Wikilink = { + /** Note name, or `""` for a same-note anchor like `[[#Heading]]`. */ + target: string; + /** Heading anchor without the `#`, or `null`. */ + heading: string | null; + /** Block id without the `^`, or `null`. */ + blockId: string | null; + /** Display text after `|`, or `null`. */ + alias: string | null; + /** The matched source text, including brackets and any escaping. */ + raw: string; + /** Offset of the match within the searched string. */ + index: number; +}; + +/** + * Matches every wikilink form Obsidian accepts: + * + * [[Note]] [[Note|alias]] + * [[Note#Heading]] [[Note#Heading|alias]] + * [[Note^blockid]] [[Note#^blockid]] + * [[#Heading]] [[^blockid]] (same-note anchors) + * + * Two details that are easy to get wrong: + * + * - `\[\[…\]\]` is tolerated. prosemirror-markdown escapes brackets in text + * nodes, so a link read back out of the serializer arrives escaped. + * - Embeds (`![[…]]`) are excluded, being a different construct. That needs + * *two* lookbehinds: `(?(); + for (const link of parseWikilinks(text)) { + if (link.target) seen.add(link.target); + } + return [...seen]; +} + +/** How a wikilink should be displayed. */ +export function wikilinkDisplayText(link: Wikilink): string { + if (link.alias) return link.alias; + if (link.target && link.heading) return `${link.target} › ${link.heading}`; + if (link.target) return link.target; + if (link.heading) return link.heading; + return link.blockId ?? ""; +} diff --git a/desktop/src/features/documents/ui/DocumentBacklinksPanel.tsx b/desktop/src/features/documents/ui/DocumentBacklinksPanel.tsx new file mode 100644 index 0000000000..8f2359ad5a --- /dev/null +++ b/desktop/src/features/documents/ui/DocumentBacklinksPanel.tsx @@ -0,0 +1,134 @@ +import * as React from "react"; +import { ChevronDown, ChevronRight, Link2, Link2Off } from "lucide-react"; + +import type { Backlinks, Mention } from "@/features/documents/lib/backlinks"; +import { groupMentionsBySource } from "@/features/documents/lib/backlinks"; + +function MentionGroup({ + mentions, + onOpen, + sourceName, + sourcePath, +}: { + mentions: Mention[]; + onOpen: (path: string) => void; + sourceName: string; + sourcePath: string; +}) { + return ( +

  • + +
      + {mentions.map((mention) => ( +
    • + +
    • + ))} +
    +
  • + ); +} + +function Section({ + emptyLabel, + icon, + mentions, + onOpen, + testId, + title, +}: { + emptyLabel: string; + icon: React.ReactNode; + mentions: Mention[]; + onOpen: (path: string) => void; + testId: string; + title: string; +}) { + const groups = groupMentionsBySource(mentions); + const [collapsed, setCollapsed] = React.useState(false); + const Chevron = collapsed ? ChevronRight : ChevronDown; + + return ( +
    + + {collapsed ? null : groups.length === 0 ? ( +

    {emptyLabel}

    + ) : ( +
      + {groups.map((group) => ( + + ))} +
    + )} +
    + ); +} + +/** + * Linked and unlinked mentions of the open note. + * + * Unlinked mentions are kept in their own section rather than mixed in: they + * are a suggestion ("you named this note but did not link it"), not a fact + * about the graph. + */ +export function DocumentBacklinksPanel({ + backlinks, + onOpen, +}: { + backlinks: Backlinks; + onOpen: (path: string) => void; +}) { + return ( +
    +
    } + mentions={backlinks.linked} + onOpen={onOpen} + testId="documents-linked-mentions" + title="Linked mentions" + /> +
    } + mentions={backlinks.unlinked} + onOpen={onOpen} + testId="documents-unlinked-mentions" + title="Unlinked mentions" + /> +
    + ); +} diff --git a/desktop/src/features/documents/ui/DocumentEditorPane.tsx b/desktop/src/features/documents/ui/DocumentEditorPane.tsx new file mode 100644 index 0000000000..a0127eee71 --- /dev/null +++ b/desktop/src/features/documents/ui/DocumentEditorPane.tsx @@ -0,0 +1,299 @@ +import * as React from "react"; +import { EditorContent } from "@tiptap/react"; +import { Eye, FileCode2, TriangleAlert } from "lucide-react"; + +import type { DocumentTab } from "@/features/documents/lib/documentTabs"; +import { useVaultEditor } from "@/features/documents/lib/editor/useVaultEditor"; +import type { WikilinkClickHandler } from "@/features/documents/lib/editor/wikilinkExtension"; +import type { NoteIndex } from "@/features/documents/lib/noteIndex"; +import { useAlwaysLivePreview } from "@/features/documents/useDocumentsPreferences"; +import { + activeHeadingIndex, + type OutlineHeading, +} from "@/features/documents/lib/obsidianSyntax"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +/** + * Notes that this file will be reformatted if edited in live preview. + * + * Deliberately one short line with no action button: the mode toggle sits + * immediately below in the header, and having both said "live preview" read as + * two competing controls for the same thing. + */ +function RoundTripNotice() { + return ( +

    + + Live preview would reformat this file — it uses markdown the editor does + not yet support. +

    + ); +} + +/** Offers a choice when a dirty file changed underneath the user. */ +function ExternalChangeBanner({ + onKeepMine, + onReload, +}: { + onKeepMine: () => void; + onReload: () => void; +}) { + return ( +
    + +

    + This file changed on disk while you had unsaved edits. +

    + + +
    + ); +} + +/** + * Source mode: a plain textarea over the note body. + * + * Deliberately never touches the markdown serializer — what the user types is + * exactly what is written. This is the escape hatch that makes the round-trip + * guard acceptable rather than merely restrictive. + */ +function DocumentSourceEditor({ + onChange, + onSave, + tab, +}: { + onChange: (markdown: string) => void; + onSave: () => void; + tab: DocumentTab; +}) { + return ( +