14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-bbs-polish-and-live-content | 03 | execute | 3 |
|
|
true |
|
|
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.
<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>
@.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<notify::Resultnotify::Event>, 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<FileWatcher>,
/// Debounce timer: set to Some(Instant) when a relevant file event arrives.
/// Reload fires 300ms after the last event.
pending_reload_at: Option<Instant>,
-
Update
App::new()to accept and store afile_watcher: Option<FileWatcher>parameter. Initializepending_reload_at: None. -
Add a
reload_current_document()method:/// 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; } } } -
Add a
rewatch_for_current_page()method:/// 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); } } -
Integrate
try_recv()and debounce intorun_event_loop(). Add these blocks AFTER theevent::poll()block (step 3) and BEFORE the should_quit check (step 4):// 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. -
Call
rewatch_for_current_page()at the end ofnavigate_to(),navigate_back(),navigate_forward(), andnavigate_to_directory(). This re-points the watcher to the correct directory whenever navigation changes the current page. -
Run
cargo buildto verify everything compiles.cargo buildsucceeds. 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.
-
Update the
App::new()call in main.rs to pass thefile_watcher: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 ); -
Ensure the
App::new()signature in app.rs acceptsfile_watcher: Option<FileWatcher>and stores it. This was already specified in Task 1 — verify it matches. -
Run
cargo buildto verify the full pipeline compiles end-to-end. -
Run
cargo clippyto check for warnings.cargo buildsucceeds.cargo clippyproduces 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.
<success_criteria>
- 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 </success_criteria>