docs(04): create phase plan

This commit is contained in:
2026-02-28 23:41:11 +01:00
parent 13f14ae88e
commit 6d03979ddd
4 changed files with 919 additions and 2 deletions
@@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Create FileWatcher and integrate into event loop</name>
<files>src/app.rs</files>
<action>
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::Result<notify::Event>>,
watched_dir: PathBuf,
}
impl FileWatcher {
/// Create a new watcher watching the given directory.
pub fn new(dir: &Path) -> notify::Result<Self> {
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>,
```
3. Update `App::new()` to accept and store a `file_watcher: Option<FileWatcher>` 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, &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;
}
}
}
```
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.
</action>
<verify>`cargo build` succeeds. FileWatcher struct compiles. Event loop integrates try_recv without blocking. Debounce logic uses Instant with 300ms threshold.</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Initialize FileWatcher in main.rs and wire App constructor</name>
<files>src/main.rs</files>
<action>
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<FileWatcher>` 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.
</action>
<verify>`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).</verify>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-03-SUMMARY.md`
</output>