diff --git a/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md b/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md new file mode 100644 index 0000000..107079d --- /dev/null +++ b/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md @@ -0,0 +1,640 @@ +# Phase 4: BBS Polish and Live Content - Research + +**Researched:** 2026-02-28 +**Domain:** ANSI art rendering, filesystem watching, directory tree, file metadata, status bar layout +**Confidence:** HIGH (core stack) / MEDIUM (ansi-to-tui ratatui-core compat) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### ANSI art splash screen +- Art loaded from a vault file named `splash.txt` in the vault root +- Colorful ANSI art style using the CGA palette — multi-color block characters +- If `splash.txt` is missing, show index.md normally — graceful degradation, no fallback banner +- Splash is rendered above index.md content (header + content below it) + +#### Timestamp & page metadata +- Last-modified timestamp displayed in the status bar (not inline in content) +- BBS-style format: "Last modified: Feb 25, 2026" +- Also show file size alongside timestamp, e.g. "Last modified: Feb 25, 2026 | 2.4 KB" +- Status bar layout: left = breadcrumb, right = metadata + keyboard hints + +#### Directory listing +- Accessed via a special `[[Directory]]` wiki-link (not a keyboard shortcut) +- Tree view display showing folder hierarchy with indentation +- Entries show name only — no timestamps or sizes in the listing itself +- Entries are navigable using existing Tab-cycle link model (Tab between entries, Enter to open) +- Directory is a virtual page that participates in navigation history + +#### Live reload +- Silent refresh — no visual indicator, content updates seamlessly +- Scroll position preserved on refresh — user stays where they are +- Watch scope: current file + vault directory (detect new/removed files for directory listing) +- Short debounce (~300ms) to avoid flickering from rapid saves + +### Claude's Discretion +- ANSI art parsing implementation (how to interpret color codes from splash.txt) +- Exact status bar spacing and truncation strategy when terminal is narrow +- Tree view indentation characters and styling +- notify crate configuration and event filtering +- Debounce implementation details + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| BBS-01 | User sees ANSI art splash screen on index.md | `ansi-to-tui` 8.0.1 crate parses ANSI SGR codes into ratatui `Text`; `Text` lines can be prepended to markdown render output via `Vec` concatenation | +| BBS-02 | User sees "last updated" file mtime on pages | `std::fs::metadata().modified()` returns `SystemTime`; convert via `duration_since(UNIX_EPOCH)` + pure Gregorian math or lightweight `gregorian` crate; `metadata().len()` gives file size in bytes | +| LIVE-01 | App watches filesystem for changes and auto-refreshes current page | `notify` 6.1.1 `RecommendedWatcher` with `std::sync::mpsc::channel`; watch parent directory (not the file itself) with `RecursiveMode::NonRecursive`; integrate via `try_recv()` in existing 250ms event loop; manual debounce with `Instant` | +| LIVE-02 | User can browse a vault-wide directory listing | `walkdir` 2.5.0 for recursive directory walk; `DirEntry.depth()` for tree indentation; `[[Directory]]` magic wiki-link intercepted in `vault::resolve_wiki_link` before filesystem lookup; virtual `DocumentState::Directory` variant | + + +--- + +## Summary + +Phase 4 adds four distinct capabilities that share no common library but all touch the existing `App` event loop and draw pipeline. The ANSI splash screen is the most dependency-sensitive item: `ansi-to-tui` 8.0.1 now depends on `ratatui-core 0.1.0` (extracted from ratatui in the 0.30 modularization) rather than `ratatui` directly. Because this project already uses `ratatui = "0.30.0"` and ratatui 0.30 re-exports all ratatui-core types, the two crates share types and the dependency will resolve correctly — but this must be verified at `cargo add` time. + +File metadata (BBS-02) requires no new dependency if a simple hand-rolled Gregorian conversion is acceptable. The calculation — convert unix seconds to year/month/day — is ~30 lines of pure arithmetic. The `gregorian` crate (0.2.4) is an alternative if a dependency is preferred. File size formatting (e.g. "2.4 KB") is straightforward arithmetic with no crate needed. + +Live reload (LIVE-01) is the most architecturally significant change. The existing synchronous event loop uses `event::poll(Duration::from_millis(250))` — a blocking call. Integrating `notify` requires a non-blocking `try_recv()` pattern: the notify watcher runs in its own OS thread (spawned internally), sends events through an `mpsc` channel, and the event loop calls `try_recv()` each iteration. Manual debouncing with `Instant::now()` and a 300ms threshold is cleaner than pulling in `notify-debouncer-mini` for this use case. Watching the parent directory rather than the file itself is mandatory because many editors (vim, neovim) save by writing a temp file and renaming, which causes the original inode to be removed — a file-level watch loses the target and stops firing. + +**Primary recommendation:** Add `notify = "6.1"`, `ansi-to-tui = "8.0"`, and `walkdir = "2.5"` to Cargo.toml. Use manual debounce with `Instant` rather than `notify-debouncer-mini`. Hand-roll the date formatting to avoid a new time library dependency. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `notify` | 6.1.1 | Cross-platform filesystem event notifications | Used by rust-analyzer, deno, cargo-watch; the standard choice for sync file watching | +| `ansi-to-tui` | 8.0.1 | Parse ANSI SGR escape codes → ratatui `Text` | Official ratatui-org crate; only parser targeting ratatui types directly | +| `walkdir` | 2.5.0 | Recursive directory traversal with depth tracking | BurntSushi crate; used everywhere, returns `DirEntry` with `.depth()` for tree indentation | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `gregorian` | 0.2.4 | Unix timestamp → year/month/day | Only if hand-rolled Gregorian math is undesirable | +| `notify-debouncer-mini` | 0.7.0 | Debounced filesystem events | Only if manual debounce becomes complex; adds a thread | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `ansi-to-tui` | Hand-roll ANSI SGR parser | ansi-to-tui uses `nom` 8 for robust parsing; hand-rolled would miss edge cases (256-color, reset codes, nested SGR) | +| Hand-rolled Gregorian math | `gregorian` 0.2.4 or `chrono` 0.4 | Hand-rolled is ~30 lines, no dependency, sufficient for "Jan–Dec" abbreviated month names | +| Manual debounce | `notify-debouncer-mini` 0.7.0 | Debouncer-mini spawns an extra thread and callback; manual debounce is simpler in a synchronous event loop | +| `walkdir` | `std::fs::read_dir` recursion | `walkdir` handles symlink loops and provides `.depth()` directly; manual recursion requires a stack | + +**Installation:** +```toml +# Cargo.toml additions +notify = "6.1" +ansi-to-tui = "8.0" +walkdir = "2.5" +``` + +--- + +## Architecture Patterns + +### Recommended Module Structure +``` +src/ +├── app.rs # Add: watcher field, debounce state, handle_file_change(), virtual directory state +├── vault.rs # Add: list_vault_files(), resolve_wiki_link intercepts "Directory" +├── renderer.rs # Add: render_directory_listing() → (Vec, Vec) +├── splash.rs # NEW: load_splash() → Option>> +└── main.rs # Add: init watcher after terminal init, pass rx to App::new() +``` + +### Pattern 1: Notify Watcher — Parent Directory Watch + try_recv Integration + +**What:** Watch the parent directory with `RecursiveMode::NonRecursive`, filter events by filename match in the event handler, use `try_recv()` in the existing event loop (not a separate thread). + +**Why parent dir:** Editors like vim write a temp file then rename it over the target. The original inode is deleted; a file-level watch stops firing after the first save. Watching the parent directory with a filename filter catches the rename/create that replaces the file. + +**Watch scope decisions:** +- For a regular document: watch its parent directory, filter events to match `current_path` filename +- For the directory listing page: watch the vault root (NonRecursive) to detect new/removed `.md` files + +**Source:** Official notify docs + GitHub issue #113 (no notifications after saving twice in vim) + +```rust +// Source: https://docs.rs/notify/6.1.1/notify/ +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use std::sync::mpsc::{channel, Receiver}; +use std::time::{Duration, Instant}; + +pub struct FileWatcher { + _watcher: RecommendedWatcher, // kept alive; drop would stop watching + pub rx: Receiver>, + pub last_event: Instant, // for manual debounce + pub watched_dir: PathBuf, +} + +impl FileWatcher { + pub fn new(dir: &Path) -> notify::Result { + let (tx, rx) = channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + watcher.watch(dir, RecursiveMode::NonRecursive)?; + Ok(FileWatcher { + _watcher: watcher, + rx, + last_event: Instant::now(), + watched_dir: dir.to_path_buf(), + }) + } + + /// Re-point the watcher at a new directory. + pub fn rewatch(&mut self, new_dir: &Path) -> notify::Result<()> { + self._watcher.unwatch(&self.watched_dir)?; + self._watcher.watch(new_dir, RecursiveMode::NonRecursive)?; + self.watched_dir = new_dir.to_path_buf(); + Ok(()) + } +} + +// In App::run_event_loop — add after event::poll block: +if let Ok(Ok(event)) = self.file_watcher.rx.try_recv() { + // Filter: only act if an event path matches current_path filename + let target = Path::new(&self.current_path).file_name(); + let relevant = event.paths.iter().any(|p| p.file_name() == target); + if relevant { + self.file_watcher.last_event = Instant::now(); + } +} +// Debounce: fire reload only after 300ms of silence +if self.file_watcher.last_event.elapsed() > Duration::from_millis(300) + && self.pending_reload +{ + self.reload_current_document(); + self.pending_reload = false; +} +``` + +### Pattern 2: ANSI Splash Screen via ansi-to-tui + +**What:** Load `splash.txt` as raw bytes, parse with `ansi-to-tui`'s `IntoText` trait, prepend resulting `Vec` to the rendered markdown lines before storing in `DocumentState::Loaded`. + +**How multi-line works:** `ansi-to-tui` returns `Text` which has a `.lines: Vec>` field. Prepend these lines to the markdown render output. The combined `Vec` is stored in `DocumentState::Loaded.lines` as usual — no new render path needed. + +**Source:** https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/ + +```rust +// Source: https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/ +use ansi_to_tui::IntoText; + +pub fn load_splash(vault_path: &Path) -> Option>> { + let splash_path = vault_path.join("splash.txt"); + let bytes = std::fs::read(&splash_path).ok()?; + let text = bytes.into_text().ok()?; + Some(text.lines) +} + +// In main.rs or wherever index.md is rendered: +let (mut lines, link_records) = renderer::render_markdown(&content, width, Some(&vault_path)); +if let Some(mut splash_lines) = splash::load_splash(&vault_path) { + splash_lines.push(Line::default()); // blank separator + splash_lines.extend(lines); + lines = splash_lines; +} +``` + +### Pattern 3: File Metadata — Timestamp and Size + +**What:** After `vault::load_document()` succeeds, call `std::fs::metadata()` on the full path to get `modified()` and `len()`. Store as optional metadata in `App` state. Format in `draw_status_bar()`. + +**Date formatting without external crate:** Convert `SystemTime` to unix seconds via `duration_since(UNIX_EPOCH)`, then compute year/month/day with Gregorian arithmetic. + +```rust +// Source: https://doc.rust-lang.org/std/fs/struct.Metadata.html +use std::time::UNIX_EPOCH; + +pub struct FileMetadata { + pub size_bytes: u64, + pub modified_display: String, // "Feb 25, 2026" + pub size_display: String, // "2.4 KB" +} + +pub fn read_file_metadata(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let size_bytes = meta.len(); + let modified = meta.modified().ok()?; + let secs = modified.duration_since(UNIX_EPOCH).ok()?.as_secs(); + + let modified_display = format_unix_date(secs); + let size_display = format_file_size(size_bytes); + + Some(FileMetadata { size_bytes, modified_display, size_display }) +} + +fn format_file_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +// Pure Gregorian math — no dependency +fn format_unix_date(unix_secs: u64) -> String { + const MONTH_NAMES: [&str; 12] = [ + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec", + ]; + let (year, month, day) = unix_secs_to_ymd(unix_secs); + format!("{} {}, {}", MONTH_NAMES[(month - 1) as usize], day, year) +} + +fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) { + // Days since epoch + let mut days = (secs / 86400) as u32; + let mut year = 1970u32; + loop { + let leap = is_leap(year); + let days_in_year = if leap { 366 } else { 365 }; + if days < days_in_year { break; } + days -= days_in_year; + year += 1; + } + let leap = is_leap(year); + let month_days: [u32; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 0u32; + for (i, &d) in month_days.iter().enumerate() { + if days < d { month = i as u32 + 1; break; } + days -= d; + } + (year, month, days + 1) +} + +fn is_leap(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} +``` + +### Pattern 4: Virtual Directory Page via walkdir + +**What:** Intercept `[[Directory]]` in `vault::resolve_wiki_link` before the filesystem scan. Return a sentinel value (e.g., `None` for a file, but navigate via a new `navigate_to_directory()` path). Render a tree view as `Vec>` with `LinkRecord`s pointing to vault-relative paths. Store as `DocumentState::Loaded` with a special filename `"[Directory]"`. + +**walkdir API:** + +```rust +// Source: https://docs.rs/walkdir/2.5.0/walkdir/ +use walkdir::WalkDir; + +pub fn list_vault_files(vault_path: &Path) -> Vec<(usize, String, bool)> { + // Returns (depth, display_name, is_dir) tuples, sorted alphabetically + let mut entries: Vec<(usize, String, bool)> = Vec::new(); + for entry in WalkDir::new(vault_path) + .sort_by_file_name() // alphabetical + .into_iter() + .filter_map(|e| e.ok()) + .skip(1) // skip vault root itself (depth 0) + { + let depth = entry.depth(); + let is_dir = entry.file_type().is_dir(); + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip non-markdown files (except directories) + if !is_dir && !name.ends_with(".md") { + continue; + } + // Skip hidden files/dirs + if name.starts_with('.') { + continue; + } + entries.push((depth, name, is_dir)); + } + entries +} +``` + +**Tree indentation characters (Claude's discretion — recommended):** +- `" "` repeated `depth - 1` times (2 spaces per level), no box-drawing connectors +- Directories: yellow, bold, display name without `.md` +- Files: cyan (matching existing link style), display name without `.md` +- Selected entry: existing `Modifier::REVERSED` highlight (same as all links) + +**`[[Directory]]` magic link interception in vault.rs:** +```rust +// In resolve_wiki_link, before the filesystem scan: +pub fn resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option { + // Magic sentinel — caller checks for this special string + // Return None so App can detect unresolved wiki-link and route to directory + // OR use a dedicated sentinel path like PathBuf::from("__directory__") + if raw_target.eq_ignore_ascii_case("directory") { + return Some(PathBuf::from("__directory__")); + } + // ... existing filesystem scan ... +} +``` + +Then in `App::follow_selected_link()`, check `resolved == Path::new("__directory__")` and call `navigate_to_directory()` instead of `navigate_to()`. + +### Anti-Patterns to Avoid + +- **Watching the file itself (not the parent dir):** Vim/neovim write a new file and rename it over the target. The inode being watched is deleted; the watcher fires a `Remove` event and stops receiving `Modify` events for subsequent saves. Always watch the parent directory with a filename filter. +- **Blocking `rx.recv()` in the event loop:** The existing loop already blocks on `event::poll(250ms)`. A second blocking recv would serialize input handling and file watching instead of interleaving them. Use `try_recv()` which returns immediately. +- **Caching rendered directory output:** The directory listing must be re-generated on every navigation to it (and on live-reload when watching vault root), otherwise it will show stale entries. Re-render from walkdir each time. +- **Storing `Text<'a>` from ansi-to-tui with lifetime:** ansi-to-tui returns `Text<'static>` (all strings are owned), but only if the input bytes are `'static`. When reading from a file at runtime, parse immediately and store `Vec>` by calling `.lines` on the `Text` result. Do not store the `Text` struct with a borrow of the file bytes. +- **Drawing splash as a separate widget:** Prepend splash lines to the content `Vec` before storing in `DocumentState::Loaded`. This keeps the single-Paragraph draw path intact; no Layout changes needed. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| ANSI SGR parsing | Custom escape code tokenizer | `ansi-to-tui` 8.0.1 | SGR handles nested attributes, 256-color, true-color, reset codes — edge cases multiply quickly | +| Recursive directory walk with depth tracking | Custom `read_dir` recursion stack | `walkdir` 2.5.0 | Symlink loops, permission errors, cross-device paths require robust error handling that walkdir provides | +| Filesystem event delivery | Manually calling `stat()` each loop iteration | `notify` 6.1.1 | Platform-specific (inotify/kqueue/FSEvents) — `notify` handles all three transparently | + +**Key insight:** ANSI parsing looks simple ("just split on `\x1b[`") but breaks on multi-attribute sequences, partial resets, and terminal-specific extensions. The `ansi-to-tui` crate uses `nom` 8 as a proper parser combinator, which handles all of these cases. + +--- + +## Common Pitfalls + +### Pitfall 1: Editor Save — Watcher Stops Firing After First Save + +**What goes wrong:** You watch `vault/file.md` directly. The user saves in vim once — you see a `Modify` event. They save again — nothing. The watcher has gone silent. + +**Why it happens:** vim's default write mode (`nowritebackup` off) renames a temp file over the target. The inode that was being watched is deleted (`Remove` event). The new file at the same path has a different inode; the old watch descriptor is now invalid. + +**How to avoid:** Watch the parent directory with `RecursiveMode::NonRecursive`. Filter events by checking `event.paths` for a path whose `file_name()` matches the target file. This survives atomic writes. + +**Warning signs:** Works correctly the first save, silently stops working on second save. + +**Source:** https://github.com/notify-rs/notify/issues/113 and watchfiles issue #215 + +### Pitfall 2: Event Loop Blocked — Blocking recv in a Synchronous Loop + +**What goes wrong:** Adding `rx.recv()` inside `run_event_loop()` alongside `event::poll(250ms)`. The loop either hangs waiting for a file event or hangs waiting for a keyboard event. + +**Why it happens:** Both `event::poll` and `Receiver::recv` block. You cannot block on both simultaneously without threads. + +**How to avoid:** Use `rx.try_recv()` which returns immediately with `Err(TryRecvError::Empty)` when no events are queued. The existing 250ms `event::poll` timeout keeps the loop responsive; check `try_recv()` once per iteration. + +**Warning signs:** Keyboard input becomes sluggish or freezes when no file events are happening (or vice versa). + +### Pitfall 3: ansi-to-tui / ratatui-core Type Mismatch + +**What goes wrong:** `cargo build` fails with "expected `ratatui::text::Text`, found `ratatui_core::text::Text`" or similar type mismatch errors. + +**Why it happens:** `ansi-to-tui` 8.0.1 depends on `ratatui-core 0.1.0` (a sub-crate extracted in ratatui 0.30). If Cargo resolves two different versions of `ratatui-core` (one via `ratatui = "0.30"` and another directly), types become incompatible even though they look identical. + +**How to avoid:** Do not add `ratatui-core` as a direct dependency. Only add `ratatui = "0.30"` and `ansi-to-tui = "8.0"`. Cargo should unify the `ratatui-core` transitive dependency to a single version. If it does not, add a `[patch]` section or explicit `ratatui-core = "0.1"` to force unification. + +**Warning signs:** `cargo build` fails immediately after adding `ansi-to-tui` with type mismatch errors involving `ratatui_core` vs `ratatui`. + +**Verification step:** Run `cargo tree | grep ratatui-core` after adding the dependency. There should be exactly one version. + +### Pitfall 4: Splash Lines Lose 'static Lifetime + +**What goes wrong:** Compiler errors about lifetime of `Text<'a>` when `ansi-to-tui` parses borrowed bytes. + +**Why it happens:** If you borrow the bytes (e.g., from a `Vec` that you then try to drop), the resulting `Text<'a>` borrows from the source. The existing `DocumentState` stores `Vec>`. + +**How to avoid:** Read splash bytes into an owned `Vec` with `std::fs::read()`, then call `.into_text()?` on the owned vec. The owned bytes produce owned spans, yielding `Text<'static>`. Call `.lines` immediately to get `Vec>`. + +### Pitfall 5: Directory Listing Stale After Vault File Changes + +**What goes wrong:** User creates a new `.md` file in vault. They navigate to `[[Directory]]`. The listing still shows the old files. + +**Why it happens:** The directory listing was generated once at navigation time and cached. The live-reload watcher fires for the new file but the directory page has no mechanism to refresh. + +**How to avoid:** When the current page is the directory listing (detect via `current_path == "__directory__"`), re-generate the listing whenever a relevant vault root event arrives. Treat the directory listing as ephemeral — always render fresh, never cache. + +### Pitfall 6: Status Bar Overflow on Narrow Terminals + +**What goes wrong:** "Last modified: Feb 25, 2026 | 2.4 KB Tab:Links Enter:Go Bksp:Back q:Quit" exceeds the terminal width and wraps or corrupts the display. + +**Why it happens:** The current `draw_status_bar` builds left and right strings and pads between them. On narrow terminals (e.g., 60 cols), the right side is wider than the available space. + +**How to avoid:** Implement graceful truncation: if `left.len() + right.len() >= width`, progressively drop the metadata fields (size first, then date, then hints abbreviation). Reserve 20 chars minimum for breadcrumb. Use `width.saturating_sub()` to prevent panic. + +--- + +## Code Examples + +Verified patterns from official sources: + +### notify — Non-blocking Integration in Synchronous Event Loop +```rust +// Source: https://docs.rs/notify/6.1.1/notify/ + https://doc.rust-lang.org/std/sync/mpsc/struct.Receiver.html +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use std::sync::mpsc::{self, TryRecvError}; + +// Setup (once, at app init): +let (tx, rx) = mpsc::channel(); +let mut watcher = RecommendedWatcher::new(tx, Config::default())?; +watcher.watch(parent_dir, RecursiveMode::NonRecursive)?; + +// In event loop — each iteration, AFTER event::poll(): +match rx.try_recv() { + Ok(Ok(event)) => { + // Check if event concerns our current file + let filename = Path::new(&self.current_path).file_name(); + if event.paths.iter().any(|p| p.file_name() == filename) { + self.pending_reload_at = Some(Instant::now()); + } + } + Ok(Err(e)) => { /* watch error — log to debug, ignore */ } + Err(TryRecvError::Empty) => { /* no events this tick */ } + Err(TryRecvError::Disconnected) => { /* watcher dropped — recreate */ } +} + +// Debounce: fire reload 300ms after last relevant event +if let Some(t) = self.pending_reload_at { + if t.elapsed() >= Duration::from_millis(300) { + self.reload_current_document(); + self.pending_reload_at = None; + } +} +``` + +### ansi-to-tui — Parse splash.txt +```rust +// Source: https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/ +use ansi_to_tui::IntoText; +use ratatui::text::Line; + +pub fn load_splash(vault_path: &Path) -> Option>> { + let bytes: Vec = std::fs::read(vault_path.join("splash.txt")).ok()?; + let text = bytes.into_text().ok()?; + Some(text.lines) +} +``` + +### walkdir — Build Directory Listing +```rust +// Source: https://docs.rs/walkdir/2.5.0/walkdir/ +use walkdir::WalkDir; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Returns (vault_relative_path, display_line) pairs. +/// Caller converts display_lines into Vec> and builds LinkRecords. +pub fn build_directory_listing(vault_path: &Path) -> Vec<(Option, Line<'static>)> { + let mut out = Vec::new(); + for entry in WalkDir::new(vault_path) + .sort_by_file_name() + .into_iter() + .filter_map(|e| e.ok()) + .skip(1) // skip vault root itself + { + let depth = entry.depth(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { continue; } + + let is_dir = entry.file_type().is_dir(); + let indent = " ".repeat(depth - 1); + + if is_dir { + // Directory: yellow bold, no link + let line = Line::from(vec![ + Span::raw(indent), + Span::styled(name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]); + out.push((None, line)); + } else if name.ends_with(".md") { + // File: cyan link + let display = name.strip_suffix(".md").unwrap_or(&name).to_string(); + // vault_relative_path for LinkRecord.dest + let rel = entry.path() + .strip_prefix(vault_path) + .unwrap() + .to_string_lossy() + .to_string(); + let line = Line::from(vec![ + Span::raw(indent), + Span::styled(format!("[{}]", display), Style::default().fg(Color::LightCyan)), + ]); + out.push((Some(rel), line)); + } + } + out +} +``` + +### File Metadata — Timestamp and Size +```rust +// Source: https://doc.rust-lang.org/std/fs/struct.Metadata.html +use std::time::UNIX_EPOCH; +use std::path::Path; + +pub struct PageMeta { + pub modified: String, // "Feb 25, 2026" + pub size: String, // "2.4 KB" +} + +pub fn read_page_meta(full_path: &Path) -> Option { + let meta = std::fs::metadata(full_path).ok()?; + let secs = meta.modified().ok()? + .duration_since(UNIX_EPOCH).ok()? + .as_secs(); + let (y, m, d) = unix_secs_to_ymd(secs); + let months = ["Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec"]; + Some(PageMeta { + modified: format!("{} {}, {}", months[(m-1) as usize], d, y), + size: format_size(meta.len()), + }) +} + +fn format_size(bytes: u64) -> String { + match bytes { + 0..=1023 => format!("{} B", bytes), + 1024..=1_048_575 => format!("{:.1} KB", bytes as f64 / 1024.0), + _ => format!("{:.1} MB", bytes as f64 / 1_048_576.0), + } +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `notify` 4.x/5.x synchronous blocking API | `notify` 6.x callback/channel API; `RecommendedWatcher::new(tx, Config)` | ~2022 | API breaking change; old `raw_watch`, `op::Op` flags gone | +| `tui` crate (fork) | `ratatui` 0.30 with modular `ratatui-core` sub-crate | 2024–2025 | `ansi-to-tui` 8.x now depends on `ratatui-core` not `ratatui` directly | +| `ansi-to-tui` 3.x/4.x targeting `tui`/older `ratatui` | `ansi-to-tui` 8.x targeting `ratatui-core 0.1` | 2025 | Must use 8.x with ratatui 0.30 | + +**Deprecated/outdated:** +- `notify` 4.x/5.x: `RawEvent`, `Op` flags, and `Watcher::new()` with `Duration` parameter — replaced by event-based API in 6.x +- `ansi-to-tui` versions below 6.x: target incompatible ratatui versions + +--- + +## Open Questions + +1. **ansi-to-tui 8.0.1 + ratatui 0.30 type unification** + - What we know: `ansi-to-tui` 8.0.1 depends on `ratatui-core = "0.1.0"`; `ratatui = "0.30.0"` provides `ratatui-core` as a workspace member/re-export + - What's unclear: Cargo's dependency resolver might or might not unify these without explicit version pinning + - Recommendation: After `cargo add ansi-to-tui@8`, immediately run `cargo tree | grep ratatui-core` and verify a single version appears. If two appear, pin with `ratatui-core = "0.1"` in `[dependencies]`. + +2. **notify version — 6.1.1 vs latest** + - What we know: Latest stable seen is 6.1.1; `cargo search` showed `9.0.0-rc.2` as a pre-release + - What's unclear: Whether 9.0.0 has breaking API changes vs 6.1.1 + - Recommendation: Pin to `notify = "6.1"` (stable) until 9.x is stable; avoid the RC. + +3. **macOS kqueue vs Linux inotify behavior differences** + - What we know: `RecommendedWatcher` selects kqueue on macOS and inotify on Linux + - What's unclear: Whether the parent-directory watch pattern for atomic saves behaves identically on both platforms + - Recommendation: Test save-with-vim on both platforms during implementation. On macOS, kqueue watches directories natively; the parent-dir pattern should work. + +4. **Watcher lifetime — where to store in App** + - What we know: `RecommendedWatcher` must be kept alive (dropping stops watching); `_watcher` must live as long as `App` + - What's unclear: Whether `RecommendedWatcher` is `Send + Sync` (needed if App is moved across closures) + - Recommendation: Store `Option` in `App` (None when watching directory listing). `RecommendedWatcher` is `Send` in 6.x. + +--- + +## Sources + +### Primary (HIGH confidence) +- https://docs.rs/notify/6.1.1/notify/ — watcher API, event types, Config, known problems +- https://docs.rs/notify/6.1.1/notify/event/struct.Event.html — Event.paths, EventKind +- https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/ — IntoText trait, supported SGR features +- https://raw.githubusercontent.com/ratatui/ansi-to-tui/main/Cargo.toml — confirmed `ratatui-core = "0.1.0"` dependency +- https://docs.rs/walkdir/2.5.0/walkdir/ — WalkDir, DirEntry.depth(), sort_by_file_name() +- https://docs.rs/walkdir/2.5.0/walkdir/struct.DirEntry.html — depth(), file_type(), file_name() +- https://doc.rust-lang.org/std/fs/struct.Metadata.html — modified(), len(), SystemTime +- https://doc.rust-lang.org/std/sync/mpsc/struct.Receiver.html — try_recv(), TryRecvError +- https://docs.rs/ratatui/0.30.0/ratatui/text/struct.Text.html — Text.lines, extend, push_line + +### Secondary (MEDIUM confidence) +- https://oneuptime.com/blog/post/2026-01-25-file-watcher-debouncing-rust/view — manual debounce with Instant pattern (verified against notify docs) +- https://users.rust-lang.org/t/how-to-get-year-month-day-etc-from-systemtime/84588 — date math options (verified against stdlib docs) + +### Tertiary (LOW confidence) +- GitHub issue notify-rs/notify#113 (no notifications after double save in vim) — via WebSearch summary; pattern confirmed by watchfiles issue #215 + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack (notify, walkdir, ansi-to-tui): HIGH — all verified via official docs +- Architecture patterns: HIGH — derived from verified API signatures +- ansi-to-tui ratatui-core compat: MEDIUM — Cargo.toml confirmed dependency but runtime type unification not directly tested +- notify vim/atomic-save pitfall: MEDIUM — confirmed via two independent issues, pattern consistent with inotify documentation +- Date formatting algorithm: HIGH — pure math, no external dependency + +**Research date:** 2026-02-28 +**Valid until:** 2026-03-28 (notify 9.x RC may stabilize; ansi-to-tui compat verified at add time)