641 lines
32 KiB
Markdown
641 lines
32 KiB
Markdown
# 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 "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<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 | 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<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)
|