Files
bbs-md/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md
T

32 KiB
Raw Blame History

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:

# Cargo.toml additions
notify = "6.1"
ansi-to-tui = "8.0"
walkdir = "2.5"

Architecture Patterns

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)

// 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/

// 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.

// 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 LinkRecords pointing to vault-relative paths. Store as DocumentState::Loaded with a special filename "[Directory]".

walkdir API:

// 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:

// 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

// 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

// 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

// 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

// 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)

Secondary (MEDIUM confidence)

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)