diff --git a/CHANGELOG.md b/CHANGELOG.md index b14398b..52b6c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-06-02 + +- Support symlinked Markdown files and symlinked directories in workspaces. The sidebar and search index now preserve the logical workspace path, show a symlink badge in the file tree, skip broken/recursive links, save through live file symlinks without replacing the link, and translate watcher events from canonical targets back to the visible symlink path. + ## 2026-06-01 - 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. diff --git a/SPECs/Agent/worksheet-symlink-file-view-edit-watch.md b/SPECs/Agent/worksheet-symlink-file-view-edit-watch.md new file mode 100644 index 0000000..5d80ce9 --- /dev/null +++ b/SPECs/Agent/worksheet-symlink-file-view-edit-watch.md @@ -0,0 +1,36 @@ +# Agent Worksheet: Symlink File View/Edit/Watch + +## Task + +- TODO: Symlink file view/editing/watching. +- Spec: [`SPECs/symlink-file-view-edit-watch-spec.md`](../symlink-file-view-edit-watch-spec.md). + +## Context Reviewed + +- Local files: `commands/fs.rs`, `commands/search.rs`, `watcher.rs`, `state.rs`, `ignore.rs`, sidebar `DirEntry` rendering/types. +- Project docs: `AGENTS.md`, `docs/consolidation.md`, `docs/workflows/agent-loop.md`, prior watcher specs. +- Zed reference: temporary clone of `zed-industries/zed` at `a565ab4`; relevant files were `crates/fs/src/fs.rs`, `crates/fs/src/fs_watcher.rs`, and `crates/worktree/src/worktree.rs`. + +## Plan + +- Add a Rust path-info helper to classify symlinks with target metadata while hiding broken links. +- Update directory reads and indexing to include symlinked markdown files and eagerly traverse symlinked directories with cycle guards. +- Preserve symlinked files on save by writing to the resolved target path and recording both logical and canonical paths for self-write suppression. +- Add per-window symlink target mappings and normalize watcher events so target changes refresh the logical symlink paths. +- Add `is_symlink` to `DirEntry` and render a subtle sidebar badge. + +## Results + +- Added a shared Rust symlink path classifier and canonical target map used by directory reads, indexing, writes, and watcher normalization. +- Directory reads and search indexing now include live symlinked markdown files and markdown-bearing symlinked directories, skip broken links, and guard symlink loops. +- Saves through live file symlinks now write to the resolved target so the logical symlink remains intact; broken symlink saves fail instead of replacing the link. +- Watcher events from canonical symlink targets are normalized back to logical workspace paths, with external target watch registrations for linked files and directories. +- Sidebar entries now carry `is_symlink` and render a compact symlink badge. + +## Validation + +- `vp check` passed with two pre-existing E2E lint warnings. +- `vp test` passed: 27 files, 438 tests. +- `cargo fmt --check` passed. +- `cargo test` passed: 119 tests. +- `cargo clippy` passed with pre-existing warnings in config/search/images code. diff --git a/SPECs/symlink-file-view-edit-watch-spec.md b/SPECs/symlink-file-view-edit-watch-spec.md new file mode 100644 index 0000000..a97d3ec --- /dev/null +++ b/SPECs/symlink-file-view-edit-watch-spec.md @@ -0,0 +1,35 @@ +# Symlink File View/Edit/Watch + +## Summary + +Writer should handle symlinked markdown files and symlinked directories like other local-first editors do: the sidebar shows the logical path inside the workspace, editing a symlinked file writes through to its target without replacing the link, and filesystem events from resolved targets refresh the visible symlink path. + +The implementation follows the relevant parts of Zed's worktree model as reviewed from `zed-industries/zed` commit `a565ab4`: keep symlink metadata separate from target file/dir classification, preserve logical worktree paths in the UI, and maintain canonical-target mappings so watcher events can be translated back to symlink paths. + +## Goals + +- Show live `.md` and `.markdown` symlink files in the sidebar and search results. +- Eagerly scan symlinked directories that contain markdown, while respecting existing hidden-path and gitignore filters. +- Preserve symlink files during save by writing to the resolved target instead of atomically replacing the link. +- Translate watcher events from symlink targets back to the logical symlink paths visible in Writer. +- Add a subtle sidebar badge for symlink entries. + +## Non-Goals + +- Broken symlinks stay hidden from the markdown tree. +- No new setting for lazy/eager symlink traversal. +- No polling watcher fallback for filesystems that do not deliver native events. +- Rename and delete operate on the symlink path itself, not the target. + +## Implementation Notes + +- Add a single Rust helper for path classification so directory reads, indexing, watcher updates, and write handling agree on `is_symlink`, target file/dir type, canonical target path, and markdown eligibility. +- Track canonical target mappings in per-window `WorkspaceState`; both indexed symlink files and scanned symlink directories register their target paths. +- Watch external symlink targets in addition to the workspace root. Existing event filtering and self-write suppression then operate on normalized logical paths. +- Use inode/canonical-cycle guards when walking symlink directories to avoid recursive loops. + +## Tests + +- Unit-test symlink file save preservation, broken symlink hiding, symlink directory indexing, loop avoidance, and watcher event normalization. +- Run Rust validation from `apps/desktop/src-tauri/`: `cargo test`, `cargo clippy`, and `cargo fmt --check`. +- Run frontend validation: `vp check` and `vp test`. diff --git a/TODOS.md b/TODOS.md index 2d20b7a..8767d7e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,6 +6,7 @@ ## Done +- Symlink file view/editing/watching: [`SPECs/symlink-file-view-edit-watch-spec.md`](SPECs/symlink-file-view-edit-watch-spec.md) — support markdown file symlinks and symlinked directories in the sidebar/search index, preserve symlinks when editing, and map watcher events from canonical targets 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..5551eeb 100644 --- a/apps/desktop/src-tauri/src/commands/fs.rs +++ b/apps/desktop/src-tauri/src/commands/fs.rs @@ -1,6 +1,7 @@ use crate::error::AppError; use crate::ignore::WorkspaceIgnore; use crate::state::{AppState, WorkspaceState}; +use crate::symlink::{classify_path, write_target}; use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; @@ -14,6 +15,7 @@ pub struct DirEntry { pub path: String, pub is_dir: bool, pub is_markdown: bool, + pub is_symlink: bool, pub modified_at: u64, /// Document title extracted from frontmatter `title:` or leading `# ` heading. /// `None` for directories or files without a recognizable title. @@ -121,25 +123,41 @@ 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 = std::collections::HashSet::new(); + dir_contains_markdown_recursive_inner(path, ignore, &mut visited) +} + +fn dir_contains_markdown_recursive_inner( + path: &Path, + ignore: Option<&WorkspaceIgnore>, + visited: &mut std::collections::HashSet, +) -> bool { + let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !visited.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(Some(info)) = classify_path(&entry_path) else { + continue; + }; if let Some(ignore) = ignore { - if ignore.is_ignored(&entry_path, ft.is_dir()) { + if ignore.is_ignored(&entry_path, info.is_dir) { continue; } } - if ft.is_file() { - if entry_path.extension().and_then(|e| e.to_str()) == Some("md") { + if info.is_file { + if info.is_markdown { return true; } - } else if ft.is_dir() && dir_contains_markdown_recursive(&entry_path, ignore) { + } else if info.is_dir && dir_contains_markdown_recursive_inner(&entry_path, ignore, visited) + { return true; } } @@ -184,9 +202,9 @@ pub fn read_directory_impl( let mut dirs = Vec::new(); let mut files = Vec::new(); + let mut symlink_entries = 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,36 +215,61 @@ pub fn read_directory_impl( continue; } + let Some(info) = classify_path(&entry_path)? else { + continue; + }; + if let Some(ignore) = ignore_matcher { - if ignore.is_ignored(&entry_path, file_type.is_dir()) { + if ignore.is_ignored(&entry_path, info.is_dir) { continue; } } - if file_type.is_dir() { + if info.is_dir { if dir_contains_markdown(&entry_path, state) { + if info.is_symlink { + if let Some(canonical) = info.canonical_path.clone() { + symlink_entries.push((entry_path.clone(), canonical, info.is_dir)); + } + } dirs.push(DirEntry { name, path: entry_path.to_string_lossy().to_string(), is_dir: true, is_markdown: false, + is_symlink: info.is_symlink, modified_at: modified_time(&entry_path), title: None, }); } - } else if file_type.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); - files.push(DirEntry { - name, - path: entry_path.to_string_lossy().to_string(), - is_dir: false, - is_markdown: true, - modified_at: modified_time(&entry_path), - title, - }); + } else if info.is_file && info.is_markdown { + if info.is_symlink { + if let Some(canonical) = info.canonical_path.clone() { + symlink_entries.push((entry_path.clone(), canonical, info.is_dir)); + } } + let title = extract_title(&entry_path); + files.push(DirEntry { + name, + path: entry_path.to_string_lossy().to_string(), + is_dir: false, + is_markdown: true, + is_symlink: info.is_symlink, + modified_at: modified_time(&entry_path), + title, + }); + } + } + + if let Some(state) = state { + if !symlink_entries.is_empty() { + { + let mut targets = state.symlink_targets.write(); + for (logical, canonical, is_dir) in symlink_entries { + targets.insert(logical, canonical, is_dir); + } + } + crate::watcher::sync_symlink_watches(state); } } @@ -266,7 +309,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 logical_path = PathBuf::from(path); + let file_path = write_target(&logical_path)?; // Atomic write: write to temp file, then rename let dir = file_path @@ -278,7 +322,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 logical_path = PathBuf::from(&path); + crate::watcher::record_write(&state, &logical_path); + if let Ok(target) = write_target(&logical_path) { + if target != logical_path { + crate::watcher::record_write(&state, &target); + } + } blocking(move || write_file_impl(&path, &content)).await } @@ -336,6 +386,7 @@ pub fn create_directory_impl(path: &str) -> Result { path: path.to_string(), is_dir: true, is_markdown: false, + is_symlink: false, modified_at: modified_time(&dir_path), title: None, }) @@ -489,9 +540,11 @@ mod tests { // Build index let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (indexed, dirs) = crate::commands::search::index_workspace_impl(dir.path(), cancel); + let (indexed, dirs, symlinks) = + crate::commands::search::index_workspace_impl(dir.path(), cancel); *state.file_index.write() = indexed; *state.dirs_with_markdown.write() = dirs; + *state.symlink_targets.write() = symlinks; state.index_ready.store(true, Ordering::Relaxed); let result = read_directory_impl(&dir.path().to_string_lossy(), Some(&state)).unwrap(); @@ -531,6 +584,92 @@ mod tests { assert_eq!(content, "new content"); } + #[cfg(unix)] + #[test] + fn test_read_directory_includes_markdown_file_symlink() { + let dir = TempDir::new().unwrap(); + let target = dir.path().join("target.md"); + let link = dir.path().join("linked.md"); + fs::write(&target, "# Linked").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let result = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + let linked = result + .iter() + .find(|entry| entry.name == "linked.md") + .unwrap(); + + assert!(!linked.is_dir); + assert!(linked.is_markdown); + assert!(linked.is_symlink); + assert_eq!(linked.title.as_deref(), Some("Linked")); + } + + #[cfg(unix)] + #[test] + fn test_read_directory_includes_symlinked_dir_with_markdown() { + let dir = TempDir::new().unwrap(); + let external = TempDir::new().unwrap(); + fs::write(external.path().join("note.md"), "# Note").unwrap(); + let link = dir.path().join("external"); + std::os::unix::fs::symlink(external.path(), &link).unwrap(); + + let result = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + let linked = result + .iter() + .find(|entry| entry.name == "external") + .unwrap(); + + assert!(linked.is_dir); + assert!(linked.is_symlink); + } + + #[cfg(unix)] + #[test] + fn test_read_directory_hides_broken_symlink() { + let dir = TempDir::new().unwrap(); + std::os::unix::fs::symlink(dir.path().join("missing.md"), dir.path().join("broken.md")) + .unwrap(); + + let result = read_directory_impl(&dir.path().to_string_lossy(), None).unwrap(); + + assert!(!result.iter().any(|entry| entry.name == "broken.md")); + } + + #[cfg(unix)] + #[test] + fn test_write_file_preserves_symlink_and_updates_target() { + let dir = TempDir::new().unwrap(); + let target = dir.path().join("target.md"); + let link = dir.path().join("linked.md"); + fs::write(&target, "old").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + write_file_impl(&link.to_string_lossy(), "new").unwrap(); + + assert!(fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + } + + #[cfg(unix)] + #[test] + fn test_write_file_does_not_replace_broken_symlink() { + let dir = TempDir::new().unwrap(); + let link = dir.path().join("broken.md"); + std::os::unix::fs::symlink(dir.path().join("missing.md"), &link).unwrap(); + + let result = write_file_impl(&link.to_string_lossy(), "new"); + + assert!(result.is_err()); + assert!(fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + } + #[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..77d87c9 100644 --- a/apps/desktop/src-tauri/src/commands/search.rs +++ b/apps/desktop/src-tauri/src/commands/search.rs @@ -1,5 +1,6 @@ use crate::error::AppError; use crate::state::{self, AppState, IndexedFile}; +use crate::symlink::{classify_path, is_markdown_path, SymlinkMap}; use ignore::WalkBuilder; use parking_lot::Mutex; use serde::Serialize; @@ -42,7 +43,7 @@ pub fn index_workspace( let epoch = state.workspace_epoch.load(Ordering::SeqCst); let start = std::time::Instant::now(); - let (indexed, dirs) = index_workspace_impl(&root, cancel); + let (indexed, dirs, symlink_targets) = index_workspace_impl(&root, cancel); let file_count = indexed.len(); let duration_ms = start.elapsed().as_millis() as u64; @@ -55,7 +56,9 @@ pub fn index_workspace( *state.file_index.write() = indexed; *state.dirs_with_markdown.write() = dirs; + *state.symlink_targets.write() = symlink_targets; state.index_ready.store(true, Ordering::Relaxed); + crate::watcher::sync_symlink_watches(&state); Ok(IndexStats { file_count, @@ -156,15 +159,17 @@ fn fuzzy_search_from( pub fn index_workspace_impl( root: &Path, cancel: Arc, -) -> (Vec, HashSet) { +) -> (Vec, HashSet, SymlinkMap) { let root = root.to_path_buf(); let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + let symlink_targets: Arc> = Arc::new(Mutex::new(SymlinkMap::default())); let threads = std::thread::available_parallelism() .map(|n| n.get().min(8)) .unwrap_or(4); let results_ref = Arc::clone(&results); + let symlink_targets_ref = Arc::clone(&symlink_targets); let root_ref = root.clone(); WalkBuilder::new(&root) @@ -172,10 +177,12 @@ pub fn index_workspace_impl( .git_ignore(true) .git_global(true) .git_exclude(true) + .follow_links(true) .threads(threads) .build_parallel() .run(move || { let results = Arc::clone(&results_ref); + let symlink_targets = Arc::clone(&symlink_targets_ref); let root = root_ref.clone(); let cancel = Arc::clone(&cancel); Box::new(move |entry| { @@ -186,16 +193,30 @@ pub fn index_workspace_impl( Ok(e) => e, Err(_) => return ignore::WalkState::Continue, }; + let info = match classify_path(entry.path()) { + Ok(Some(info)) => info, + Ok(None) => return ignore::WalkState::Continue, + Err(_) => return ignore::WalkState::Continue, + }; + + if info.is_symlink { + if let Some(canonical) = info.canonical_path.clone() { + symlink_targets.lock().insert( + entry.path().to_path_buf(), + canonical, + info.is_dir, + ); + } + } + // Safety net: skip node_modules even without .gitignore - if entry.file_type().is_some_and(|ft| ft.is_dir()) { + if info.is_dir { if entry.file_name() == "node_modules" { return ignore::WalkState::Skip; } return ignore::WalkState::Continue; } - if entry.file_type().is_some_and(|ft| ft.is_file()) - && entry.path().extension().and_then(|e| e.to_str()) == Some("md") - { + if info.is_file && is_markdown_path(entry.path()) { let rel = entry .path() .strip_prefix(&root) @@ -206,6 +227,8 @@ pub fn index_workspace_impl( path: entry.path().to_path_buf(), relative_path: rel, name: entry.file_name().to_string_lossy().to_string(), + is_symlink: info.is_symlink, + canonical_path: info.canonical_path, }); } ignore::WalkState::Continue @@ -213,13 +236,14 @@ pub fn index_workspace_impl( }); let indexed = Arc::try_unwrap(results).unwrap().into_inner(); + let symlink_targets = Arc::try_unwrap(symlink_targets).unwrap().into_inner(); let dirs = state::rebuild_dirs_from_index(&indexed, &root); - (indexed, dirs) + (indexed, dirs, symlink_targets) } /// Test-only convenience: run an uncancellable index. #[cfg(test)] -fn index_workspace_test(root: &Path) -> (Vec, HashSet) { +fn index_workspace_test(root: &Path) -> (Vec, HashSet, SymlinkMap) { index_workspace_impl(root, Arc::new(AtomicBool::new(false))) } @@ -249,14 +273,14 @@ mod tests { #[test] fn test_index_workspace_counts_md_files() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); assert_eq!(index.len(), 3); // readme.md, notes.md, docs/guide.md } #[test] fn test_index_workspace_ignores_hidden() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); // Should not include .git/config.md assert!(!index.iter().any(|f| f.relative_path.contains(".git"))); } @@ -265,7 +289,7 @@ mod tests { fn test_index_workspace_builds_dirs_with_markdown() { let dir = setup_workspace(); let root = dir.path().to_path_buf(); - let (_index, dirs) = index_workspace_test(dir.path()); + let (_index, dirs, _symlinks) = index_workspace_test(dir.path()); // The root and docs/ should be in the set assert!(dirs.contains(&root)); assert!(dirs.contains(&root.join("docs"))); @@ -273,14 +297,62 @@ mod tests { assert!(!dirs.contains(&root.join(".git"))); } + #[cfg(unix)] + #[test] + fn test_index_workspace_follows_symlinked_markdown_file() { + let dir = TempDir::new().unwrap(); + let target = dir.path().join("target.md"); + let link = dir.path().join("linked.md"); + fs::write(&target, "# target").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let (index, _dirs, symlinks) = index_workspace_test(dir.path()); + + let linked = index.iter().find(|file| file.name == "linked.md").unwrap(); + assert!(linked.is_symlink); + assert_eq!(linked.path, link); + assert!(!symlinks.is_empty()); + } + + #[cfg(unix)] + #[test] + fn test_index_workspace_follows_symlinked_directory() { + let dir = TempDir::new().unwrap(); + let external = TempDir::new().unwrap(); + fs::write(external.path().join("external.md"), "# external").unwrap(); + std::os::unix::fs::symlink(external.path(), dir.path().join("external")).unwrap(); + + let (index, dirs, symlinks) = index_workspace_test(dir.path()); + + assert!(index + .iter() + .any(|file| file.relative_path == "external/external.md")); + assert!(dirs.contains(&dir.path().join("external"))); + assert!(!symlinks.is_empty()); + } + + #[cfg(unix)] + #[test] + fn test_index_workspace_skips_symlink_loop() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("root.md"), "# root").unwrap(); + std::os::unix::fs::symlink(dir.path(), dir.path().join("loop")).unwrap(); + + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); + + let root_hits = index.iter().filter(|file| file.name == "root.md").count(); + assert_eq!(root_hits, 1); + } + #[test] fn test_cancel_flag_short_circuits_walk() { // Pre-cancelled walker should return before visiting any file. let dir = setup_workspace(); let cancel = Arc::new(AtomicBool::new(true)); - let (index, dirs) = index_workspace_impl(dir.path(), cancel); + let (index, dirs, symlinks) = index_workspace_impl(dir.path(), cancel); assert!(index.is_empty()); assert!(dirs.is_empty()); + assert!(symlinks.is_empty()); } #[test] @@ -298,7 +370,7 @@ mod tests { #[test] fn test_fuzzy_search_ranks_by_relevance() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); let results = fuzzy_search_impl("readme", &index, 50); assert!(!results.is_empty()); assert_eq!(results[0].filename, "readme.md"); @@ -308,7 +380,7 @@ mod tests { fn test_fuzzy_search_matches_space_separated_names() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("No prior experience.md"), "# Note").unwrap(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); let results = fuzzy_search_impl("No prior experience", &index, 50); @@ -319,7 +391,7 @@ mod tests { #[test] fn test_fuzzy_search_returns_match_indices() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); let results = fuzzy_search_impl("guide", &index, 50); assert!(!results.is_empty()); assert!(!results[0].match_indices.is_empty()); @@ -328,7 +400,7 @@ mod tests { #[test] fn test_fuzzy_search_empty_query() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); let results = fuzzy_search_impl("", &index, 50); assert!(results.is_empty()); } @@ -336,7 +408,7 @@ mod tests { #[test] fn test_fuzzy_search_respects_limit() { let dir = setup_workspace(); - let (index, _dirs) = index_workspace_test(dir.path()); + let (index, _dirs, _symlinks) = index_workspace_test(dir.path()); let results = fuzzy_search_impl("md", &index, 1); assert!(results.len() <= 1); } diff --git a/apps/desktop/src-tauri/src/commands/workspace.rs b/apps/desktop/src-tauri/src/commands/workspace.rs index 7c9d01e..4162471 100644 --- a/apps/desktop/src-tauri/src/commands/workspace.rs +++ b/apps/desktop/src-tauri/src/commands/workspace.rs @@ -87,6 +87,8 @@ fn prepare_workspace_state( *state.workspace_root.write() = Some(root.clone()); *state.file_index.write() = Vec::new(); *state.dirs_with_markdown.write() = Default::default(); + *state.symlink_targets.write() = Default::default(); + *state.symlink_watch_paths.write() = Default::default(); state.index_ready.store(false, Ordering::Relaxed); // Install a cheap bootstrap matcher synchronously so the first @@ -185,7 +187,7 @@ fn run_workspace_bootstrap( // Walk the tree. The `cancel` flag lets a concurrent workspace switch // stop this walk at the next directory boundary. - let (indexed, dirs) = index_workspace_impl(&root, Arc::clone(&cancel)); + let (indexed, dirs, symlink_targets) = index_workspace_impl(&root, Arc::clone(&cancel)); if cancel.load(Ordering::Relaxed) { return; } @@ -196,7 +198,9 @@ fn run_workspace_bootstrap( } *state.file_index.write() = indexed; *state.dirs_with_markdown.write() = dirs; + *state.symlink_targets.write() = symlink_targets; state.index_ready.store(true, Ordering::Relaxed); + crate::watcher::sync_symlink_watches(&state); let _ = handle.emit_to(label, "index:complete", file_count); } 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/state.rs b/apps/desktop/src-tauri/src/state.rs index 9ebb7a5..8e43170 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -1,6 +1,7 @@ use crate::config::Settings; use crate::ignore::WorkspaceIgnore; use crate::open_target::PendingOpenPayload; +use crate::symlink::{SymlinkMap, WatchPath}; use notify::RecommendedWatcher; use parking_lot::{Mutex, RwLock}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -29,6 +30,12 @@ pub struct WorkspaceState { /// Gitignore matcher for the current workspace. Rebuilt when any /// `.gitignore` file changes. `None` until the first workspace is opened. pub workspace_ignore: RwLock>>, + /// Logical workspace symlink paths keyed by canonical target paths. The + /// watcher uses this to translate target events back to visible paths. + pub symlink_targets: RwLock, + /// Extra symlink target watch registrations currently added to + /// `watcher_handle`. Cleared on workspace switch with the watcher itself. + pub symlink_watch_paths: RwLock>, /// Monotonic counter incremented on every workspace switch inside this /// window. Background tasks capture it at launch and re-check before /// writing; stale results are dropped. Watcher closures capture it too @@ -65,6 +72,8 @@ pub struct IndexedFile { pub path: PathBuf, pub relative_path: String, pub name: String, + pub is_symlink: bool, + pub canonical_path: Option, } impl Default for WorkspaceState { @@ -77,6 +86,8 @@ impl Default for WorkspaceState { watcher_handle: RwLock::new(None), recent_writes: RwLock::new(HashMap::new()), workspace_ignore: RwLock::new(None), + symlink_targets: RwLock::new(SymlinkMap::default()), + symlink_watch_paths: RwLock::new(HashSet::new()), workspace_epoch: AtomicU64::new(0), cancel_index: RwLock::new(Arc::new(AtomicBool::new(false))), settings: RwLock::new(None), diff --git a/apps/desktop/src-tauri/src/symlink.rs b/apps/desktop/src-tauri/src/symlink.rs new file mode 100644 index 0000000..82e0940 --- /dev/null +++ b/apps/desktop/src-tauri/src/symlink.rs @@ -0,0 +1,300 @@ +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct PathInfo { + pub is_symlink: bool, + pub is_dir: bool, + pub is_file: bool, + pub is_markdown: bool, + pub canonical_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymlinkTarget { + pub logical_path: PathBuf, + pub is_dir: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WatchMode { + Recursive, + NonRecursive, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WatchPath { + pub path: PathBuf, + pub mode: WatchMode, +} + +#[derive(Debug, Clone, Default)] +pub struct SymlinkMap { + targets: HashMap>, +} + +pub fn is_markdown_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown")) +} + +/// Classify a filesystem path while preserving whether the path itself is a +/// symlink. Live symlinks are classified by their target metadata; broken or +/// recursive symlinks return `Ok(None)` so callers can hide them from the tree. +pub fn classify_path(path: &Path) -> io::Result> { + let symlink_metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(None); + } + Err(err) => return Err(err), + }; + + let is_symlink = symlink_metadata.file_type().is_symlink(); + let metadata = if is_symlink { + match fs::metadata(path) { + Ok(metadata) => metadata, + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(None); + } + Err(err) => return Err(err), + } + } else { + symlink_metadata + }; + + let canonical_path = if is_symlink { + match fs::canonicalize(path) { + Ok(path) => Some(path), + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(None); + } + Err(err) => return Err(err), + } + } else { + None + }; + + Ok(Some(PathInfo { + is_symlink, + is_dir: metadata.is_dir(), + is_file: metadata.is_file(), + is_markdown: is_markdown_path(path), + canonical_path, + })) +} + +/// Resolve the physical destination to write. Writing through a live symlinked +/// file must preserve the symlink itself, so the atomic replace happens at the +/// resolved target path instead of at the logical link path. +pub fn write_target(path: &Path) -> io::Result { + if fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return fs::canonicalize(path); + } + + let Some(info) = classify_path(path)? else { + return Ok(path.to_path_buf()); + }; + if info.is_symlink && info.is_file { + if let Some(canonical) = info.canonical_path { + return Ok(canonical); + } + } + Ok(path.to_path_buf()) +} + +impl SymlinkMap { + pub fn insert(&mut self, logical_path: PathBuf, canonical_path: PathBuf, is_dir: bool) { + let targets = self.targets.entry(canonical_path).or_default(); + if targets + .iter() + .any(|target| target.logical_path == logical_path && target.is_dir == is_dir) + { + return; + } + targets.push(SymlinkTarget { + logical_path, + is_dir, + }); + } + + pub fn extend(&mut self, other: SymlinkMap) { + for (canonical, targets) in other.targets { + for target in targets { + self.insert(target.logical_path, canonical.clone(), target.is_dir); + } + } + } + + pub fn remove_logical_path(&mut self, logical_path: &Path) { + for targets in self.targets.values_mut() { + targets.retain(|target| target.logical_path != logical_path); + } + self.targets.retain(|_, targets| !targets.is_empty()); + } + + pub fn remove_logical_subtree(&mut self, logical_root: &Path) { + for targets in self.targets.values_mut() { + targets.retain(|target| { + target.logical_path != logical_root + && !target.logical_path.starts_with(logical_root) + }); + } + self.targets.retain(|_, targets| !targets.is_empty()); + } + + pub fn normalize_event_path(&self, event_path: &Path, workspace_root: &Path) -> Vec { + let mut normalized = Vec::new(); + if event_path.starts_with(workspace_root) { + normalized.push(event_path.to_path_buf()); + } + + for (canonical, targets) in &self.targets { + for target in targets { + let mapped = if target.is_dir { + let Ok(suffix) = event_path.strip_prefix(canonical) else { + continue; + }; + if suffix.as_os_str().is_empty() { + target.logical_path.clone() + } else { + target.logical_path.join(suffix) + } + } else { + if event_path != canonical { + continue; + } + target.logical_path.clone() + }; + + if !normalized.iter().any(|path| path == &mapped) { + normalized.push(mapped); + } + } + } + + normalized + } + + pub fn watch_paths(&self, workspace_root: &Path) -> Vec { + let mut paths = Vec::new(); + for (canonical, targets) in &self.targets { + if canonical.starts_with(workspace_root) { + continue; + } + let needs_recursive = targets.iter().any(|target| target.is_dir); + if needs_recursive { + paths.push(WatchPath { + path: canonical.clone(), + mode: WatchMode::Recursive, + }); + if let Some(parent) = canonical.parent() { + paths.push(WatchPath { + path: parent.to_path_buf(), + mode: WatchMode::NonRecursive, + }); + } + } else if let Some(parent) = canonical.parent() { + paths.push(WatchPath { + path: parent.to_path_buf(), + mode: WatchMode::NonRecursive, + }); + } + } + paths.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then((a.mode as u8).cmp(&(b.mode as u8))) + }); + paths.dedup(); + paths + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_external_dir_events_to_logical_paths() { + let root = Path::new("/workspace"); + let mut map = SymlinkMap::default(); + map.insert( + PathBuf::from("/workspace/deps/ext"), + PathBuf::from("/outside/ext"), + true, + ); + + assert_eq!( + map.normalize_event_path(Path::new("/outside/ext/note.md"), root), + vec![PathBuf::from("/workspace/deps/ext/note.md")] + ); + } + + #[test] + fn keeps_internal_target_and_symlink_paths() { + let root = Path::new("/workspace"); + let mut map = SymlinkMap::default(); + map.insert( + PathBuf::from("/workspace/links/docs"), + PathBuf::from("/workspace/docs"), + true, + ); + + assert_eq!( + map.normalize_event_path(Path::new("/workspace/docs/note.md"), root), + vec![ + PathBuf::from("/workspace/docs/note.md"), + PathBuf::from("/workspace/links/docs/note.md") + ] + ); + } + + #[test] + fn external_dir_watch_paths_include_target_and_parent() { + let root = Path::new("/workspace"); + let mut map = SymlinkMap::default(); + map.insert( + PathBuf::from("/workspace/links/docs"), + PathBuf::from("/outside/docs"), + true, + ); + + let paths = map.watch_paths(root); + + assert!(paths.contains(&WatchPath { + path: PathBuf::from("/outside/docs"), + mode: WatchMode::Recursive, + })); + assert!(paths.contains(&WatchPath { + path: PathBuf::from("/outside"), + mode: WatchMode::NonRecursive, + })); + } +} diff --git a/apps/desktop/src-tauri/src/watcher.rs b/apps/desktop/src-tauri/src/watcher.rs index 72d1876..1fe22d4 100644 --- a/apps/desktop/src-tauri/src/watcher.rs +++ b/apps/desktop/src-tauri/src/watcher.rs @@ -1,5 +1,6 @@ use crate::ignore::{is_gitignore_path, WorkspaceIgnore}; use crate::state::{self, AppState, WorkspaceState}; +use crate::symlink::{classify_path, is_markdown_path, WatchMode}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; use std::path::Path; @@ -126,6 +127,7 @@ pub fn record_write(state: &WorkspaceState, path: &Path) { /// `dirs_with_markdown` ancestry so the sidebar's "directory contains /// markdown" check returns true for newly-populated subtrees. fn add_to_index(state: &WorkspaceState, path: &Path, root: &Path) { + let info = classify_path(path).ok().flatten(); let mut index = state.file_index.write(); if index.iter().any(|f| f.path == path) { return; @@ -143,15 +145,34 @@ fn add_to_index(state: &WorkspaceState, path: &Path, root: &Path) { path: path.to_path_buf(), relative_path: rel, name, + is_symlink: info.as_ref().is_some_and(|info| info.is_symlink), + canonical_path: info.as_ref().and_then(|info| info.canonical_path.clone()), }); drop(index); + if let Some(info) = info { + if info.is_symlink { + if let Some(canonical) = info.canonical_path { + state + .symlink_targets + .write() + .insert(path.to_path_buf(), canonical, info.is_dir); + sync_symlink_watches(state); + } + } + } + state::register_ancestors(&mut state.dirs_with_markdown.write(), path, root); } /// Drop a single path from the file index and rebuild `dirs_with_markdown`. fn remove_from_index(state: &WorkspaceState, path: &Path, root: &Path) { state.file_index.write().retain(|f| f.path != path); + let symlink_still_exists = + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()); + if !symlink_still_exists { + state.symlink_targets.write().remove_logical_path(path); + } let index = state.file_index.read(); *state.dirs_with_markdown.write() = state::rebuild_dirs_from_index(&index, root); } @@ -169,6 +190,11 @@ fn remove_subtree_from_index(state: &WorkspaceState, dir: &Path, root: &Path) { .file_index .write() .retain(|f| !f.path.starts_with(&dir_with_sep) && f.path != dir); + let symlink_still_exists = + std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_symlink()); + if !symlink_still_exists { + state.symlink_targets.write().remove_logical_subtree(dir); + } let index = state.file_index.read(); *state.dirs_with_markdown.write() = state::rebuild_dirs_from_index(&index, root); } @@ -183,8 +209,10 @@ fn remove_subtree_from_index(state: &WorkspaceState, dir: &Path, root: &Path) { /// from search results until the workspace is reopened. fn add_subtree_to_index(state: &WorkspaceState, dir: &Path, root: &Path) { let cancel = Arc::new(AtomicBool::new(false)); - let (found, _) = crate::commands::search::index_workspace_impl(dir, cancel); + let (found, _, symlink_targets) = crate::commands::search::index_workspace_impl(dir, cancel); if found.is_empty() { + state.symlink_targets.write().extend(symlink_targets); + sync_symlink_watches(state); return; } @@ -209,10 +237,14 @@ fn add_subtree_to_index(state: &WorkspaceState, dir: &Path, root: &Path) { path: file.path, relative_path: rel, name: file.name, + is_symlink: file.is_symlink, + canonical_path: file.canonical_path, }); added.push(path); } } + state.symlink_targets.write().extend(symlink_targets); + sync_symlink_watches(state); if added.is_empty() { return; @@ -311,138 +343,150 @@ 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 normalized_paths = if let Some(ref root) = root_for_filter { + state + .symlink_targets + .read() + .normalize_event_path(raw_path, root) + } 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; - continue; - } + for path in normalized_paths { + let path = path.as_path(); + if let Some(ref root) = root_for_filter { + if should_ignore(path, root) { + wlog!("filter[should_ignore]: {}", 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(); + // `.gitignore` changes defer to a background rebuild. + if is_gitignore_path(path) { + wlog!("filter[gitignore-change]: {}", path.display()); + rebuild_ignore = true; + continue; + } - if is_workspace_ignored(&state, path, is_dir) { - wlog!("filter[workspace_ignore]: {}", 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(); - if is_self_write(&state, path) { - continue; - } + if is_workspace_ignored(&state, path, is_dir) { + wlog!("filter[workspace_ignore]: {}", path.display()); + continue; + } - let kind_str = match event_kind_str(&event.kind) { - Some(k) => k, - None => { - wlog!("filter[unmapped_kind]: {:?} {}", event.kind, path.display()); + if is_self_write(&state, path) { continue; } - }; - let payload = FileChangeEvent { - path: path.to_string_lossy().to_string(), - kind: kind_str.to_string(), - }; + let kind_str = match event_kind_str(&event.kind) { + Some(k) => k, + None => { + wlog!("filter[unmapped_kind]: {:?} {}", event.kind, path.display()); + continue; + } + }; - 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 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", ()); + continue; } - let _ = handle.emit_to(label.clone(), "settings:changed", ()); - continue; - } - wlog!("emit fs:file-changed kind={kind_str} {}", path.display()); - let _ = handle.emit_to(label.clone(), "fs:file-changed", &payload); - } + 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; - } + // 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; + } - // 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); + // 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 = is_markdown_path(path); + 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); } - } 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(), - }, - ); + // 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(), + }, + ); + } } } } @@ -461,6 +505,47 @@ pub fn start_watcher( Ok(watcher) } +/// Add watcher registrations for symlink targets discovered by directory reads +/// or indexing. Targets inside the workspace root are already covered by the +/// root recursive watch; external directories are watched recursively plus by +/// parent, and external file parents non-recursively, so atomic replacement of +/// the target is still observed. +pub fn sync_symlink_watches(state: &WorkspaceState) { + let Some(root) = state.workspace_root.read().clone() else { + return; + }; + let watch_paths = state.symlink_targets.read().watch_paths(&root); + if watch_paths.is_empty() { + return; + } + + let mut watched = state.symlink_watch_paths.write(); + let mut watcher_guard = state.watcher_handle.write(); + let Some(watcher) = watcher_guard.as_mut() else { + return; + }; + + for watch_path in watch_paths { + if watched.contains(&watch_path) { + continue; + } + let mode = match watch_path.mode { + WatchMode::Recursive => RecursiveMode::Recursive, + WatchMode::NonRecursive => RecursiveMode::NonRecursive, + }; + if let Err(error) = watcher.watch(&watch_path.path, mode) { + wlog!( + "failed to watch symlink target {}: {}", + watch_path.path.display(), + error + ); + } else { + watched.insert(watch_path.clone()); + wlog!("watch symlink target {}", watch_path.path.display()); + } + } +} + /// Rebuild the workspace gitignore matcher on a one-shot background thread, /// then swap it in and nudge the sidebar to re-read. Keeps the watcher's /// event loop free while the tree walk runs. diff --git a/apps/desktop/src/components/sidebar/file-tree-node.tsx b/apps/desktop/src/components/sidebar/file-tree-node.tsx index 164adcd..fb5e2c3 100644 --- a/apps/desktop/src/components/sidebar/file-tree-node.tsx +++ b/apps/desktop/src/components/sidebar/file-tree-node.tsx @@ -44,6 +44,26 @@ function FileIcon() { return ; } +function SymlinkBadge() { + return ( + + ); +} + interface FileTreeNodeProps { entry: DirEntry; depth: number; @@ -130,10 +150,11 @@ export const FileTreeNode = memo(function FileTreeNode({ style={{ paddingLeft: depth === 0 ? 10 : depth * 12 + 6 }} > e.preventDefault()} onClick={handleClick} onContextMenu={handleContextMenu} @@ -197,6 +222,7 @@ export const FileTreeNode = memo(function FileTreeNode({ )} + {entry.is_symlink ? : null}