diff --git a/CHANGELOG.md b/CHANGELOG.md index b14398b..da6f85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-06-01 +- Support symlinked markdown files and folders inside workspaces. Symlinked `.md` files and folders containing markdown now appear in the sidebar and workspace search using their visible workspace paths, symlink cycles are skipped safely, saves through symlinked files update the target without replacing the link, and watcher events from symlink targets (including symlinks created after opening the workspace) are translated back to the link path. - Stabilize scrolling through documents with folded Markdown tables. Table preview widgets now provide CodeMirror with a deterministic height estimate before they enter the measured viewport, so the editor no longer suddenly changes document height, scrollbar thumb size, or scroll position as tables virtualize in. - Fix opening a folder or file from the CLI, Finder, or by dropping onto the dock so it lands in a single window instead of spawning a duplicate empty one on cold start. On macOS the open target arrives via the system open event (not argv), which previously raced window creation; it now seeds the main window's startup open target while that window is still unhydrated, and opens a new window only when the app is already running with a different workspace. - Make the startup open target a single source of truth (`startup_open`) that Rust sets before the webview loads and the client reads once during startup hydration — same lifecycle as settings — so an explicit open no longer restores the previous session's tabs alongside the requested file. Runtime drag-and-drop onto a live window still uses the separate pending-open queue. diff --git a/SPECs/Agent/worksheet-symlink-support.md b/SPECs/Agent/worksheet-symlink-support.md new file mode 100644 index 0000000..44237ff --- /dev/null +++ b/SPECs/Agent/worksheet-symlink-support.md @@ -0,0 +1,65 @@ +# Worksheet: Symlink support + +## Task + +TODO: Symlink support — [`SPECs/symlink-support-spec.md`](../symlink-support-spec.md). Add support for symlinked markdown files/folders in the sidebar and search index, preserve symlinks on save, and translate watcher events for symlink targets back to visible workspace paths. + +## Workspace state + +- Branch: `feature/symlink` +- Initial git state: clean (`## feature/symlink...origin/master`). +- Baseline validation: `cd apps/desktop/src-tauri && cargo test` passed (108 tests; existing dead-code warnings only). + +## Docs and code reviewed + +- `TODOS.md` +- `docs/workflows/agent-loop.md` +- `docs/consolidation.md` +- `SPECs/external-file-watcher-spec.md` (prior canonical-path and watcher assumptions) +- Context7 docs for `ignore::WalkBuilder::follow_links(true)` and symlink-loop handling +- Context7/local source docs for `notify` watcher behavior; `notify` 7 FSEvents canonicalizes watched paths +- `apps/desktop/src-tauri/src/commands/fs.rs` +- `apps/desktop/src-tauri/src/commands/search.rs` +- `apps/desktop/src-tauri/src/watcher.rs` +- `apps/desktop/src-tauri/src/ignore.rs` +- `apps/desktop/src-tauri/src/state.rs` +- `apps/desktop/src/hooks/use-file-watcher.ts` +- `apps/desktop/src/stores/workspace-store.ts` + +## Plan + +1. Add a small Rust `symlink` helper module as the single source of truth for: + - symlink-aware write target resolution + - workspace symlink alias collection + - watcher event path translation from canonical targets to visible link paths +2. Update sidebar directory reads to classify entries using symlink-following metadata and make recursive markdown checks cycle-safe with canonical-directory visited tracking. +3. Update workspace indexing to follow symlinks via `ignore::WalkBuilder::follow_links(true)` and add tests for symlinked files/folders and cycles. +4. Update writes to preserve symlink files by atomically replacing the canonical target, not the link path; record both link and target for self-write suppression. +5. Update watcher startup to collect aliases, best-effort watch outside-root symlink targets, dynamically sync aliases on membership changes, and process translated logical paths only. +6. Update `WorkspaceIgnore::load` to follow symlinked directories so sidebar fallback filtering stays aligned with the indexer. +7. Add Rust tests and run Rust validation. + +## Risks / tradeoffs + +- Following symlinked folders can index large external trees if the user links them into a workspace; this is the expected behavior for supporting symlinked folders. +- On platforms where notify already reports symlink-path events, translation dedupes logical paths and should be harmless. + +## Implementation summary + +- Added `apps/desktop/src-tauri/src/symlink.rs` for symlink-following metadata, write-target resolution, alias collection, and watcher path translation. +- Updated sidebar reads to follow symlink targets for classification and to use canonical visited-directory tracking for recursive markdown checks. +- Updated search indexing and workspace ignore loading to follow symlinked directories while relying on `ignore` loop detection. +- Updated saves to replace a symlink target atomically rather than replacing the symlink; write echo suppression records both the visible link path and target path. +- Updated watcher startup to collect symlink aliases, best-effort watch outside-root targets, dynamically add target watches when new symlinks appear, remove stale alias translations when symlinks disappear, and emit/index only logical workspace paths. +- Updated direct markdown-file open-target handling so final-path symlinks stay keyed by the visible link path. +- Added Rust unit coverage for symlinked files/folders, symlink cycles, write-through preservation, ignore rules inside symlinked folders, open-target paths, watcher alias translation, dynamic alias sync, and subtree indexing through links. + +## Validation + +- `vp install` — completed; lockfile unchanged. +- `vp check` — completed with existing E2E JS lint/type warnings (`no-floating-promises` in `apps/desktop/e2e/specs/smoke.spec.js`, redundant type constituent in `apps/desktop/e2e/wdio.conf.js`). +- `vp test` — passed (27 files, 438 tests). +- `cd apps/desktop/src-tauri && cargo test symlink` — passed (20 tests) after dynamic watcher sync follow-up. +- `cd apps/desktop/src-tauri && cargo test` — passed (123 tests). +- `cd apps/desktop/src-tauri && cargo clippy` — completed with existing warnings in `search.rs`, `config.rs`, and `images.rs`. +- `cd apps/desktop/src-tauri && cargo fmt --check` — passed. diff --git a/SPECs/symlink-support-spec.md b/SPECs/symlink-support-spec.md new file mode 100644 index 0000000..ce26ea3 --- /dev/null +++ b/SPECs/symlink-support-spec.md @@ -0,0 +1,60 @@ +# Symlink Support Spec + +## Summary + +Writer currently ignores symlinked markdown files and symlinked folders in workspace listings and search indexing because the sidebar uses `DirEntry::file_type()` and the index walker does not follow links. Saving through a symlinked markdown file can also replace the symlink with a regular file because the atomic write renames the temp file over the link path. + +Support symlinked markdown entries as first-class workspace entries while keeping Writer's visible paths workspace-relative to the symlink location. + +## Goals + +- Show symlinked markdown files in the sidebar when the symlink path has a `.md` extension. +- Show symlinked folders when their target tree contains visible markdown files. +- Include markdown files under symlinked folders in the workspace search index, using the symlink path as the indexed path and relative path. +- Avoid infinite recursion for symlink cycles. +- Preserve a symlink when saving a symlinked markdown file; write the target file instead of replacing the link. +- Reflect external changes under symlink targets where the platform watcher can observe them, translating target paths back to workspace symlink paths. +- Start watching targets for symlinks created after the workspace is already open. +- Keep a symlinked markdown file opened directly from drag/drop or the CLI keyed by its visible file path when its parent workspace is not itself a symlink. + +## Non-goals + +- Editing or rendering broken symlinks. Broken links stay hidden from the markdown-only sidebar. +- Showing symlinks to non-markdown files. +- Adding a frontend symlink badge or separate symlink type in `DirEntry`. + +## Design + +### Sidebar listing + +Use symlink-following metadata for entry classification in `read_directory_impl` and recursive markdown checks. Keep the visible path as the symlink path (`entry.path()`), not the canonical target path. Recursive markdown detection tracks canonicalized directory targets in a visited set so loops such as `loop -> .` terminate. + +### Search indexing + +Enable `ignore::WalkBuilder::follow_links(true)` in the workspace indexer. The `ignore` walker keeps reported paths at the symlink location and performs loop detection, so search results and `dirs_with_markdown` can continue using visible workspace paths. + +### Open targets and saving + +For direct markdown-file open targets, keep a final-path symlink in the visible namespace by combining the canonical parent workspace path with the symlink filename. Symlinked workspace roots still canonicalize to their target, preserving the existing duplicate-window/session behavior. + +For `write_file_impl`, detect when the requested path is a symlink and resolve it to its canonical target before creating the temp file and renaming. Return the original path in the IPC payload so frontend state remains keyed by the workspace-visible symlink path. + +Record both the visible symlink path and the canonical target path as self-writes so watcher echoes are suppressed regardless of which path the OS reports. + +### Watcher path translation + +On watcher startup, collect symlink aliases visible under the workspace and add best-effort watches for targets outside the canonical workspace root. Membership-change events also sync the alias map for the changed path, so a newly-created symlink starts watching its target immediately and a removed/replaced symlink stops translating target events back to the old link path. When an event arrives, process only logical workspace paths: + +- the raw event path if it is already under the workspace root +- each symlink path produced by mapping an event under an alias target back to the alias link path + +This keeps frontend cache keys, open-file keys, and search index paths in the symlink namespace the user sees. + +## Test plan + +- Rust unit tests for sidebar inclusion of symlinked markdown files and folders. +- Rust unit tests for write-through preserving the symlink inode. +- Rust unit tests for index traversal through symlinked files/folders and loop avoidance. +- Rust unit tests for watcher alias collection/path translation and dynamic alias sync. +- `cargo test` from `apps/desktop/src-tauri/`. +- `cargo clippy` and `cargo fmt --check` from `apps/desktop/src-tauri/`. diff --git a/TODOS.md b/TODOS.md index 2d20b7a..60b7f4c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,6 +6,7 @@ ## Done +- Symlink support: [`SPECs/symlink-support-spec.md`](SPECs/symlink-support-spec.md) — show symlinked markdown files/folders in the sidebar and search index, preserve symlinks on save, and translate watcher target events back to visible workspace paths. - Table virtualization scroll stability: [`SPECs/table-virtualization-scroll-stability-spec.md`](SPECs/table-virtualization-scroll-stability-spec.md) — give folded markdown table widgets stable CodeMirror height estimates so scrolling through virtualized documents with tables does not suddenly resize the document or scrollbar. - Sidebar file label setting — add an `appearance.sidebar-file-label` enum (`title` | `filename`, default `title`) and have the sidebar file tree render the filename stem or the title-fallback chain accordingly. Also expose a "Rename..." action in the file context menu (files reuse the inline-rename flow folders already had). - Desktop dev script — make the root `dev` script delegate to the desktop package's Tauri dev workflow and keep desktop build/preview scripts on Vite+ commands. diff --git a/apps/desktop/src-tauri/src/commands/fs.rs b/apps/desktop/src-tauri/src/commands/fs.rs index dcc0ef9..80515e8 100644 --- a/apps/desktop/src-tauri/src/commands/fs.rs +++ b/apps/desktop/src-tauri/src/commands/fs.rs @@ -2,6 +2,7 @@ use crate::error::AppError; use crate::ignore::WorkspaceIgnore; use crate::state::{AppState, WorkspaceState}; use serde::Serialize; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; @@ -121,25 +122,44 @@ fn modified_time(path: &std::path::Path) -> u64 { /// workspace ignore matcher so ignored directories don't resurrect their /// parent in the sidebar. fn dir_contains_markdown_recursive(path: &Path, ignore: Option<&WorkspaceIgnore>) -> bool { + let mut visited_dirs = HashSet::new(); + dir_contains_markdown_recursive_inner(path, ignore, &mut visited_dirs) +} + +fn dir_contains_markdown_recursive_inner( + path: &Path, + ignore: Option<&WorkspaceIgnore>, + visited_dirs: &mut HashSet, +) -> bool { + let Ok(canonical) = path.canonicalize() else { + return false; + }; + if !visited_dirs.insert(canonical) { + return false; + } + let Ok(entries) = fs::read_dir(path) else { return false; }; for entry in entries.flatten() { - let ft = entry.file_type(); - let Ok(ft) = ft else { continue }; let entry_path = entry.path(); + let Ok(metadata) = crate::symlink::followed_metadata(&entry_path) else { + continue; + }; + let is_dir = metadata.is_dir(); if let Some(ignore) = ignore { - if ignore.is_ignored(&entry_path, ft.is_dir()) { + if ignore.is_ignored(&entry_path, is_dir) { continue; } } - if ft.is_file() { + if metadata.is_file() { if entry_path.extension().and_then(|e| e.to_str()) == Some("md") { return true; } - } else if ft.is_dir() && dir_contains_markdown_recursive(&entry_path, ignore) { + } else if is_dir && dir_contains_markdown_recursive_inner(&entry_path, ignore, visited_dirs) + { return true; } } @@ -186,7 +206,6 @@ pub fn read_directory_impl( let mut files = Vec::new(); for entry in fs::read_dir(&dir_path)?.flatten() { - let file_type = entry.file_type()?; let entry_path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); @@ -197,13 +216,19 @@ pub fn read_directory_impl( continue; } + let metadata = match crate::symlink::followed_metadata(&entry_path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let is_dir = metadata.is_dir(); + if let Some(ignore) = ignore_matcher { - if ignore.is_ignored(&entry_path, file_type.is_dir()) { + if ignore.is_ignored(&entry_path, is_dir) { continue; } } - if file_type.is_dir() { + if is_dir { if dir_contains_markdown(&entry_path, state) { dirs.push(DirEntry { name, @@ -214,7 +239,7 @@ pub fn read_directory_impl( title: None, }); } - } else if file_type.is_file() { + } else if metadata.is_file() { let is_markdown = entry_path.extension().and_then(|e| e.to_str()) == Some("md"); if is_markdown { let title = extract_title(&entry_path); @@ -266,7 +291,8 @@ pub async fn read_file(path: String) -> Result { } pub fn write_file_impl(path: &str, content: &str) -> Result { - let file_path = PathBuf::from(path); + let requested_path = PathBuf::from(path); + let file_path = crate::symlink::write_target_path(&requested_path)?; // Atomic write: write to temp file, then rename let dir = file_path @@ -278,7 +304,7 @@ pub fn write_file_impl(path: &str, content: &str) -> Result().get_or_create(webview.label()); - crate::watcher::record_write(&state, &PathBuf::from(&path)); + let requested_path = PathBuf::from(&path); + crate::watcher::record_write(&state, &requested_path); + if let Ok(target_path) = crate::symlink::write_target_path(&requested_path) { + if target_path != requested_path { + crate::watcher::record_write(&state, &target_path); + } + } blocking(move || write_file_impl(&path, &content)).await } @@ -433,6 +465,8 @@ pub async fn reveal_in_file_manager(path: String) -> Result<(), AppError> { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::TempDir; fn setup_test_dir() -> TempDir { @@ -501,6 +535,60 @@ mod tests { assert_eq!(result[0].name, "notes"); } + #[cfg(unix)] + #[test] + fn read_directory_includes_symlinked_markdown_file() { + let dir = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + let real = target.path().join("target.md"); + let link = dir.path().join("link.md"); + fs::write(&real, "# Linked Title").unwrap(); + symlink(&real, &link).unwrap(); + + let result = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "link.md"); + assert!(!result[0].is_dir); + assert!(result[0].is_markdown); + assert_eq!(result[0].title.as_deref(), Some("Linked Title")); + } + + #[cfg(unix)] + #[test] + fn read_directory_includes_symlinked_directory_with_markdown() { + let dir = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + fs::write(target.path().join("note.md"), "# Linked Note").unwrap(); + fs::write(target.path().join("ignored.txt"), "text").unwrap(); + let link = dir.path().join("linked"); + symlink(target.path(), &link).unwrap(); + + let root_entries = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + assert_eq!(root_entries.len(), 1); + assert_eq!(root_entries[0].name, "linked"); + assert!(root_entries[0].is_dir); + + let linked_entries = read_directory_impl(&link.to_string_lossy(), None).unwrap(); + assert_eq!(linked_entries.len(), 1); + assert_eq!(linked_entries[0].name, "note.md"); + assert_eq!( + linked_entries[0].path, + link.join("note.md").to_string_lossy() + ); + } + + #[cfg(unix)] + #[test] + fn read_directory_symlink_cycle_does_not_recurse_forever() { + let dir = TempDir::new().unwrap(); + symlink(dir.path(), dir.path().join("loop")).unwrap(); + + let result = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + + assert!(result.is_empty()); + } + #[test] fn test_read_file_returns_content() { let dir = TempDir::new().unwrap(); @@ -531,6 +619,27 @@ mod tests { assert_eq!(content, "new content"); } + #[cfg(unix)] + #[test] + fn write_file_preserves_symlink_and_updates_target() { + let dir = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + let real = target.path().join("target.md"); + let link = dir.path().join("link.md"); + fs::write(&real, "old").unwrap(); + symlink(&real, &link).unwrap(); + + let result = write_file_impl(&link.to_string_lossy(), "new content").unwrap(); + + assert_eq!(result.path, link.to_string_lossy().to_string()); + assert!(fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_to_string(&real).unwrap(), "new content"); + assert_eq!(fs::read_to_string(&link).unwrap(), "new content"); + } + #[test] fn test_create_file_already_exists() { let dir = TempDir::new().unwrap(); diff --git a/apps/desktop/src-tauri/src/commands/search.rs b/apps/desktop/src-tauri/src/commands/search.rs index 9f120be..0db5fe8 100644 --- a/apps/desktop/src-tauri/src/commands/search.rs +++ b/apps/desktop/src-tauri/src/commands/search.rs @@ -172,6 +172,7 @@ pub fn index_workspace_impl( .git_ignore(true) .git_global(true) .git_exclude(true) + .follow_links(true) .threads(threads) .build_parallel() .run(move || { @@ -231,6 +232,8 @@ pub fn fuzzy_search_impl(query: &str, index: &[IndexedFile], limit: usize) -> Ve mod tests { use super::*; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::TempDir; fn setup_workspace() -> TempDir { @@ -273,6 +276,58 @@ mod tests { assert!(!dirs.contains(&root.join(".git"))); } + #[cfg(unix)] + #[test] + fn test_index_workspace_follows_symlinked_markdown_file() { + let dir = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + let real = target.path().join("real.md"); + fs::write(&real, "# Real").unwrap(); + symlink(&real, dir.path().join("link.md")).unwrap(); + + let (index, dirs) = index_workspace_test(dir.path()); + + assert_eq!(index.len(), 1); + assert_eq!(index[0].relative_path, "link.md"); + assert_eq!(index[0].path, dir.path().join("link.md")); + assert!(dirs.contains(dir.path())); + } + + #[cfg(unix)] + #[test] + fn test_index_workspace_follows_symlinked_directory() { + let dir = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + fs::create_dir_all(target.path().join("nested")).unwrap(); + fs::write(target.path().join("nested/note.md"), "# Note").unwrap(); + fs::write(target.path().join("nested/ignored.txt"), "text").unwrap(); + symlink(target.path(), dir.path().join("linked")).unwrap(); + + let (index, dirs) = index_workspace_test(dir.path()); + + assert_eq!(index.len(), 1); + assert_eq!(index[0].relative_path, "linked/nested/note.md"); + assert_eq!(index[0].path, dir.path().join("linked/nested/note.md")); + assert!(dirs.contains(&dir.path().join("linked"))); + assert!(dirs.contains(&dir.path().join("linked/nested"))); + } + + #[cfg(unix)] + #[test] + fn test_index_workspace_symlink_loop_does_not_duplicate_root() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a.md"), "# A").unwrap(); + symlink(dir.path(), dir.path().join("loop")).unwrap(); + + let (index, _dirs) = index_workspace_test(dir.path()); + + let relative_paths: Vec<_> = index + .iter() + .map(|file| file.relative_path.as_str()) + .collect(); + assert_eq!(relative_paths, vec!["a.md"]); + } + #[test] fn test_cancel_flag_short_circuits_walk() { // Pre-cancelled walker should return before visiting any file. diff --git a/apps/desktop/src-tauri/src/ignore.rs b/apps/desktop/src-tauri/src/ignore.rs index d30a53e..fd50c08 100644 --- a/apps/desktop/src-tauri/src/ignore.rs +++ b/apps/desktop/src-tauri/src/ignore.rs @@ -59,6 +59,7 @@ impl WorkspaceIgnore { .git_global(false) .git_exclude(false) .parents(false) + .follow_links(true) .filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); name != ".git" && name != "node_modules" @@ -135,6 +136,8 @@ pub fn is_gitignore_path(path: &Path) -> bool { mod tests { use super::*; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::TempDir; fn touch(path: &Path, content: &str) { @@ -241,6 +244,23 @@ mod tests { assert!(!ignore.is_ignored(&dir.path().join("docs").join("final.md"), false)); } + #[cfg(unix)] + #[test] + fn gitignore_inside_symlinked_directory_is_applied_to_link_path() { + let dir = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + touch(&target.path().join(".gitignore"), "drafts/\n"); + touch(&target.path().join("drafts").join("wip.md"), "# wip"); + touch(&target.path().join("final.md"), "# final"); + symlink(target.path(), dir.path().join("linked")).unwrap(); + + let ignore = WorkspaceIgnore::load(dir.path()); + + assert!(ignore.is_ignored(&dir.path().join("linked/drafts"), true)); + assert!(ignore.is_ignored(&dir.path().join("linked/drafts/wip.md"), false)); + assert!(!ignore.is_ignored(&dir.path().join("linked/final.md"), false)); + } + #[test] fn gitignore_file_itself_stays_visible() { let dir = TempDir::new().unwrap(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6d76e1c..1af2dcb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod error; mod ignore; pub mod open_target; mod state; +mod symlink; #[cfg(desktop)] mod updater; mod watcher; diff --git a/apps/desktop/src-tauri/src/open_target.rs b/apps/desktop/src-tauri/src/open_target.rs index fbbeb1c..3d29d59 100644 --- a/apps/desktop/src-tauri/src/open_target.rs +++ b/apps/desktop/src-tauri/src/open_target.rs @@ -80,16 +80,29 @@ fn classify(path: &Path) -> Result { let canonical_parent = parent .canonicalize() .unwrap_or_else(|_| parent.to_path_buf()); - let canonical_file = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let file_path = visible_file_path(path, &canonical_parent); return Ok(PendingOpenPayload { workspace: canonical_parent.to_string_lossy().to_string(), - file: Some(canonical_file.to_string_lossy().to_string()), + file: Some(file_path.to_string_lossy().to_string()), }); } Err(OpenTargetError::Unsupported(path.to_path_buf())) } +fn visible_file_path(path: &Path, canonical_parent: &Path) -> PathBuf { + if path + .symlink_metadata() + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + if let Some(name) = path.file_name() { + return canonical_parent.join(name); + } + } + + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn is_markdown(path: &Path) -> bool { path.extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown")) @@ -99,6 +112,8 @@ fn is_markdown(path: &Path) -> bool { mod tests { use super::*; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::tempdir; #[test] @@ -140,6 +155,33 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn symlinked_markdown_file_keeps_visible_workspace_path() { + let dir = tempdir().unwrap(); + let target = tempdir().unwrap(); + let real = target.path().join("real.md"); + let link = dir.path().join("link.md"); + fs::write(&real, "# Real").unwrap(); + symlink(&real, &link).unwrap(); + + let payload = validate_and_resolve(&link).unwrap(); + + assert_eq!( + payload.workspace, + dir.path().canonicalize().unwrap().to_string_lossy() + ); + assert_eq!( + payload.file.unwrap(), + dir.path() + .canonicalize() + .unwrap() + .join("link.md") + .to_string_lossy() + .to_string() + ); + } + #[test] fn non_markdown_file_is_unsupported() { let dir = tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/symlink.rs b/apps/desktop/src-tauri/src/symlink.rs new file mode 100644 index 0000000..293467f --- /dev/null +++ b/apps/desktop/src-tauri/src/symlink.rs @@ -0,0 +1,238 @@ +use ignore::WalkBuilder; +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Visible workspace symlink paired with the canonical path it points at. +/// +/// `link_path` stays in the namespace the user sees in the sidebar/search +/// index. `target_path` is canonical so OS watcher events can be mapped back +/// even when a backend reports the resolved target path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymlinkAlias { + pub link_path: PathBuf, + pub target_path: PathBuf, + pub is_dir: bool, +} + +/// Return metadata for `path`, following symlinks to classify the visible +/// entry by its target. Broken symlinks return the underlying IO error and are +/// treated by callers like any other unreadable path. +pub fn followed_metadata(path: &Path) -> io::Result { + fs::metadata(path) +} + +/// Resolve the path an atomic write should replace. +/// +/// For ordinary files, preserve the current behavior: write to the requested +/// path, even if it does not exist yet. For symlinks, write to the canonical +/// target so saving a note does not replace the symlink itself with a regular +/// file. +pub fn write_target_path(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => path.canonicalize(), + Ok(_) => Ok(path.to_path_buf()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(err) => Err(err), + } +} + +pub fn alias_for_path(path: &Path) -> Option { + let link_metadata = fs::symlink_metadata(path).ok()?; + if !link_metadata.file_type().is_symlink() { + return None; + } + + let target_metadata = followed_metadata(path).ok()?; + let is_dir = target_metadata.is_dir(); + let target_path = path.canonicalize().ok()?; + + // Directory links that point at one of their own ancestors create an alias + // for the whole workspace (for example `loop -> .`). The index walk's + // loop detection skips those; the watcher alias map should skip them too + // or every root event would be mirrored under the loop path. Compare in + // canonical space so macOS `/var` -> `/private/var` aliases don't hide the + // ancestor relationship. + let canonical_link_path = path + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .and_then(|parent| path.file_name().map(|name| parent.join(name))) + .unwrap_or_else(|| path.to_path_buf()); + if is_dir && canonical_link_path.starts_with(&target_path) { + return None; + } + + Some(SymlinkAlias { + link_path: path.to_path_buf(), + target_path, + is_dir, + }) +} + +/// Collect symlinks visible under `root` without traversing into symlinked +/// directories. The primary indexer separately follows links for content; this +/// pass only needs the alias boundary so watcher events can be translated back +/// to visible workspace paths. +pub fn collect_symlink_aliases(root: &Path) -> Vec { + let mut aliases = Vec::new(); + let mut seen_links = HashSet::new(); + + for result in WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .follow_links(false) + .build() + { + let Ok(entry) = result else { continue }; + let path = entry.path(); + if !seen_links.insert(path.to_path_buf()) { + continue; + } + if let Some(alias) = alias_for_path(path) { + aliases.push(alias); + } + } + + // Longest targets first makes translation deterministic when aliases are + // nested or overlap. We still return every matching logical path. + aliases.sort_by_key(|alias| std::cmp::Reverse(alias.target_path.components().count())); + aliases +} + +/// Map an OS watcher event path into the logical workspace paths Writer uses. +/// +/// The raw path is kept only when it is already inside the workspace root. +/// Events from supplementary watches on symlink targets are translated back to +/// each matching symlink path. Results are deduped while preserving order. +pub fn logical_paths_for_event( + event_path: &Path, + workspace_root: &Path, + aliases: &[SymlinkAlias], +) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + if event_path.starts_with(workspace_root) { + push_unique(&mut paths, &mut seen, event_path.to_path_buf()); + } + + for alias in aliases { + let Ok(relative) = event_path.strip_prefix(&alias.target_path) else { + continue; + }; + let logical = if relative.as_os_str().is_empty() { + alias.link_path.clone() + } else { + alias.link_path.join(relative) + }; + push_unique(&mut paths, &mut seen, logical); + } + + paths +} + +fn push_unique(paths: &mut Vec, seen: &mut HashSet, path: PathBuf) { + if seen.insert(path.clone()) { + paths.push(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::symlink; + + #[cfg(unix)] + #[test] + fn collect_symlink_aliases_records_file_and_dir_targets() { + let root = tempfile::TempDir::new().unwrap(); + let target = tempfile::TempDir::new().unwrap(); + fs::write(target.path().join("note.md"), "# Note").unwrap(); + fs::create_dir_all(target.path().join("folder")).unwrap(); + + symlink(target.path().join("note.md"), root.path().join("link.md")).unwrap(); + symlink( + target.path().join("folder"), + root.path().join("linked-folder"), + ) + .unwrap(); + + let aliases = collect_symlink_aliases(root.path()); + + assert!(aliases.iter().any(|alias| { + alias.link_path == root.path().join("link.md") + && alias.target_path == target.path().join("note.md").canonicalize().unwrap() + && !alias.is_dir + })); + assert!(aliases.iter().any(|alias| { + alias.link_path == root.path().join("linked-folder") + && alias.target_path == target.path().join("folder").canonicalize().unwrap() + && alias.is_dir + })); + } + + #[test] + fn logical_paths_include_root_path_and_symlink_alias() { + let root = PathBuf::from("/workspace"); + let aliases = vec![SymlinkAlias { + link_path: root.join("linked"), + target_path: root.join("real"), + is_dir: true, + }]; + + let paths = logical_paths_for_event(&root.join("real/note.md"), &root, &aliases); + + assert_eq!( + paths, + vec![root.join("real/note.md"), root.join("linked/note.md")] + ); + } + + #[test] + fn logical_paths_translate_external_target_to_visible_link_path() { + let root = PathBuf::from("/workspace"); + let target = PathBuf::from("/external/notes"); + let aliases = vec![SymlinkAlias { + link_path: root.join("linked"), + target_path: target.clone(), + is_dir: true, + }]; + + let paths = logical_paths_for_event(&target.join("nested/note.md"), &root, &aliases); + + assert_eq!(paths, vec![root.join("linked/nested/note.md")]); + } + + #[cfg(unix)] + #[test] + fn write_target_path_resolves_symlink_without_resolving_regular_files() { + let root = tempfile::TempDir::new().unwrap(); + let target = tempfile::TempDir::new().unwrap(); + let real = target.path().join("note.md"); + let link = root.path().join("link.md"); + fs::write(&real, "# Note").unwrap(); + symlink(&real, &link).unwrap(); + + assert_eq!( + write_target_path(&link).unwrap(), + real.canonicalize().unwrap() + ); + assert_eq!(write_target_path(&real).unwrap(), real); + } + + #[cfg(unix)] + #[test] + fn collect_symlink_aliases_skips_directory_links_to_ancestors() { + let root = tempfile::TempDir::new().unwrap(); + symlink(root.path(), root.path().join("loop")).unwrap(); + + let aliases = collect_symlink_aliases(root.path()); + + assert!(aliases.is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/watcher.rs b/apps/desktop/src-tauri/src/watcher.rs index 72d1876..ef1d11f 100644 --- a/apps/desktop/src-tauri/src/watcher.rs +++ b/apps/desktop/src-tauri/src/watcher.rs @@ -2,6 +2,7 @@ use crate::ignore::{is_gitignore_path, WorkspaceIgnore}; use crate::state::{self, AppState, WorkspaceState}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; +use std::collections::HashSet; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; @@ -232,6 +233,111 @@ fn event_kind_str(kind: &EventKind) -> Option<&'static str> { } } +fn watch_symlink_targets( + watcher: &mut RecommendedWatcher, + root: &Path, + aliases: &[crate::symlink::SymlinkAlias], +) { + let mut watched = HashSet::new(); + for alias in aliases { + if alias.target_path.starts_with(root) { + continue; + } + if !watched.insert(alias.target_path.clone()) { + continue; + } + watch_symlink_target(watcher, alias); + } +} + +fn watch_symlink_target(watcher: &mut RecommendedWatcher, alias: &crate::symlink::SymlinkAlias) { + let mode = if alias.is_dir { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + match watcher.watch(&alias.target_path, mode) { + Ok(()) => wlog!( + "watch symlink target: {} -> {}", + alias.link_path.display(), + alias.target_path.display() + ), + Err(err) => wlog!( + "watch symlink target failed: {} -> {} ({err})", + alias.link_path.display(), + alias.target_path.display() + ), + } +} + +fn watch_symlink_target_from_state( + state: &WorkspaceState, + root: &Path, + alias: &crate::symlink::SymlinkAlias, + captured_epoch: u64, +) { + if alias.target_path.starts_with(root) { + return; + } + if state.workspace_epoch.load(Ordering::SeqCst) != captured_epoch { + return; + } + + let mut guard = state.watcher_handle.write(); + if state.workspace_epoch.load(Ordering::SeqCst) != captured_epoch { + return; + } + let Some(watcher) = guard.as_mut() else { + wlog!( + "watch symlink target deferred; watcher not installed yet: {} -> {}", + alias.link_path.display(), + alias.target_path.display() + ); + return; + }; + watch_symlink_target(watcher, alias); +} + +fn sync_symlink_alias_for_path( + state: &WorkspaceState, + root: &Path, + aliases: &mut Vec, + path: &Path, + captured_epoch: u64, +) { + let existing_index = aliases.iter().position(|alias| alias.link_path == path); + let next_alias = crate::symlink::alias_for_path(path); + + match next_alias { + Some(alias) => { + if existing_index.is_some_and(|index| aliases[index] == alias) { + return; + } + + let target_already_aliased = aliases.iter().enumerate().any(|(index, existing)| { + Some(index) != existing_index && existing.target_path == alias.target_path + }); + + if let Some(index) = existing_index { + aliases[index] = alias.clone(); + } else { + aliases.push(alias.clone()); + } + aliases.sort_by_key(|alias| std::cmp::Reverse(alias.target_path.components().count())); + + if !target_already_aliased { + watch_symlink_target_from_state(state, root, &alias, captured_epoch); + } + } + None => { + if let Some(index) = existing_index { + let removed = aliases.remove(index); + wlog!("removed symlink alias: {}", removed.link_path.display()); + } + } + } +} + /// Start a file watcher targeted at a specific window. All emitted events /// are routed via `emit_to(&window_label, ...)` so two windows hosting /// different workspaces don't cross-talk on file events. The watcher @@ -256,6 +362,12 @@ pub fn start_watcher( watcher.watch(&root_path, RecursiveMode::Recursive)?; + let mut symlink_aliases = crate::symlink::collect_symlink_aliases(&root_path); + watch_symlink_targets(&mut watcher, &root_path, &symlink_aliases); + if !symlink_aliases.is_empty() { + wlog!("loaded {} symlink aliases", symlink_aliases.len()); + } + let captured_epoch = epoch; // Spawn thread to process events @@ -311,139 +423,168 @@ pub fn start_watcher( let root_for_filter = state.workspace_root.read().clone(); for event in pending.drain(..) { - for path in &event.paths { - if let Some(ref root) = root_for_filter { - if should_ignore(path, root) { - wlog!("filter[should_ignore]: {}", path.display()); - continue; - } - } + for raw_path in &event.paths { + let logical_paths = if let Some(ref root) = root_for_filter { + crate::symlink::logical_paths_for_event(raw_path, root, &symlink_aliases) + } else { + vec![raw_path.clone()] + }; - // `.gitignore` changes defer to a background rebuild. - if is_gitignore_path(path) { - wlog!("filter[gitignore-change]: {}", path.display()); - rebuild_ignore = true; + if logical_paths.is_empty() { + wlog!("filter[outside_unmapped]: {}", raw_path.display()); continue; } - // FSEvents reports the path as it was at event time; by - // the time we read it the file may already be gone, so - // `path.is_dir()` is unreliable. Trust the event kind - // first, fall back to the live stat. Computed up here - // because `is_workspace_ignored` needs an accurate - // is_dir to match dir-only gitignore rules (e.g. `dist/`) - // against deleted directories. - let is_folder_event = matches!( - event.kind, - EventKind::Remove(notify::event::RemoveKind::Folder) - ) || matches!( - event.kind, - EventKind::Create(notify::event::CreateKind::Folder) - ); - let is_dir = is_folder_event || path.is_dir(); + for path in logical_paths { + if let Some(ref root) = root_for_filter { + if should_ignore(&path, root) { + wlog!("filter[should_ignore]: {}", path.display()); + continue; + } + } - if is_workspace_ignored(&state, path, is_dir) { - wlog!("filter[workspace_ignore]: {}", path.display()); - continue; - } + // `.gitignore` changes defer to a background rebuild. + if is_gitignore_path(&path) { + wlog!("filter[gitignore-change]: {}", path.display()); + rebuild_ignore = true; + continue; + } - if is_self_write(&state, path) { - continue; - } + // FSEvents reports the path as it was at event time; by + // the time we read it the file may already be gone, so + // `path.is_dir()` is unreliable. Trust the event kind + // first, fall back to the live stat. Computed up here + // because `is_workspace_ignored` needs an accurate + // is_dir to match dir-only gitignore rules (e.g. `dist/`) + // against deleted directories. + let is_folder_event = matches!( + event.kind, + EventKind::Remove(notify::event::RemoveKind::Folder) + ) || matches!( + event.kind, + EventKind::Create(notify::event::CreateKind::Folder) + ); + let is_dir = is_folder_event || path.is_dir(); - let kind_str = match event_kind_str(&event.kind) { - Some(k) => k, - None => { - wlog!("filter[unmapped_kind]: {:?} {}", event.kind, path.display()); + if is_workspace_ignored(&state, &path, is_dir) { + wlog!("filter[workspace_ignore]: {}", path.display()); continue; } - }; - - let payload = FileChangeEvent { - path: path.to_string_lossy().to_string(), - kind: kind_str.to_string(), - }; - if is_dir { - wlog!( - "emit fs:directory-changed kind={kind_str} {}", - path.display() - ); - let _ = handle.emit_to(label.clone(), "fs:directory-changed", &payload); - } else { - // `.writer/config` changes reload settings instead. - if is_config_file(path) { - wlog!("emit settings:changed {}", path.display()); - if let Some(ref mut s) = *state.settings.write() { - s.reload_workspace(); - } - let _ = handle.emit_to(label.clone(), "settings:changed", ()); + if is_self_write(&state, &path) { continue; } - wlog!("emit fs:file-changed kind={kind_str} {}", path.display()); - let _ = handle.emit_to(label.clone(), "fs:file-changed", &payload); - } + let kind_str = match event_kind_str(&event.kind) { + Some(k) => k, + None => { + wlog!("filter[unmapped_kind]: {:?} {}", event.kind, path.display()); + continue; + } + }; - // Treat Create, Remove, and Rename (Modify(Name)) as - // directory-membership changes. Finder's "Move to Trash" - // and `mv file /elsewhere` arrive as Modify(Name(_)) on - // macOS — not Remove — so the previous code missed them - // entirely. - let is_membership_change = matches!( - event.kind, - EventKind::Create(_) - | EventKind::Remove(_) - | EventKind::Modify(notify::event::ModifyKind::Name(_)) - ); - if !is_membership_change { - continue; - } + let payload = FileChangeEvent { + path: path.to_string_lossy().to_string(), + kind: kind_str.to_string(), + }; - // Maintain the file index by reading current ground truth - // (`path.exists()`) instead of trusting the event kind. - // FSEvents coalesces Create+Remove for the same path - // within one watch window, and Modify(Name) doesn't tell - // us which side of the rename this path is. - let is_md = path.extension().and_then(|e| e.to_str()) == Some("md"); - let path_exists = path.exists(); - if let Some(ref root) = root_for_filter { - if is_md { - if path_exists { - add_to_index(&state, path, root); - } else { - remove_from_index(&state, path, root); + if is_dir { + wlog!( + "emit fs:directory-changed kind={kind_str} {}", + path.display() + ); + let _ = handle.emit_to(label.clone(), "fs:directory-changed", &payload); + } else { + // `.writer/config` changes reload settings instead. + if is_config_file(&path) { + wlog!("emit settings:changed {}", path.display()); + if let Some(ref mut s) = *state.settings.write() { + s.reload_workspace(); + } + let _ = handle.emit_to(label.clone(), "settings:changed", ()); + continue; } - } else if path_exists && is_dir { - // A folder entered the watched tree (Create or - // rename-in). FSEvents won't re-emit Create events - // for descendants, so walk now to keep the index - // in sync. - add_subtree_to_index(&state, path, root); - } else if !path_exists { - // A vanished non-`.md` path could be a renamed- - // away folder; FSEvents may not emit per-child - // events for the descendants, so prune anything - // the index still holds under it. - remove_subtree_from_index(&state, path, root); + + wlog!("emit fs:file-changed kind={kind_str} {}", path.display()); + let _ = handle.emit_to(label.clone(), "fs:file-changed", &payload); + } + + // Treat Create, Remove, and Rename (Modify(Name)) as + // directory-membership changes. Finder's "Move to Trash" + // and `mv file /elsewhere` arrive as Modify(Name(_)) on + // macOS — not Remove — so the previous code missed them + // entirely. + let is_membership_change = matches!( + event.kind, + EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(notify::event::ModifyKind::Name(_)) + ); + if !is_membership_change { + continue; } - } - // Refresh the parent directory's listing. Without this, - // non-`.md` file changes, folder deletes, and Finder - // moves never trigger a sidebar refresh. - if !is_dir { - if let Some(parent) = path.parent() { - wlog!("emit fs:directory-changed (parent) {}", parent.display()); - let _ = handle.emit_to( - label.clone(), - "fs:directory-changed", - &FileChangeEvent { - path: parent.to_string_lossy().to_string(), - kind: "modified".to_string(), - }, + // Maintain the symlink alias map before touching the + // index. A newly-created link should immediately map + // target events back to the visible workspace path; + // a removed/replaced link should stop doing so even if + // its supplementary target watch keeps running until + // workspace close. + if let Some(ref root) = root_for_filter { + sync_symlink_alias_for_path( + &state, + root, + &mut symlink_aliases, + &path, + captured_epoch, ); } + + // Maintain the file index by reading current ground truth + // (`path.exists()`) instead of trusting the event kind. + // FSEvents coalesces Create+Remove for the same path + // within one watch window, and Modify(Name) doesn't tell + // us which side of the rename this path is. + let is_md = path.extension().and_then(|e| e.to_str()) == Some("md"); + let path_exists = path.exists(); + if let Some(ref root) = root_for_filter { + if is_md { + if path_exists { + add_to_index(&state, &path, root); + } else { + remove_from_index(&state, &path, root); + } + } else if path_exists && is_dir { + // A folder entered the watched tree (Create or + // rename-in). FSEvents won't re-emit Create events + // for descendants, so walk now to keep the index + // in sync. + add_subtree_to_index(&state, &path, root); + } else if !path_exists { + // A vanished non-`.md` path could be a renamed- + // away folder; FSEvents may not emit per-child + // events for the descendants, so prune anything + // the index still holds under it. + remove_subtree_from_index(&state, &path, root); + } + } + + // Refresh the parent directory's listing. Without this, + // non-`.md` file changes, folder deletes, and Finder + // moves never trigger a sidebar refresh. + if !is_dir { + if let Some(parent) = path.parent() { + wlog!("emit fs:directory-changed (parent) {}", parent.display()); + let _ = handle.emit_to( + label.clone(), + "fs:directory-changed", + &FileChangeEvent { + path: parent.to_string_lossy().to_string(), + kind: "modified".to_string(), + }, + ); + } + } } } } @@ -507,6 +648,8 @@ pub fn drop_watcher_off_thread(watcher: Option) { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; use std::path::PathBuf; const ROOT: &str = "/workspace"; @@ -667,6 +810,52 @@ mod tests { assert_eq!(state.file_index.read().len(), 1); } + #[cfg(unix)] + #[test] + fn add_subtree_indexes_symlinked_directory_under_link_path() { + let dir = tempfile::TempDir::new().unwrap(); + let target = tempfile::TempDir::new().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::create_dir_all(target.path().join("nested")).unwrap(); + std::fs::write(target.path().join("nested/a.md"), "# a").unwrap(); + let link = root.join("linked"); + symlink(target.path(), &link).unwrap(); + + let state = WorkspaceState::default(); + add_subtree_to_index(&state, &link, &root); + + let index = state.file_index.read(); + assert_eq!(index.len(), 1); + assert_eq!(index[0].path, link.join("nested/a.md")); + assert_eq!(index[0].relative_path, "linked/nested/a.md"); + } + + #[cfg(unix)] + #[test] + fn sync_symlink_alias_adds_new_link_and_removes_deleted_link() { + let dir = tempfile::TempDir::new().unwrap(); + let target = tempfile::TempDir::new().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let real = target.path().join("target.md"); + let link = root.join("link.md"); + std::fs::write(&real, "# target").unwrap(); + symlink(&real, &link).unwrap(); + + let state = WorkspaceState::default(); + let mut aliases = Vec::new(); + sync_symlink_alias_for_path(&state, &root, &mut aliases, &link, 0); + + assert_eq!(aliases.len(), 1); + assert_eq!(aliases[0].link_path, link); + assert_eq!(aliases[0].target_path, real.canonicalize().unwrap()); + + std::fs::remove_file(&aliases[0].link_path).unwrap(); + let link = root.join("link.md"); + sync_symlink_alias_for_path(&state, &root, &mut aliases, &link, 0); + + assert!(aliases.is_empty()); + } + #[test] fn remove_subtree_drops_only_matching_descendants() { let state = WorkspaceState::default();