32 KiB
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.txtin the vault root - Colorful ANSI art style using the CGA palette — multi-color block characters
- If
splash.txtis 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:
# 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_pathfilename - For the directory listing page: watch the vault root (NonRecursive) to detect new/removed
.mdfiles
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):
" "repeateddepth - 1times (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::REVERSEDhighlight (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
Removeevent and stops receivingModifyevents for subsequent saves. Always watch the parent directory with a filename filter. - Blocking
rx.recv()in the event loop: The existing loop already blocks onevent::poll(250ms). A second blocking recv would serialize input handling and file watching instead of interleaving them. Usetry_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 returnsText<'static>(all strings are owned), but only if the input bytes are'static. When reading from a file at runtime, parse immediately and storeVec<Line<'static>>by calling.lineson theTextresult. Do not store theTextstruct with a borrow of the file bytes. - Drawing splash as a separate widget: Prepend splash lines to the content
Vec<Line>before storing inDocumentState::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 |
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:
notify4.x/5.x:RawEvent,Opflags, andWatcher::new()withDurationparameter — replaced by event-based API in 6.xansi-to-tuiversions below 6.x: target incompatible ratatui versions
Open Questions
-
ansi-to-tui 8.0.1 + ratatui 0.30 type unification
- What we know:
ansi-to-tui8.0.1 depends onratatui-core = "0.1.0";ratatui = "0.30.0"providesratatui-coreas 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 runcargo tree | grep ratatui-coreand verify a single version appears. If two appear, pin withratatui-core = "0.1"in[dependencies].
- What we know:
-
notify version — 6.1.1 vs latest
- What we know: Latest stable seen is 6.1.1;
cargo searchshowed9.0.0-rc.2as 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.
- What we know: Latest stable seen is 6.1.1;
-
macOS kqueue vs Linux inotify behavior differences
- What we know:
RecommendedWatcherselects 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.
- What we know:
-
Watcher lifetime — where to store in App
- What we know:
RecommendedWatchermust be kept alive (dropping stops watching);_watchermust live as long asApp - What's unclear: Whether
RecommendedWatcherisSend + Sync(needed if App is moved across closures) - Recommendation: Store
Option<FileWatcher>inApp(None when watching directory listing).RecommendedWatcherisSendin 6.x.
- What we know:
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)