diff --git a/src/app.rs b/src/app.rs index 0cae66f..aecd340 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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>, + 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, 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, + /// Filesystem watcher for live content refresh. + file_watcher: Option, + /// Debounce timer: set to Some(Instant) when a relevant file event arrives. + /// Reload fires 300ms after the last event. + pending_reload_at: Option, } 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, link_records: Vec, current_path: String, + file_watcher: Option, ) -> 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.