diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8d83ed9..8b81c8c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -75,7 +75,11 @@ Plans: 2. Each page shows the file's last-modified timestamp 3. When a markdown file in the vault is modified on disk, the currently displayed page auto-refreshes without the user doing anything 4. User can navigate to a vault-wide directory listing showing all available documents -**Plans**: TBD +**Plans:** 3 plans +Plans: +- [ ] 04-01-PLAN.md — ANSI splash screen on index.md and file metadata in status bar +- [ ] 04-02-PLAN.md — Virtual directory listing via [[Directory]] wiki-link +- [ ] 04-03-PLAN.md — Live filesystem watching with auto-refresh ## Progress @@ -87,4 +91,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 | 1. Safety Foundation | 3/3 | Complete | 2026-02-28 | | 2. Vault Core and Rendering | 3/3 | Complete | 2026-02-28 | | 3. Navigation and Links | 2/2 | Complete | 2026-02-28 | -| 4. BBS Polish and Live Content | 0/TBD | Not started | - | +| 4. BBS Polish and Live Content | 0/3 | Not started | - | diff --git a/.planning/phases/04-bbs-polish-and-live-content/04-01-PLAN.md b/.planning/phases/04-bbs-polish-and-live-content/04-01-PLAN.md new file mode 100644 index 0000000..c5c7d4e --- /dev/null +++ b/.planning/phases/04-bbs-polish-and-live-content/04-01-PLAN.md @@ -0,0 +1,230 @@ +--- +phase: 04-bbs-polish-and-live-content +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - Cargo.toml + - src/splash.rs + - src/app.rs + - src/main.rs +autonomous: true +requirements: + - BBS-01 + - BBS-02 + +must_haves: + truths: + - "When splash.txt exists in vault root, index.md page displays ANSI art header above the markdown content" + - "When splash.txt is missing, index.md renders normally with no error or fallback banner" + - "Every loaded page shows 'Last modified: Mon DD, YYYY | X.X KB' in the right side of the status bar" + - "Status bar left side still shows breadcrumb, right side now shows metadata + keyboard hints" + artifacts: + - path: "src/splash.rs" + provides: "load_splash() function parsing ANSI art from splash.txt into Vec>" + min_lines: 10 + - path: "src/app.rs" + provides: "PageMeta struct, read_page_meta(), status bar metadata display, splash prepend logic" + contains: "PageMeta" + - path: "Cargo.toml" + provides: "ansi-to-tui, notify, walkdir dependencies" + contains: "ansi-to-tui" + key_links: + - from: "src/splash.rs" + to: "ansi-to-tui crate" + via: "IntoText trait on Vec" + pattern: "into_text" + - from: "src/app.rs" + to: "src/splash.rs" + via: "splash::load_splash() called during navigate_to for index.md" + pattern: "load_splash" + - from: "src/app.rs" + to: "std::fs::metadata" + via: "read_page_meta() reading mtime and size" + pattern: "read_page_meta" +--- + + +Add ANSI art splash screen support and file metadata display to complete the BBS aesthetic. + +Purpose: The splash screen makes the landing page feel like a real BBS with colorful ANSI art. The file metadata (last-modified timestamp and file size) in the status bar gives users context about content freshness. + +Output: `src/splash.rs` module, updated `src/app.rs` with PageMeta and splash prepend, updated `Cargo.toml` with all Phase 4 dependencies (ansi-to-tui, notify, walkdir — added now so later plans don't need to touch Cargo.toml). + + + +@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ruohki/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md +@src/app.rs +@src/main.rs +@Cargo.toml + + + + + + Task 1: Add Phase 4 dependencies and create splash + metadata modules + Cargo.toml, src/splash.rs, src/main.rs + +1. Add all three Phase 4 dependencies to Cargo.toml: + ```toml + notify = "6.1" + ansi-to-tui = "8.0" + walkdir = "2.5" + ``` + +2. Run `cargo tree | grep ratatui-core` to verify ansi-to-tui resolves to a single ratatui-core version. If two versions appear, add `ratatui-core = "0.1"` to force unification. + +3. Create `src/splash.rs` with: + ```rust + use std::path::Path; + use ansi_to_tui::IntoText; + use ratatui::text::Line; + + /// Load ANSI art from `splash.txt` in the vault root. + /// Returns None if file doesn't exist or can't be parsed (graceful degradation). + pub fn load_splash(vault_path: &Path) -> Option>> { + let bytes: Vec = std::fs::read(vault_path.join("splash.txt")).ok()?; + let text = bytes.into_text().ok()?; + Some(text.lines) + } + ``` + +4. Add `mod splash;` to `src/main.rs` module declarations (after `mod signals;`). + +5. Run `cargo build` to verify compilation with new dependencies. + + `cargo build` succeeds. `cargo tree | grep ratatui-core` shows exactly one version. `src/splash.rs` exists. + All three Phase 4 crate dependencies compile. splash.rs provides load_splash(). Module is declared in main.rs. + + + + Task 2: Wire splash prepend on index and add file metadata to status bar + src/app.rs, src/main.rs + +1. Add file metadata types and helpers to `src/app.rs` (near the top, after use statements): + + ```rust + use std::time::UNIX_EPOCH; + + /// File metadata for status bar display. + struct PageMeta { + modified: String, // "Feb 25, 2026" + size: String, // "2.4 KB" + } + + fn read_page_meta(full_path: &Path) -> Option { + let meta = std::fs::metadata(full_path).ok()?; + let secs = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_secs(); + let (y, m, d) = unix_secs_to_ymd(secs); + let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + Some(PageMeta { + modified: format!("{} {}, {}", months[(m-1) as usize], d, y), + size: format_file_size(meta.len()), + }) + } + + fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) { + let mut days = (secs / 86400) as u32; + let mut year = 1970u32; + loop { + let days_in_year = if is_leap(year) { 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) + } + + fn format_file_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), + } + } + ``` + +2. Add `page_meta: Option` field to the `App` struct (Phase 4 additions section). Initialize to `None` in `App::new()`. + +3. Update `navigate_to()` — after loading a document successfully (inside the `VaultDocument::Loaded` match arm): + - Compute full path: `let full_path = vault_path.join(vault_relative);` + - Read metadata: `self.page_meta = read_page_meta(&full_path);` + - Check if this is the index page and prepend splash if so: + ```rust + if vault_relative == "index.md" { + if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) { + splash_lines.push(Line::default()); // blank separator + splash_lines.extend(lines); + lines = splash_lines; + // Adjust link records: offset all line_index values by the splash line count + // splash_count = splash_lines_original_len + 1 (for blank separator) + } + } + ``` + - IMPORTANT: When splash lines are prepended, all `link_records` line_index values must be offset by the number of splash lines added. Store the splash line count and add it to each record's `line_index`. + - For Missing/ReadError arms: set `self.page_meta = None;` + +4. Apply the same splash prepend logic in `navigate_back()` and `navigate_forward()` — when the target path is "index.md", prepend splash lines and adjust link_record line indices. Also set page_meta via `read_page_meta()`. + +5. Apply splash + metadata in `main.rs` initial load too — after rendering index.md, prepend splash lines if available and adjust link_records, then compute initial page_meta. + +6. Update `handle_resize()`: when re-rendering, if `current_path == "index.md"`, prepend splash lines again and adjust link_records. Also note: splash lines are NOT re-rendered through render_markdown — they come from `load_splash()` directly and are prepended after the markdown render. + +7. Update `draw_status_bar()` to include metadata on the right side: + - In the normal (non-quit-prompt) branch, before the keyboard hints section: + - If `self.page_meta` is `Some(meta)`: + - Insert `format!("Last modified: {} | {}", meta.modified, meta.size)` into `right_parts` (before the hints entry). + - Implement graceful truncation: if `left.len() + right.len() >= width`, progressively drop metadata fields: + - First drop file size (show only "Last modified: ..."). + - Then drop the entire metadata line. + - Then abbreviate keyboard hints if still too wide. + - Use `width.saturating_sub()` to prevent underflow. + +8. Run `cargo build` to verify. + + `cargo build` succeeds. Status bar shows metadata when a document is loaded (visible on next run with a vault). Splash prepend logic handles both presence and absence of splash.txt. + index.md shows splash art header when splash.txt exists. All loaded pages show "Last modified: Mon DD, YYYY | X.X KB" in status bar. Status bar gracefully truncates on narrow terminals. + + + + + +1. `cargo build` — compiles without errors or warnings +2. Run with a vault containing `splash.txt` (ANSI art) — index.md shows art header above content +3. Run with a vault without `splash.txt` — index.md renders normally, no error +4. Check status bar — right side shows "Last modified: ... | ... KB" followed by keyboard hints +5. Resize terminal to <60 cols — status bar does not panic or wrap, metadata fields drop gracefully +6. Navigate to a different page and back to index — splash reappears, metadata updates + + + +- splash.txt ANSI art renders above index.md content with correct colors via ansi-to-tui +- Missing splash.txt causes no error — graceful degradation to normal index.md +- Every loaded document page shows BBS-format timestamp and file size in status bar +- Status bar layout: left=breadcrumb, right=metadata+hints, with graceful truncation +- Link Tab-cycling still works correctly on index.md when splash lines are prepended (line_index offsets are correct) + + + +After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-bbs-polish-and-live-content/04-02-PLAN.md b/.planning/phases/04-bbs-polish-and-live-content/04-02-PLAN.md new file mode 100644 index 0000000..23d4b6f --- /dev/null +++ b/.planning/phases/04-bbs-polish-and-live-content/04-02-PLAN.md @@ -0,0 +1,331 @@ +--- +phase: 04-bbs-polish-and-live-content +plan: 02 +type: execute +wave: 2 +depends_on: + - 04-01 +files_modified: + - src/vault.rs + - src/app.rs + - src/main.rs +autonomous: true +requirements: + - LIVE-02 + +must_haves: + truths: + - "User can follow a [[Directory]] wiki-link to see a tree view of all vault documents" + - "Directory listing shows folder hierarchy with indentation, directories in yellow bold, files as cyan navigable links" + - "User can Tab-cycle between directory entries and press Enter to open a document" + - "Directory page participates in navigation history (back/forward works)" + - "Navigating to [[Directory]] and back preserves scroll position and link selection" + artifacts: + - path: "src/vault.rs" + provides: "[[Directory]] magic interception in resolve_wiki_link + list_vault_files() function" + contains: "__directory__" + - path: "src/app.rs" + provides: "navigate_to_directory() method, DocumentState handling for virtual directory page" + contains: "navigate_to_directory" + key_links: + - from: "src/vault.rs" + to: "walkdir crate" + via: "list_vault_files() using WalkDir recursive traversal" + pattern: "WalkDir::new" + - from: "src/app.rs" + to: "src/vault.rs" + via: "follow_selected_link detects __directory__ sentinel and calls navigate_to_directory" + pattern: "__directory__" + - from: "src/app.rs" + to: "src/vault.rs" + via: "navigate_to_directory calls list_vault_files to build the listing" + pattern: "list_vault_files" +--- + + +Add a virtual directory listing page accessible via `[[Directory]]` wiki-link, displaying all vault documents in a navigable tree view. + +Purpose: Users can discover all available documents in the vault without knowing their paths. The directory listing acts as a table of contents for the entire vault. + +Output: Updated `src/vault.rs` with directory interception and file listing, updated `src/app.rs` with directory page navigation and rendering. + + + +@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ruohki/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md +@.planning/phases/04-bbs-polish-and-live-content/04-01-SUMMARY.md +@src/vault.rs +@src/app.rs +@src/renderer.rs + + + + + + Task 1: Add [[Directory]] interception and vault file listing + src/vault.rs + +1. In `resolve_wiki_link()`, add a magic sentinel check BEFORE the filesystem scan: + ```rust + // Magic sentinel — [[Directory]] navigates to the virtual directory page + if raw_target.eq_ignore_ascii_case("directory") { + return Some(PathBuf::from("__directory__")); + } + ``` + Place this at the very top of `resolve_wiki_link()`, before the `rfind('/')` split. + +2. Add `list_vault_files()` function to `vault.rs`: + ```rust + use walkdir::WalkDir; + + /// Entry in the vault directory listing. + pub struct DirEntry { + /// Indentation depth (1-based, depth 1 = vault root children) + pub depth: usize, + /// Display name (without .md extension for files) + pub name: String, + /// True if this entry is a directory + pub is_dir: bool, + /// Vault-relative path (only for .md files; None for directories) + pub vault_path: Option, + } + + /// List all markdown files and directories in the vault, sorted alphabetically. + /// Skips hidden files/directories (starting with '.') and non-.md files. + /// Returns entries with depth for tree indentation. + pub fn list_vault_files(vault_path: &Path) -> Vec { + let mut entries = 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(); + + // Skip hidden files/dirs + if name.starts_with('.') { + continue; + } + + let is_dir = entry.file_type().is_dir(); + + if is_dir { + entries.push(DirEntry { + depth, + name, + is_dir: true, + vault_path: None, + }); + } else if name.ends_with(".md") { + let display_name = name.strip_suffix(".md").unwrap_or(&name).to_string(); + let rel = entry.path() + .strip_prefix(vault_path) + .unwrap_or(entry.path()) + .to_string_lossy() + .to_string(); + entries.push(DirEntry { + depth, + name: display_name, + is_dir: false, + vault_path: Some(rel), + }); + } + // Skip non-.md files silently + } + entries + } + ``` + +3. Run `cargo build` to verify vault.rs compiles. + + `cargo build` succeeds. `resolve_wiki_link()` returns `Some(PathBuf::from("__directory__"))` when called with "Directory" (case-insensitive). `list_vault_files()` is defined and exported. + [[Directory]] wiki-link resolves to the `__directory__` sentinel. list_vault_files() returns structured directory entries with depth, name, is_dir, and vault_path. + + + + Task 2: Wire directory as virtual page with Tab-cycling navigation + src/app.rs, src/main.rs + +1. Add a `navigate_to_directory()` method to `App`: + ```rust + /// Navigate to the virtual directory listing page. + /// Generates a tree view of all vault files as styled lines with link records. + fn navigate_to_directory(&mut self) { + let vault_path = self.config.vault_path.clone(); + + // Save current state to history + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + // Truncate forward history + self.history.truncate(self.history_index + 1); + + // Build directory listing + let dir_entries = crate::vault::list_vault_files(&vault_path); + let (lines, link_records) = build_directory_lines(&dir_entries); + + self.document = DocumentState::Loaded { + filename: "[Directory]".to_string(), + lines, + }; + self.raw_content = None; // No raw markdown for virtual pages + self.link_records = link_records; + self.selected_link = None; + self.scroll_offset = 0; + self.current_path = "__directory__".to_string(); + self.filename = "[Directory]".to_string(); + self.page_meta = None; // No file metadata for virtual pages + + // Push history entry + self.history.push(HistoryEntry { + path: "__directory__".to_string(), + scroll_offset: 0, + selected_link: None, + }); + self.history_index = self.history.len() - 1; + } + ``` + +2. Create the `build_directory_lines()` helper function (module-level in app.rs): + ```rust + /// Build styled lines and link records from vault directory entries. + fn build_directory_lines( + entries: &[crate::vault::DirEntry], + ) -> (Vec>, Vec) { + let mut lines: Vec> = Vec::new(); + let mut link_records: Vec = Vec::new(); + + // Title header + lines.push(Line::from(Span::styled( + " VAULT DIRECTORY".to_string(), + Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + "=".repeat(40), + Style::default().fg(Color::LightCyan), + ))); + lines.push(Line::default()); // blank separator + + for entry in entries { + let indent = " ".repeat(entry.depth.saturating_sub(1)); + let indent_chars = indent.chars().count(); + + if entry.is_dir { + // Directory: yellow bold, not a link + lines.push(Line::from(vec![ + Span::raw(indent), + Span::styled( + format!("{}/", entry.name), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + ])); + } else if let Some(ref vault_rel) = entry.vault_path { + // File: cyan link — navigable via Tab-cycling + let display = format!("[{}]", entry.name); + let col_offset = indent_chars; + let span_len = display.chars().count(); + let line_index = lines.len(); // Will be this line's index + + lines.push(Line::from(vec![ + Span::raw(indent), + Span::styled( + display, + Style::default().fg(Color::LightCyan), + ), + ])); + + link_records.push(crate::renderer::LinkRecord { + line_index, + col_offset, + span_len, + dest: vault_rel.clone(), + is_wiki: false, // Direct vault-relative path, not a wiki-link + }); + } + } + + (lines, link_records) + } + ``` + +3. Update `follow_selected_link()` in app.rs: + - After resolving a wiki-link with `resolve_wiki_link()`, check if the resolved path is the directory sentinel: + ```rust + // Inside the is_wiki branch, after resolve_wiki_link returns Some: + if resolved == Path::new("__directory__") { + self.navigate_to_directory(); + } else { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } + ``` + - For the non-wiki branch (standard links), if `dest == "__directory__"`, call `navigate_to_directory()` instead. + +4. Update `navigate_back()` and `navigate_forward()`: + - When the `target_path` is `"__directory__"`, call `navigate_to_directory()`-style logic instead of `load_document()`. Specifically: + ```rust + if target_path == "__directory__" { + // Regenerate directory listing (always fresh, never cached) + let dir_entries = crate::vault::list_vault_files(&vault_path); + let (lines, link_records) = build_directory_lines(&dir_entries); + self.document = DocumentState::Loaded { + filename: "[Directory]".to_string(), + lines, + }; + self.raw_content = None; + self.link_records = link_records; + self.selected_link = target_link; + self.scroll_offset = target_scroll; + self.current_path = "__directory__".to_string(); + self.filename = "[Directory]".to_string(); + self.page_meta = None; + } else { + // existing load_document() logic + } + ``` + - IMPORTANT: Do NOT save/push new history entries in navigate_back/forward — they restore existing history positions. Only update current state fields. The existing history entry management (saving scroll/link to current index, then moving index) remains unchanged. + +5. Update `handle_resize()`: + - If `current_path == "__directory__"`, skip the `render_markdown` re-render. The directory listing is not markdown and does not need width-based re-render. The tree view spans are fixed-width text. Do nothing for directory pages on resize. + +6. Run `cargo build` to verify everything compiles. + + `cargo build` succeeds. The follow_selected_link() method correctly routes `__directory__` to navigate_to_directory(). navigate_back/forward handle the `__directory__` case. handle_resize skips re-render for directory pages. + [[Directory]] wiki-link navigates to a tree view page. Tab-cycling selects directory entries. Enter opens the selected document. Back/Forward navigates through directory page in history. Directory listing is always regenerated fresh (never stale). + + + + + +1. `cargo build` — compiles without errors +2. Add `[[Directory]]` link to index.md in a test vault — following it shows tree view +3. Tree view shows folders (yellow bold with `/`) and files (cyan `[name]` bracketed) +4. Tab-cycle between file entries — REVERSED highlight moves correctly +5. Press Enter on a file entry — navigates to that document +6. Press Backspace — returns to directory listing with scroll/link selection preserved +7. Navigate forward — returns to the document +8. Case-insensitive: `[[directory]]` and `[[DIRECTORY]]` both work + + + +- [[Directory]] wiki-link navigates to virtual tree view page +- Tree shows hierarchy with indentation, dirs yellow bold, files cyan bracketed links +- Tab-cycling works on directory entries using existing link model +- Directory participates in back/forward history +- Directory listing is regenerated each visit (not stale) +- handle_resize does not crash on directory page + + + +After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-bbs-polish-and-live-content/04-03-PLAN.md b/.planning/phases/04-bbs-polish-and-live-content/04-03-PLAN.md new file mode 100644 index 0000000..c37e838 --- /dev/null +++ b/.planning/phases/04-bbs-polish-and-live-content/04-03-PLAN.md @@ -0,0 +1,352 @@ +--- +phase: 04-bbs-polish-and-live-content +plan: 03 +type: execute +wave: 3 +depends_on: + - 04-01 + - 04-02 +files_modified: + - src/app.rs + - src/main.rs +autonomous: true +requirements: + - LIVE-01 + +must_haves: + truths: + - "When a markdown file is modified on disk, the currently displayed page auto-refreshes without user interaction" + - "Scroll position is preserved when auto-refresh occurs" + - "The refresh is silent — no visual indicator, content updates seamlessly" + - "When viewing the directory listing, new/removed files appear after a short delay" + - "Rapid saves do not cause flickering — debounce filters rapid events" + artifacts: + - path: "src/app.rs" + provides: "FileWatcher struct, try_recv integration in event loop, reload_current_document(), rewatch_for_current_page()" + contains: "FileWatcher" + - path: "src/main.rs" + provides: "FileWatcher initialization before App::new()" + contains: "FileWatcher::new" + key_links: + - from: "src/app.rs" + to: "notify crate" + via: "RecommendedWatcher in FileWatcher struct" + pattern: "RecommendedWatcher" + - from: "src/app.rs" + to: "event loop" + via: "try_recv() called each iteration after event::poll" + pattern: "try_recv" + - from: "src/app.rs" + to: "navigate_to/navigate_to_directory" + via: "rewatch_for_current_page() called after navigation to re-point watcher" + pattern: "rewatch_for_current_page" +--- + + +Add live filesystem watching so the app auto-refreshes the current page when its file changes on disk, and the directory listing updates when vault files are added or removed. + +Purpose: Content updates are visible immediately without manual refresh or restarting the app. This makes the BBS feel alive — a SysOp can edit vault files and all connected users see the changes within a second. + +Output: FileWatcher integration in `src/app.rs` event loop, watcher initialization in `src/main.rs`. + + + +@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ruohki/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md +@.planning/phases/04-bbs-polish-and-live-content/04-01-SUMMARY.md +@.planning/phases/04-bbs-polish-and-live-content/04-02-SUMMARY.md +@src/app.rs +@src/main.rs + + + + + + Task 1: Create FileWatcher and integrate into event loop + src/app.rs + +1. Add the `FileWatcher` struct at the top of `src/app.rs` (after existing use statements): + ```rust + use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; + use std::sync::mpsc::{self, Receiver, TryRecvError}; + + /// Filesystem watcher for live content refresh. + /// Watches the parent directory (not the file itself) to survive atomic saves + /// (vim/neovim write a temp file then rename it over the target). + pub struct FileWatcher { + _watcher: RecommendedWatcher, + rx: Receiver>, + watched_dir: PathBuf, + } + + impl FileWatcher { + /// Create a new watcher watching the given directory. + pub fn new(dir: &Path) -> notify::Result { + let (tx, rx) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + watcher.watch(dir, RecursiveMode::NonRecursive)?; + Ok(FileWatcher { + _watcher: watcher, + rx, + watched_dir: dir.to_path_buf(), + }) + } + + /// Re-point the watcher at a new directory. + /// Unwatches the old directory and watches the new one. + pub fn rewatch(&mut self, new_dir: &Path) -> notify::Result<()> { + let _ = self._watcher.unwatch(&self.watched_dir); + self._watcher.watch(new_dir, RecursiveMode::NonRecursive)?; + self.watched_dir = new_dir.to_path_buf(); + Ok(()) + } + } + ``` + +2. Add watcher-related fields to the `App` struct: + ```rust + // ── Phase 4 additions ───────────────────────────────────────────────────── + /// Filesystem watcher for live content refresh. + file_watcher: Option, + /// Debounce timer: set to Some(Instant) when a relevant file event arrives. + /// Reload fires 300ms after the last event. + pending_reload_at: Option, + ``` + +3. Update `App::new()` to accept and store a `file_watcher: Option` parameter. Initialize `pending_reload_at: None`. + +4. Add a `reload_current_document()` method: + ```rust + /// Reload the current document from disk, preserving scroll position. + /// For regular documents: re-read and re-render the markdown. + /// For directory listing: regenerate from walkdir. + /// For error/missing pages: do nothing. + fn reload_current_document(&mut self) { + let scroll = self.scroll_offset; + let selected = self.selected_link; + + if self.current_path == "__directory__" { + // Regenerate directory listing + let vault_path = self.config.vault_path.clone(); + let dir_entries = crate::vault::list_vault_files(&vault_path); + let (lines, link_records) = build_directory_lines(&dir_entries); + self.document = DocumentState::Loaded { + filename: "[Directory]".to_string(), + lines, + }; + self.link_records = link_records; + self.page_meta = None; + } else { + // Regular document + let vault_path = self.config.vault_path.clone(); + let current = self.current_path.clone(); + if let crate::vault::VaultDocument::Loaded { path, content } = + crate::vault::load_document(&vault_path, ¤t) + { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| current.clone()); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w) + .unwrap_or(80); + let (mut lines, mut link_records) = + crate::renderer::render_markdown(&content, width, Some(&vault_path)); + + // Splash prepend for index.md (same logic as navigate_to) + if current == "index.md" { + if let Some(splash_lines) = crate::splash::load_splash(&vault_path) { + let splash_count = splash_lines.len() + 1; // +1 for blank separator + let mut combined = splash_lines; + combined.push(Line::default()); + combined.extend(lines); + lines = combined; + // Offset link records + for record in link_records.iter_mut() { + record.line_index += splash_count; + } + } + } + + self.document = DocumentState::Loaded { + filename: filename.clone(), + lines, + }; + self.raw_content = Some(content); + self.link_records = link_records; + self.filename = filename; + + // Update metadata + let full_path = vault_path.join(¤t); + self.page_meta = read_page_meta(&full_path); + } + } + + // Preserve scroll position (clamp to new max) + let max = self.max_scroll(); + self.scroll_offset = scroll.min(max); + + // Preserve link selection if still valid + if let Some(i) = selected { + if i < self.link_records.len() { + self.selected_link = Some(i); + } else { + self.selected_link = None; + } + } + } + ``` + +5. Add a `rewatch_for_current_page()` method: + ```rust + /// Re-point the filesystem watcher to the appropriate directory for the current page. + /// - Regular documents: watch the parent directory of the current file + /// - Directory listing: watch the vault root directory + fn rewatch_for_current_page(&mut self) { + if let Some(ref mut watcher) = self.file_watcher { + let vault_path = self.config.vault_path.clone(); + let watch_dir = if self.current_path == "__directory__" { + vault_path + } else { + // Watch parent directory of current file + let full_path = vault_path.join(&self.current_path); + full_path.parent() + .map(|p| p.to_path_buf()) + .unwrap_or(vault_path) + }; + let _ = watcher.rewatch(&watch_dir); + } + } + ``` + +6. Integrate `try_recv()` and debounce into `run_event_loop()`. Add these blocks AFTER the `event::poll()` block (step 3) and BEFORE the should_quit check (step 4): + + ```rust + // 3a. Check for filesystem events (non-blocking) + if let Some(ref mut watcher) = self.file_watcher { + loop { + match watcher.rx.try_recv() { + Ok(Ok(event)) => { + // Filter: only act on events relevant to current file + let relevant = if self.current_path == "__directory__" { + // Any .md file change in vault is relevant + event.paths.iter().any(|p| { + p.extension().is_some_and(|ext| ext == "md") + || p.is_dir() + }) + } else { + // Match current file's name + let target = Path::new(&self.current_path).file_name(); + event.paths.iter().any(|p| p.file_name() == target) + }; + if relevant { + self.pending_reload_at = Some(Instant::now()); + } + } + Ok(Err(_)) => { /* watch error — ignore */ } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => break, + } + } + } + + // 3b. 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; + } + } + ``` + + IMPORTANT: The `try_recv()` is in a loop to drain ALL queued events (editors can fire multiple events per save). The debounce timer resets with each new event, so the reload only fires 300ms after the LAST event. + +7. Call `rewatch_for_current_page()` at the end of `navigate_to()`, `navigate_back()`, `navigate_forward()`, and `navigate_to_directory()`. This re-points the watcher to the correct directory whenever navigation changes the current page. + +8. Run `cargo build` to verify everything compiles. + + `cargo build` succeeds. FileWatcher struct compiles. Event loop integrates try_recv without blocking. Debounce logic uses Instant with 300ms threshold. + FileWatcher is created and stored in App. Event loop polls for filesystem events non-blockingly via try_recv(). Debounce delays reload by 300ms after last event. Watcher re-points on navigation changes. reload_current_document preserves scroll and link selection. + + + + Task 2: Initialize FileWatcher in main.rs and wire App constructor + src/main.rs + +1. After the initial document load (step 3b) and before terminal init (step 4), create the FileWatcher: + ```rust + // 3c. Initialize filesystem watcher for live content refresh. + // Watch the parent directory of index.md (or vault root if index.md is at root). + // Watcher must be created before App::new() so it can be passed in. + let file_watcher = { + let watch_dir = app_config.vault_path.join("index.md") + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| app_config.vault_path.clone()); + match app::FileWatcher::new(&watch_dir) { + Ok(w) => Some(w), + Err(e) => { + // Non-fatal: live reload won't work but app is still usable + eprintln!("Warning: filesystem watcher unavailable: {}", e); + None + } + } + }; + ``` + +2. Update the `App::new()` call in main.rs to pass the `file_watcher`: + ```rust + let mut app_state = app::App::new( + is_login_shell, + app_config, + initial_doc, + raw_content, + initial_link_records, + "index.md".to_string(), + file_watcher, // NEW parameter + ); + ``` + +3. Ensure the `App::new()` signature in app.rs accepts `file_watcher: Option` and stores it. This was already specified in Task 1 — verify it matches. + +4. Run `cargo build` to verify the full pipeline compiles end-to-end. + +5. Run `cargo clippy` to check for warnings. + + `cargo build` succeeds. `cargo clippy` produces no errors. FileWatcher is created in main.rs and passed to App::new(). Watcher failure is non-fatal (app still runs without live reload). + FileWatcher initialized at startup watching index.md's parent directory. Watcher failure is gracefully handled (warning printed, None passed to App). Full compilation pipeline verified. + + + + + +1. `cargo build` — compiles without errors +2. `cargo clippy` — no warnings +3. Run with a vault, edit a displayed markdown file in another terminal — content updates within ~1 second +4. Save the same file rapidly 5 times in 1 second — only one refresh occurs (debounce works) +5. Scroll to the middle of a document, edit the file — scroll position preserved after refresh +6. Navigate to [[Directory]], add a new .md file to vault — directory listing updates +7. Navigate between pages — no crash from watcher repoint +8. Start app without write access to vault dir — watcher creation fails gracefully, app works without live reload + + + +- File changes trigger auto-refresh within ~800ms (300ms debounce + 250ms poll + 250ms poll) +- Scroll position preserved on refresh +- No visual indicator of refresh — content updates seamlessly +- Directory listing refreshes when vault files change +- Watcher re-points on navigation (back, forward, link follow) +- Rapid saves debounced — single refresh after 300ms of silence +- Watcher failure is non-fatal — app works without live reload + + + +After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-03-SUMMARY.md` +