Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
36 changes: 36 additions & 0 deletions SPECs/Agent/worksheet-symlink-file-view-edit-watch.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions SPECs/symlink-file-view-edit-watch-spec.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
189 changes: 164 additions & 25 deletions apps/desktop/src-tauri/src/commands/fs.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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.
Expand Down Expand Up @@ -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<PathBuf>,
) -> 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;
}
}
Expand Down Expand Up @@ -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();

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

Expand Down Expand Up @@ -266,7 +309,8 @@ pub async fn read_file(path: String) -> Result<FileContent, AppError> {
}

pub fn write_file_impl(path: &str, content: &str) -> Result<WriteResult, AppError> {
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
Expand All @@ -278,7 +322,7 @@ pub fn write_file_impl(path: &str, content: &str) -> Result<WriteResult, AppErro

Ok(WriteResult {
path: path.to_string(),
modified_at: modified_time(&file_path),
modified_at: modified_time(&logical_path),
})
}

Expand All @@ -294,7 +338,13 @@ pub async fn write_file(
// the echo — if another window is watching the same workspace it
// still sees a genuine file-changed event.
let state = app.state::<AppState>().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
}
Expand Down Expand Up @@ -336,6 +386,7 @@ pub fn create_directory_impl(path: &str) -> Result<DirEntry, AppError> {
path: path.to_string(),
is_dir: true,
is_markdown: false,
is_symlink: false,
modified_at: modified_time(&dir_path),
title: None,
})
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading