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:
+184
@@ -22,14 +22,50 @@
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use crate::config::Config;
|
||||
use crate::terminal::Term;
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// File metadata for status bar display.
|
||||
@@ -189,6 +225,11 @@ pub struct App {
|
||||
/// File metadata (mtime, size) for the currently displayed document.
|
||||
/// None for error screens or when metadata cannot be read.
|
||||
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 {
|
||||
@@ -202,6 +243,7 @@ impl App {
|
||||
/// `current_path` is the vault-relative path of the initial document.
|
||||
///
|
||||
/// `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(
|
||||
is_login_shell: bool,
|
||||
config: Config,
|
||||
@@ -209,6 +251,7 @@ impl App {
|
||||
raw_content: Option<String>,
|
||||
link_records: Vec<crate::renderer::LinkRecord>,
|
||||
current_path: String,
|
||||
file_watcher: Option<FileWatcher>,
|
||||
) -> Self {
|
||||
let filename = match &document {
|
||||
DocumentState::Loaded { filename, .. } => filename.clone(),
|
||||
@@ -245,6 +288,8 @@ impl App {
|
||||
selected_link: None,
|
||||
current_path,
|
||||
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
|
||||
if self.should_quit {
|
||||
return Ok(ShutdownReason::UserQuit);
|
||||
@@ -527,6 +608,7 @@ impl App {
|
||||
self.page_meta = None;
|
||||
}
|
||||
}
|
||||
self.rewatch_for_current_page();
|
||||
}
|
||||
|
||||
/// Navigate to the virtual directory listing page.
|
||||
@@ -567,6 +649,106 @@ impl App {
|
||||
selected_link: None,
|
||||
});
|
||||
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, ¤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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -646,6 +828,7 @@ impl App {
|
||||
self.filename = filename;
|
||||
}
|
||||
// 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.
|
||||
@@ -723,6 +906,7 @@ impl App {
|
||||
self.current_path = target_path;
|
||||
self.filename = filename;
|
||||
}
|
||||
self.rewatch_for_current_page();
|
||||
}
|
||||
|
||||
/// Follow the currently selected link, resolving wiki-links or standard links.
|
||||
|
||||
Reference in New Issue
Block a user