Files

641 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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
</user_constraints>
---
<phase_requirements>
## 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<Line>` 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 |
</phase_requirements>
---
## 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 "JanDec" 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<Line>, Vec<LinkRecord>)
├── splash.rs # NEW: load_splash() → Option<Vec<Line<'static>>>
└── 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<notify::Result<notify::Event>>,
pub last_event: Instant, // for manual debounce
pub watched_dir: PathBuf,
}
impl FileWatcher {
pub fn new(dir: &Path) -> notify::Result<Self> {
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<Line>` to the rendered markdown lines before storing in `DocumentState::Loaded`.
**How multi-line works:** `ansi-to-tui` returns `Text` which has a `.lines: Vec<Line<'static>>` field. Prepend these lines to the markdown render output. The combined `Vec<Line>` 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<Vec<Line<'static>>> {
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<FileMetadata> {
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<Line<'static>>` 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<PathBuf> {
// 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<Line<'static>>` 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<Line>` 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<u8>` that you then try to drop), the resulting `Text<'a>` borrows from the source. The existing `DocumentState` stores `Vec<Line<'static>>`.
**How to avoid:** Read splash bytes into an owned `Vec<u8>` 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<Line<'static>>`.
### 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<Vec<Line<'static>>> {
let bytes: Vec<u8> = 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<Line<'static>> and builds LinkRecords.
pub fn build_directory_listing(vault_path: &Path) -> Vec<(Option<String>, 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<PageMeta> {
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 | 20242025 | `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<FileWatcher>` 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)