Files
2026-02-28 23:41:11 +01:00

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
04-01
04-02
src/app.rs
src/main.rs
true
LIVE-01
truths artifacts key_links
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
path provides contains
src/app.rs FileWatcher struct, try_recv integration in event loop, reload_current_document(), rewatch_for_current_page() FileWatcher
path provides contains
src/main.rs FileWatcher initialization before App::new() FileWatcher::new
from to via pattern
src/app.rs notify crate RecommendedWatcher in FileWatcher struct RecommendedWatcher
from to via pattern
src/app.rs event loop try_recv() called each iteration after event::poll try_recv
from to via pattern
src/app.rs navigate_to/navigate_to_directory rewatch_for_current_page() called after navigation to re-point watcher 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.

<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>,
  1. Update App::new() to accept and store a file_watcher: Option<FileWatcher> parameter. Initialize pending_reload_at: None.

  2. 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, &current)
            {
                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(&current);
                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;
            }
        }
    }
    
  3. 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);
        }
    }
    
  4. 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):

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

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

  6. 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 } } }; ```
  1. Update the App::new() call in main.rs to pass the file_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
    );
    
  2. Ensure the App::new() signature in app.rs accepts file_watcher: Option<FileWatcher> and stores it. This was already specified in Task 1 — verify it matches.

  3. Run cargo build to verify the full pipeline compiles end-to-end.

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

<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>
After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-03-SUMMARY.md`