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::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, ¤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.
|
/// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user