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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions SPECs/Agent/worksheet-symlink-support.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions SPECs/symlink-support-spec.md
Original file line number Diff line number Diff line change
@@ -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/`.
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 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.
Expand Down
133 changes: 121 additions & 12 deletions apps/desktop/src-tauri/src/commands/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PathBuf>,
) -> 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;
}
}
Expand Down Expand Up @@ -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();

Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -266,7 +291,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 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
Expand All @@ -278,7 +304,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(&requested_path),
})
}

Expand All @@ -294,7 +320,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 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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading