feat(04-03): add FileWatcher struct and integrate into event loop

- Add FileWatcher struct with RecommendedWatcher, mpsc channel, and rewatch() method
- Add file_watcher and pending_reload_at fields to App struct
- Update App::new() to accept Option<FileWatcher> parameter
- Add reload_current_document() preserving scroll and link selection
- Add rewatch_for_current_page() to re-point watcher on navigation
- Integrate try_recv() drain loop in event loop (non-blocking)
- Add 300ms debounce timer that fires reload after last relevant event
- Call rewatch_for_current_page() in navigate_to/back/forward/to_directory
This commit is contained in:
2026-03-01 11:26:30 +01:00
parent 5a083238b2
commit a44c9ccb28
+184
View File
@@ -22,14 +22,50 @@
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::{Duration, Instant, UNIX_EPOCH}; use std::time::{Duration, Instant, UNIX_EPOCH};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::prelude::*; use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
use crate::config::Config; use crate::config::Config;
use crate::terminal::Term; use crate::terminal::Term;
use crate::signals::SignalFlags; use crate::signals::SignalFlags;
// ── FileWatcher ───────────────────────────────────────────────────────────────
/// 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, NotifyConfig::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(())
}
}
// ── PageMeta ────────────────────────────────────────────────────────────────── // ── PageMeta ──────────────────────────────────────────────────────────────────
/// File metadata for status bar display. /// File metadata for status bar display.
@@ -189,6 +225,11 @@ pub struct App {
/// File metadata (mtime, size) for the currently displayed document. /// File metadata (mtime, size) for the currently displayed document.
/// None for error screens or when metadata cannot be read. /// None for error screens or when metadata cannot be read.
page_meta: Option<PageMeta>, page_meta: Option<PageMeta>,
/// 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>,
} }
impl App { impl App {
@@ -202,6 +243,7 @@ impl App {
/// `current_path` is the vault-relative path of the initial document. /// `current_path` is the vault-relative path of the initial document.
/// ///
/// `page_meta` is computed automatically from `current_path` and `config.vault_path`. /// `page_meta` is computed automatically from `current_path` and `config.vault_path`.
/// `file_watcher` is an optional filesystem watcher for live content refresh.
pub fn new( pub fn new(
is_login_shell: bool, is_login_shell: bool,
config: Config, config: Config,
@@ -209,6 +251,7 @@ impl App {
raw_content: Option<String>, raw_content: Option<String>,
link_records: Vec<crate::renderer::LinkRecord>, link_records: Vec<crate::renderer::LinkRecord>,
current_path: String, current_path: String,
file_watcher: Option<FileWatcher>,
) -> Self { ) -> Self {
let filename = match &document { let filename = match &document {
DocumentState::Loaded { filename, .. } => filename.clone(), DocumentState::Loaded { filename, .. } => filename.clone(),
@@ -245,6 +288,8 @@ impl App {
selected_link: None, selected_link: None,
current_path, current_path,
page_meta, page_meta,
file_watcher,
pending_reload_at: None,
} }
} }
@@ -292,6 +337,42 @@ impl App {
} }
} }
// 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;
}
}
// 4. Check if we should quit // 4. Check if we should quit
if self.should_quit { if self.should_quit {
return Ok(ShutdownReason::UserQuit); return Ok(ShutdownReason::UserQuit);
@@ -527,6 +608,7 @@ impl App {
self.page_meta = None; self.page_meta = None;
} }
} }
self.rewatch_for_current_page();
} }
/// Navigate to the virtual directory listing page. /// Navigate to the virtual directory listing page.
@@ -567,6 +649,106 @@ impl App {
selected_link: None, selected_link: None,
}); });
self.history_index = self.history.len() - 1; self.history_index = self.history.len() - 1;
self.rewatch_for_current_page();
}
/// 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;
}
}
}
/// 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);
}
} }
/// Navigate back one step in the history stack, restoring scroll and link selection. /// Navigate back one step in the history stack, restoring scroll and link selection.
@@ -646,6 +828,7 @@ impl App {
self.filename = filename; self.filename = filename;
} }
// If file was deleted since last visit, leave current doc unchanged // If file was deleted since last visit, leave current doc unchanged
self.rewatch_for_current_page();
} }
/// Navigate forward one step in the history stack, restoring scroll and link selection. /// Navigate forward one step in the history stack, restoring scroll and link selection.
@@ -723,6 +906,7 @@ impl App {
self.current_path = target_path; self.current_path = target_path;
self.filename = filename; self.filename = filename;
} }
self.rewatch_for_current_page();
} }
/// Follow the currently selected link, resolving wiki-links or standard links. /// Follow the currently selected link, resolving wiki-links or standard links.