diff --git a/Cargo.lock b/Cargo.lock index 00c985a..a351908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,6 +834,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -1164,6 +1173,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.11" @@ -1225,6 +1254,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1511,6 +1560,7 @@ dependencies = [ "miette", "mq-lang", "mq-markdown", + "notify-debouncer-mini", "ratatui", "unicode-width 0.2.2", ] @@ -1564,6 +1614,45 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2187,6 +2276,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2525,6 +2623,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -2871,6 +2982,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3041,6 +3162,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 2b0842c..43811a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ log = "0.4.31" miette = {version = "7.6.0", features = ["fancy"]} mq-lang = "0.6.1" mq-markdown = "0.6.1" +notify-debouncer-mini = "0.7.0" ratatui = "0.30.0" unicode-width = "0.2.2" diff --git a/README.md b/README.md index c54b2e5..1a8239f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Interactive terminal interface for querying and manipulating Markdown content - 🔍 **Interactive Query Mode** - Real-time Markdown querying with instant results - 🌳 **Tree View** - Visual exploration of Markdown document structure +- 👀 **Rendered Preview** - View Markdown rendered close to its final look, right in the terminal +- 📺 **Watch Mode** - Automatically reload files when they change on disk +- 📑 **Multi-file Tabs** - Open several Markdown files at once and switch between them - ⚡ **Vim-style Navigation** - Efficient keyboard shortcuts (j/k, hjkl) - 📋 **Clipboard Integration** - Copy results directly to clipboard - 🎨 **Syntax Highlighting** - Color-coded display of different Markdown elements @@ -87,6 +90,30 @@ all tabs: whatever query you run is applied to every open file at once, so switching tabs shows that file's own filtered results without retyping the query. Press `o` at any time to open another file as a new tab. +### Watch Mode + +Pass `--watch` (or `-w`) to automatically reload files when they change on disk: + +```bash +mq-tui --watch README.md +``` + +The status line shows a `👀 watching` indicator while watch mode is active. +Each open file is watched using your OS's native file system notifications +(inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows) - +no polling involved, so changes are picked up almost instantly. When a file +is modified externally (e.g. saved from your editor, including atomic +save-and-rename), its content is reloaded and the current query is re-run +automatically. Watch mode is not available when reading from stdin. + +### Rendered Preview + +Press `p` to switch to a rendered preview of the active document - headings, +bold/italic text, lists, blockquotes, code blocks, tables, and links are +styled to look close to their final rendered form instead of raw Markdown +syntax. Use `↑`/`k`, `↓`/`j`, `PageUp`/`PageDown`, or `g`/`G` to scroll, and +press `p` or `Esc` to return to normal mode. + ### Query Examples Once in the TUI, press `:` to enter query mode and try these queries: @@ -121,6 +148,7 @@ Once in the TUI, press `:` to enter query mode and try these queries: | `:` | Enter query mode | | `?` / `F1` | Show help screen | | `t` | Toggle tree view mode | +| `p` | Toggle rendered preview mode | | `d` | Toggle detail view for selected item | | `y` | Copy results to clipboard | | `Ctrl+L` | Clear current query | @@ -171,6 +199,19 @@ Once in the TUI, press `:` to enter query mode and try these queries: | `Home` / `End` | Jump to start/end of path | | `Backspace` / `Delete` | Edit path text | +### Preview Mode + +| Key | Action | +| --------------- | ----------------------------- | +| `↑` / `k` | Scroll up | +| `↓` / `j` | Scroll down | +| `PageUp` | Scroll up (10 lines) | +| `PageDown` | Scroll down (10 lines) | +| `g` | Jump to top | +| `G` | Jump to bottom | +| `←` / `→` | Switch tabs (when multiple files are open) | +| `Esc` / `p` | Exit preview | + ## Modes ### Normal Mode @@ -185,6 +226,10 @@ Activated by pressing `:`. Type your mq query and press Enter to execute. The qu Activated by pressing `t`. Displays the Markdown document structure as an expandable tree, showing the hierarchy of headings, lists, and other elements. +### Preview Mode + +Activated by pressing `p`. Renders the active document's Markdown source close to its final look - styled headings, emphasis, lists, blockquotes, code blocks, tables, and links - instead of raw Markdown syntax. + ### Help Mode Activated by pressing `?` or `F1`. Displays all available keyboard shortcuts and commands. @@ -218,6 +263,13 @@ The tree view mode provides a visual representation of your Markdown document's - 🟡 **Yellow**: Images - 🔵 **Cyan**: Code blocks +### Watch Mode + +Run with `--watch` to keep open files in sync with disk. This is handy when +editing a Markdown file in another editor while exploring it with `mq-tui` - +save your changes and they show up immediately, with the current query +re-applied to the updated content. + ## Configuration `mq-tui` works out of the box with sensible defaults. The UI adapts to your terminal's color scheme and size. diff --git a/assets/demo.gif b/assets/demo.gif index 1fd697d..69e338b 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/assets/demo.tape b/assets/demo.tape index a5e5cbb..e1ca37e 100644 --- a/assets/demo.tape +++ b/assets/demo.tape @@ -17,10 +17,18 @@ Type "clear" Enter Show -Type `mq-tui demo.md` +# Open two Markdown files as tabs +Type `mq-tui demo.md demo2.md` Enter Sleep 1.5s +# Switch tabs +Tab +Sleep 1s +Tab +Sleep 1s + +# Sidebar navigation Type `s` Sleep 1s @@ -42,6 +50,7 @@ Sleep 1s Type `s` Sleep 1s +# Query mode Type `:` Sleep 0.5s @@ -49,16 +58,67 @@ Type `.h` Sleep 1.5s Backspace 2 -Sleep 1.5s +Sleep 1s Type `.code` Sleep 1.5s Backspace 5 -Sleep 1.5s +Sleep 1s Type `select(.code.lang == "python")` Sleep 1.5s Escape +Sleep 1s + +# Rendered preview mode +Type `p` Sleep 1.5s + +Type `j` +Sleep 0.4s + +Type `j` +Sleep 0.4s + +Type `j` +Sleep 0.4s + +Type `j` +Sleep 0.4s + +Type `j` +Sleep 1.5s + +Escape +Sleep 1s + +Type `q` +Sleep 0.5s + +# --watch: auto-reload when the file changes on disk +Hide +Type `cp demo.md /tmp/mq-tui-watch-demo.md` +Enter +Type `clear` +Enter +Show + +Type `(sleep 3 && printf '\n## Live Reload\n\nThis section was appended on disk while mq-tui was watching.\n' >> /tmp/mq-tui-watch-demo.md) & disown` +Enter +Sleep 0.5s + +Type `mq-tui --watch /tmp/mq-tui-watch-demo.md` +Enter +Sleep 4s + +Type `G` +Sleep 2s + +Type `q` +Sleep 0.5s + +Hide +Type `rm -f /tmp/mq-tui-watch-demo.md` +Enter diff --git a/assets/demo2.md b/assets/demo2.md new file mode 100644 index 0000000..d6befa6 --- /dev/null +++ b/assets/demo2.md @@ -0,0 +1,19 @@ +# Project Notes + +A second Markdown file, opened alongside `demo.md` as another tab. + +## Status + +- [x] Add multi-file tabs +- [x] Add rendered preview mode +- [x] Add `--watch` for auto-reload +- [ ] Ship the next release + +## Why Tabs? + +Switching between related Markdown files (a README and its CHANGELOG, for +example) without leaving the terminal keeps you focused. The query you type +is shared across every open tab, so you can compare how the same query +behaves across multiple documents. + +> Press `Tab` / `Shift+Tab` or `←` / `→` to switch tabs. diff --git a/src/app.rs b/src/app.rs index b61d0a8..bd758fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,15 +5,18 @@ use mq_lang::Engine; use mq_markdown::Markdown; use ratatui::prelude::*; use std::{ + cell::Cell, fmt::Display, io::Stdout, + path::PathBuf, time::{Duration, Instant}, }; use crate::{ event::{EventHandler, EventHandlerExt}, - ui::{draw_ui, treeview::TreeView}, + ui::{draw_ui, preview, treeview::TreeView}, util, + watcher::{self, FileWatcher}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,6 +26,7 @@ pub enum Mode { Help, TreeView, OpenFile, + Preview, } impl Display for Mode { @@ -33,6 +37,7 @@ impl Display for Mode { Mode::Help => write!(f, "HELP"), Mode::TreeView => write!(f, "TREE VIEW"), Mode::OpenFile => write!(f, "OPEN FILE"), + Mode::Preview => write!(f, "PREVIEW"), } } } @@ -53,6 +58,8 @@ struct Document { all_nodes: Vec, /// Sidebar tree view (headers only) sidebar_tree_view: Option, + /// Path on disk this document was loaded from (used for `--watch`) + path: Option, } impl Document { @@ -65,6 +72,22 @@ impl Document { error_msg: None, all_nodes: Vec::new(), sidebar_tree_view: None, + path: None, + }; + doc.init_sidebar_tree_view(); + doc + } + + fn with_path(content: String, filename: Option, path: PathBuf) -> Self { + let mut doc = Self { + content, + filename, + results: Vec::new(), + selected_idx: 0, + error_msg: None, + all_nodes: Vec::new(), + sidebar_tree_view: None, + path: Some(path), }; doc.init_sidebar_tree_view(); doc @@ -178,6 +201,14 @@ pub struct App { open_file_path: String, /// Cursor position within open_file_path open_file_cursor: usize, + /// Current scroll offset (in lines) of the rendered preview (Mode::Preview) + preview_scroll: u16, + /// Height of the preview viewport from the last draw, used to clamp scrolling + preview_viewport_height: Cell, + /// Whether watched files should be reloaded automatically when changed on disk + watch: bool, + /// Event-driven watcher (OS file system notifications) backing `watch` + file_watcher: Option, } impl App { @@ -198,6 +229,16 @@ impl App { Self::from_documents(documents) } + /// Create an App with multiple open documents (tabs), each as (content, filename, path). + /// The path is used to support `--watch`. + pub fn with_files_with_paths(files: Vec<(String, String, PathBuf)>) -> Self { + let documents = files + .into_iter() + .map(|(content, filename, path)| Document::with_path(content, Some(filename), path)) + .collect(); + Self::from_documents(documents) + } + fn from_documents(documents: Vec) -> Self { Self { documents, @@ -218,6 +259,10 @@ impl App { debounce_duration: Duration::from_millis(300), open_file_path: String::new(), open_file_cursor: 0, + preview_scroll: 0, + preview_viewport_height: Cell::new(0), + watch: false, + file_watcher: None, } } @@ -246,6 +291,10 @@ impl App { if self.query_pending && self.last_exec.elapsed() >= self.debounce_duration { self.exec_query(); } + + // Cheap, non-blocking: just drains any pending OS file system + // notifications, no polling involved. + self.check_for_file_changes(); } util::restore_terminal()?; @@ -253,6 +302,79 @@ impl App { Ok(()) } + /// Enable or disable watching opened files for changes on disk. Uses the + /// OS's native file system notifications (inotify / FSEvents / + /// ReadDirectoryChangesW) rather than polling. + pub fn set_watch(&mut self, watch: bool) { + self.watch = watch; + + if !watch { + self.file_watcher = None; + return; + } + + if self.file_watcher.is_none() { + match FileWatcher::new() { + Ok(mut watcher) => { + for doc in &self.documents { + if let Some(path) = &doc.path { + watcher.watch_file(path); + } + } + self.file_watcher = Some(watcher); + } + Err(err) => { + self.transient_error = Some(format!("Could not watch files: {err}")); + self.watch = false; + } + } + } + } + + /// Whether watch mode is enabled. + pub fn watch(&self) -> bool { + self.watch + } + + /// Reload any document whose source file was reported as changed by the + /// file watcher, re-running the current query if anything changed. + fn check_for_file_changes(&mut self) { + let Some(watcher) = &self.file_watcher else { + return; + }; + let changed_paths = watcher.drain_changed_paths(); + if changed_paths.is_empty() { + return; + } + + let mut changed = false; + for doc in self.documents.iter_mut() { + let Some(path) = doc.path.clone() else { + continue; + }; + if !changed_paths + .iter() + .any(|p| watcher::paths_refer_to_same_file(p, &path)) + { + continue; + } + if let Ok(content) = std::fs::read_to_string(&path) + && content != doc.content + { + doc.content = content; + doc.init_sidebar_tree_view(); + changed = true; + } + } + + if changed { + self.exec_query(); + if self.mode == Mode::TreeView { + self.init_tree_view(); + } + } + } + fn draw(&self, terminal: &mut Terminal>) -> miette::Result<()> { terminal .draw(|frame| draw_ui(frame, self)) @@ -269,6 +391,7 @@ impl App { Mode::Help => self.handle_help_mode_event(event), Mode::TreeView => self.handle_tree_view_mode_event(event), Mode::OpenFile => self.handle_open_file_mode_event(event), + Mode::Preview => self.handle_preview_mode_event(event), } } @@ -318,6 +441,11 @@ impl App { self.mode = Mode::TreeView; self.init_tree_view(); } + // Toggle rendered preview + (KeyCode::Char('p'), _) => { + self.mode = Mode::Preview; + self.preview_scroll = 0; + } // Toggle tree sidebar (KeyCode::Char('s'), _) => { self.toggle_tree_sidebar(); @@ -368,8 +496,8 @@ impl App { // Enter key can be used for future actions if needed } (KeyCode::PageDown, _) if !self.active_doc().results.is_empty() => { - let next = - (self.active_doc().selected_idx + 10).min(self.active_doc().results.len() - 1); + let next = (self.active_doc().selected_idx + 10) + .min(self.active_doc().results.len() - 1); let idx = self.next_visible(next, true); self.active_doc_mut().selected_idx = idx; } @@ -413,7 +541,8 @@ impl App { Some("Error: Could not copy to clipboard".to_string()); } } else { - self.transient_error = Some("Error: Could not access clipboard".to_string()); + self.transient_error = + Some("Error: Could not access clipboard".to_string()); } } (KeyCode::Char('Y'), _) if !self.active_doc().results.is_empty() => { @@ -430,7 +559,8 @@ impl App { Some("Error: Could not copy to clipboard".to_string()); } } else { - self.transient_error = Some("Error: Could not access clipboard".to_string()); + self.transient_error = + Some("Error: Could not access clipboard".to_string()); } } _ => {} @@ -702,7 +832,12 @@ impl App { .and_then(|n| n.to_str()) .unwrap_or(&path) .to_string(); - self.documents.push(Document::new(content, Some(filename))); + let path_buf = PathBuf::from(&path); + if let Some(watcher) = &mut self.file_watcher { + watcher.watch_file(&path_buf); + } + self.documents + .push(Document::with_path(content, Some(filename), path_buf)); self.active_doc = self.documents.len() - 1; self.open_file_path.clear(); self.open_file_cursor = 0; @@ -726,6 +861,105 @@ impl App { } } + fn handle_preview_mode_event(&mut self, event: Event) -> miette::Result<()> { + if let Event::Mouse(mouse_event) = event { + match mouse_event.kind { + MouseEventKind::ScrollDown => { + self.preview_scroll = self.preview_scroll.saturating_add(3); + } + MouseEventKind::ScrollUp => { + self.preview_scroll = self.preview_scroll.saturating_sub(3); + } + _ => {} + } + self.clamp_preview_scroll(); + return Ok(()); + } + + if let Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + .. + }) = event + { + match (code, modifiers) { + // Exit preview mode + (KeyCode::Esc, _) | (KeyCode::Char('p'), _) => { + self.mode = Mode::Normal; + } + // Quit + (KeyCode::Char('q'), _) => { + self.should_quit = true; + } + // Switch tabs + (KeyCode::Right, _) | (KeyCode::Tab, _) => { + self.next_tab(); + self.preview_scroll = 0; + } + (KeyCode::Left, _) | (KeyCode::BackTab, _) => { + self.prev_tab(); + self.preview_scroll = 0; + } + // Scroll + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { + self.preview_scroll = self.preview_scroll.saturating_add(1); + } + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { + self.preview_scroll = self.preview_scroll.saturating_sub(1); + } + (KeyCode::PageDown, _) => { + self.preview_scroll = self.preview_scroll.saturating_add(10); + } + (KeyCode::PageUp, _) => { + self.preview_scroll = self.preview_scroll.saturating_sub(10); + } + // Jump to top/bottom (vim-style) + (KeyCode::Char('g'), _) => { + self.preview_scroll = 0; + } + (KeyCode::Char('G'), _) => { + self.preview_scroll = u16::MAX; + } + // Show help + (KeyCode::Char('?'), _) | (KeyCode::F(1), _) => { + self.mode = Mode::Help; + } + _ => {} + } + self.clamp_preview_scroll(); + } + + Ok(()) + } + + /// Get the current preview scroll offset (in rendered lines) + pub fn preview_scroll(&self) -> u16 { + self.preview_scroll + } + + /// Record the height of the preview viewport from the last draw, so that + /// scrolling can be clamped to the document's actual rendered length. + pub fn set_preview_viewport_height(&self, height: u16) { + self.preview_viewport_height.set(height); + } + + /// Get the content of the active document (used to render the preview) + pub fn active_doc_content(&self) -> &str { + &self.active_doc().content + } + + fn preview_total_lines(&self) -> usize { + preview::render_preview(&self.active_doc().content).len() + } + + fn clamp_preview_scroll(&mut self) { + let total = self.preview_total_lines(); + let viewport = self.preview_viewport_height.get().saturating_sub(2).max(1) as usize; + let max_scroll = total.saturating_sub(viewport) as u16; + self.preview_scroll = self.preview_scroll.min(max_scroll); + } + /// Update results based on current sidebar selection fn update_sidebar_selection(&mut self) { if !self.show_tree_sidebar { @@ -1206,7 +1440,10 @@ mod tests { fn test_query_applies_to_all_documents() { let mut app = App::with_files(vec![ ("# One".to_string(), "one.md".to_string()), - ("# Two\n\nbody\n\n## Three".to_string(), "two.md".to_string()), + ( + "# Two\n\nbody\n\n## Three".to_string(), + "two.md".to_string(), + ), ]); // Identity query, executed once, should populate results for every @@ -2073,4 +2310,123 @@ mod tests { // Verify the fix: query is "jk" not "jjkk" } + + #[test] + fn test_preview_mode_toggle_and_exit() { + let mut app = create_test_app(); + assert_eq!(app.mode(), Mode::Normal); + + app.handle_event(Event::Key(KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })) + .unwrap(); + assert_eq!(app.mode(), Mode::Preview); + + app.handle_event(Event::Key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })) + .unwrap(); + assert_eq!(app.mode(), Mode::Normal); + } + + #[test] + fn test_preview_mode_scroll_navigation() { + let mut app = App::new("# Title\n\nSome body text".to_string()); + app.set_mode(Mode::Preview); + + app.handle_event(Event::Key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })) + .unwrap(); + assert_eq!(app.preview_scroll(), 1); + + app.handle_event(Event::Key(KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })) + .unwrap(); + assert_eq!(app.preview_scroll(), 0); + } + + #[test] + fn test_preview_renders_active_document_content() { + let app = App::new("# Hello".to_string()); + assert_eq!(app.active_doc_content(), "# Hello"); + } + + #[test] + fn test_watch_disabled_by_default() { + let app = create_test_app(); + assert!(!app.watch()); + } + + #[test] + fn test_watch_reloads_changed_file() { + let tmp_dir = std::env::temp_dir(); + let file_path = tmp_dir.join(format!("mq_tui_watch_test_{}.md", std::process::id())); + std::fs::write(&file_path, "# Original\n").unwrap(); + + let mut app = App::with_files_with_paths(vec![( + "# Original\n".to_string(), + "watch.md".to_string(), + file_path.clone(), + )]); + app.set_watch(true); + + // Give the OS watcher a moment to register before writing. + std::thread::sleep(Duration::from_millis(100)); + std::fs::write(&file_path, "# Updated\n").unwrap(); + + // The debouncer waits ~300ms before emitting; poll a bit longer than + // that for the change to be picked up. + let mut reloaded = false; + for _ in 0..30 { + std::thread::sleep(Duration::from_millis(50)); + app.check_for_file_changes(); + if app.active_doc_content() == "# Updated\n" { + reloaded = true; + break; + } + } + + std::fs::remove_file(&file_path).ok(); + assert!( + reloaded, + "expected the document to reload after the file changed on disk" + ); + } + + #[test] + fn test_watch_without_changes_keeps_content() { + let tmp_dir = std::env::temp_dir(); + let file_path = tmp_dir.join(format!( + "mq_tui_watch_test_nochange_{}.md", + std::process::id() + )); + std::fs::write(&file_path, "# Original\n").unwrap(); + + let mut app = App::with_files_with_paths(vec![( + "# Original\n".to_string(), + "watch.md".to_string(), + file_path.clone(), + )]); + app.set_watch(true); + + app.check_for_file_changes(); + + assert_eq!(app.active_doc_content(), "# Original\n"); + + std::fs::remove_file(&file_path).ok(); + } } diff --git a/src/event.rs b/src/event.rs index 31e26f9..26f365e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -78,16 +78,6 @@ mod tests { ); } - #[test] - fn test_event_handler_with_different_tick_rates() { - let fast_handler = EventHandler::new(Duration::from_millis(10)); - let slow_handler = EventHandler::new(Duration::from_millis(1000)); - - // Both should be created successfully - assert!(fast_handler.next().unwrap().is_none()); - assert!(slow_handler.next().unwrap().is_none()); - } - #[test] fn test_multiple_next_calls() { let handler = EventHandler::new(Duration::from_millis(50)); diff --git a/src/lib.rs b/src/lib.rs index 8af1cb6..3d49618 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod app; mod event; mod ui; mod util; +mod watcher; pub use app::App; pub use app::Mode; diff --git a/src/main.rs b/src/main.rs index a8e2b6e..fb63314 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ use std::path::PathBuf; $ mq_tui README.md\n\n Open multiple Markdown files as tabs:\n $ mq_tui README.md CHANGELOG.md\n\n + Watch a file and reload automatically when it changes:\n + $ mq_tui --watch README.md\n\n Read from stdin:\n $ cat README.md | mq_tui\n\n Use with mq CLI:\n @@ -23,6 +25,10 @@ struct Cli { /// Paths to the Markdown files to open (each opens as a tab) #[arg(value_name = "FILE")] file_paths: Vec, + + /// Watch opened files and reload automatically when they change on disk + #[arg(short, long)] + watch: bool, } fn main() -> miette::Result<()> { @@ -37,10 +43,13 @@ fn main() -> miette::Result<()> { .and_then(|n| n.to_str()) .unwrap_or("file.md") .to_string(); - files.push((content, filename)); + files.push((content, filename, file_path.clone())); } - App::with_files(files) + App::with_files_with_paths(files) } else if !io::stdin().is_terminal() { + if cli.watch { + return Err(miette!("--watch cannot be used when reading from stdin")); + } let mut content = String::new(); io::stdin().read_to_string(&mut content).into_diagnostic()?; App::with_file(content, "stdin".to_string()) @@ -50,6 +59,10 @@ fn main() -> miette::Result<()> { )); }; + if cli.watch { + app.set_watch(true); + } + app.run()?; Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 95fac62..a912e92 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ +pub mod preview; pub mod treeview; use ratatui::{ @@ -53,6 +54,9 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { tree_view.render(frame, results_area); } } + Mode::Preview => { + draw_preview(frame, app, results_area); + } _ => { // Show sidebar if enabled if app.show_tree_sidebar() && app.sidebar_tree_view().is_some() { @@ -262,6 +266,31 @@ fn draw_results_list(frame: &mut Frame, app: &App, area: Rect) { frame.render_stateful_widget(list, area, &mut state); } +/// Draw a best-effort rendered preview of the active document's full source. +fn draw_preview(frame: &mut Frame, app: &App, area: Rect) { + app.set_preview_viewport_height(area.height); + + let lines = preview::render_preview(app.active_doc_content()); + let total_lines = lines.len(); + let inner_height = area.height.saturating_sub(2).max(1); + let max_scroll = total_lines.saturating_sub(inner_height as usize) as u16; + let scroll = app.preview_scroll().min(max_scroll); + + let title = format!( + "Preview - {} (↑/k ↓/j scroll, g/G top/bottom, p/Esc to exit)", + app.filename().unwrap_or("untitled") + ); + + let block = Block::default().title(title).borders(Borders::ALL); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + + frame.render_widget(paragraph, area); +} + /// Check if a line is a markdown header (starts with #) fn is_markdown_header(line: &str) -> bool { let trimmed = line.trim_start(); @@ -279,8 +308,11 @@ fn draw_status_line(frame: &mut Frame, app: &App, area: Rect) { String::new() }; + let watch_info = if app.watch() { "👀 watching | " } else { "" }; + let status = format!( - "{}{} results | Execution time: {:.2}ms | Press q to quit", + "{}{}{} results | Execution time: {:.2}ms | Press q to quit", + watch_info, doc_info, results_count, exec_time.as_secs_f64() * 1000.0 @@ -308,7 +340,7 @@ fn draw_title_bar(frame: &mut Frame, app: &App, area: Rect) { ), Span::raw(" | "), Span::styled( - "Press 's' for sidebar, 't' for tree view, 'o' to open a file, '?' for help", + "Press 's' for sidebar, 't' for tree view, 'p' for preview, 'o' to open a file, '?' for help", Style::default().fg(Color::Gray), ), ]; @@ -509,6 +541,30 @@ fn draw_help_screen(frame: &mut Frame) { Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(" - Exit tree view"), ]), + Line::from(""), + Line::from(vec![Span::styled( + "Preview Mode", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::UNDERLINED), + )]), + Line::from(""), + Line::from(vec![ + Span::styled("p", Style::default().fg(Color::Yellow)), + Span::raw(" - Toggle rendered preview"), + ]), + Line::from(vec![ + Span::styled("↑/k ↓/j", Style::default().fg(Color::Yellow)), + Span::raw(" - Scroll preview"), + ]), + Line::from(vec![ + Span::styled("g/G", Style::default().fg(Color::Yellow)), + Span::raw(" - Jump to top/bottom"), + ]), + Line::from(vec![ + Span::styled("Esc/p", Style::default().fg(Color::Yellow)), + Span::raw(" - Exit preview"), + ]), ]; let help_paragraph = Paragraph::new(help_text) diff --git a/src/ui/preview.rs b/src/ui/preview.rs new file mode 100644 index 0000000..812e0fc --- /dev/null +++ b/src/ui/preview.rs @@ -0,0 +1,535 @@ +//! Best-effort rendering of raw Markdown source into styled `ratatui` lines, +//! so the TUI can show something close to a rendered preview without needing +//! a full CommonMark renderer. Operates line-by-line on the source text +//! (preserving the user's original layout) and applies simple inline styling +//! for emphasis, code spans, links, and images. + +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; + +/// Render raw Markdown `content` into a list of styled lines suitable for +/// display in a scrollable `Paragraph`. +pub fn render_preview(content: &str) -> Vec> { + let src_lines: Vec<&str> = content.lines().collect(); + let mut lines = Vec::with_capacity(src_lines.len()); + let mut in_code_block = false; + let mut code_fence = String::new(); + let mut i = 0; + + while i < src_lines.len() { + let raw = src_lines[i]; + let trimmed = raw.trim_start(); + + if in_code_block { + if trimmed.starts_with(code_fence.as_str()) { + in_code_block = false; + lines.push(Line::from(Span::styled( + code_fence.clone(), + Style::default().fg(Color::DarkGray), + ))); + } else { + lines.push(Line::from(Span::styled( + raw.to_string(), + Style::default().fg(Color::Green), + ))); + } + i += 1; + continue; + } + + if let Some(fence) = fence_marker(trimmed) { + in_code_block = true; + let lang = trimmed.trim_start_matches(['`', '~']).trim().to_string(); + code_fence = fence; + let title = if lang.is_empty() { + code_fence.clone() + } else { + format!("{} {}", code_fence, lang) + }; + lines.push(Line::from(Span::styled( + title, + Style::default().fg(Color::DarkGray), + ))); + i += 1; + continue; + } + + if let Some((depth, text)) = heading(trimmed) { + lines.push(Line::from(Span::styled(text, heading_style(depth)))); + i += 1; + continue; + } + + if is_horizontal_rule(trimmed) { + lines.push(Line::from(Span::styled( + "─".repeat(60), + Style::default().fg(Color::DarkGray), + ))); + i += 1; + continue; + } + + // Table header row, immediately followed by an alignment separator row. + if raw.contains('|') + && src_lines + .get(i + 1) + .is_some_and(|next| is_table_separator(next)) + { + lines.push(Line::from(table_row_spans( + raw, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + i += 1; + continue; + } + + if is_table_separator(raw) { + lines.push(Line::from(Span::styled( + "─".repeat(raw.trim().len().clamp(8, 80)), + Style::default().fg(Color::DarkGray), + ))); + i += 1; + continue; + } + + if raw.contains('|') && raw.trim().starts_with('|') { + lines.push(Line::from(table_row_spans(raw, Style::default()))); + i += 1; + continue; + } + + if let Some((level, rest)) = blockquote_prefix(raw) { + let mut spans = vec![Span::styled( + "┃ ".repeat(level.max(1)), + Style::default().fg(Color::DarkGray), + )]; + spans.extend(parse_inline( + rest, + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + )); + lines.push(Line::from(spans)); + i += 1; + continue; + } + + if let Some((indent, marker, checked, rest)) = list_item(raw) { + let mut spans = vec![Span::raw(" ".repeat(indent))]; + spans.push(Span::styled(marker, Style::default().fg(Color::Yellow))); + if let Some(is_checked) = checked { + spans.push(Span::styled( + if is_checked { "☑ " } else { "☐ " }, + Style::default().fg(Color::Cyan), + )); + } + spans.extend(parse_inline(rest, Style::default())); + lines.push(Line::from(spans)); + i += 1; + continue; + } + + if raw.trim().is_empty() { + lines.push(Line::from("")); + i += 1; + continue; + } + + lines.push(Line::from(parse_inline(raw, Style::default()))); + i += 1; + } + + lines +} + +fn fence_marker(trimmed: &str) -> Option { + if trimmed.starts_with("```") { + Some("```".to_string()) + } else if trimmed.starts_with("~~~") { + Some("~~~".to_string()) + } else { + None + } +} + +fn heading(trimmed: &str) -> Option<(usize, String)> { + if !trimmed.starts_with('#') { + return None; + } + let depth = trimmed.chars().take_while(|&c| c == '#').count(); + if depth == 0 || depth > 6 { + return None; + } + let rest = &trimmed[depth..]; + if !rest.is_empty() && !rest.starts_with(' ') { + return None; + } + let text = rest.trim().trim_end_matches('#').trim().to_string(); + Some((depth, text)) +} + +fn heading_style(depth: usize) -> Style { + match depth { + 1 => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + 2 => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + 3 => Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + _ => Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::ITALIC), + } +} + +fn is_horizontal_rule(trimmed: &str) -> bool { + let t = trimmed.trim(); + if t.len() < 3 { + return false; + } + let first = t.chars().next().unwrap(); + (first == '-' || first == '*' || first == '_') && t.chars().all(|c| c == first) +} + +fn is_table_separator(line: &str) -> bool { + let t = line.trim(); + if t.is_empty() || !t.contains('|') { + return false; + } + t.trim_matches('|').split('|').all(|cell| { + let cell = cell.trim(); + !cell.is_empty() && cell.chars().all(|c| c == '-' || c == ':') + }) +} + +fn table_row_spans(raw: &str, cell_style: Style) -> Vec> { + let cells: Vec<&str> = raw.trim().trim_matches('|').split('|').collect(); + let mut spans = Vec::new(); + for (idx, cell) in cells.iter().enumerate() { + if idx > 0 { + spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray))); + } + spans.extend(parse_inline(cell.trim(), cell_style)); + } + spans +} + +fn blockquote_prefix(raw: &str) -> Option<(usize, &str)> { + let mut rest = raw.trim_start(); + if !rest.starts_with('>') { + return None; + } + let mut level = 0; + while let Some(r) = rest.strip_prefix('>') { + level += 1; + rest = r.trim_start(); + } + Some((level, rest)) +} + +fn list_item(raw: &str) -> Option<(usize, String, Option, &str)> { + let indent = raw.len() - raw.trim_start().len(); + let trimmed = raw.trim_start(); + + if let Some(rest) = ["- ", "* ", "+ "] + .iter() + .find_map(|p| trimmed.strip_prefix(p)) + { + let (checked, rest) = checkbox(rest); + return Some((indent, "• ".to_string(), checked, rest)); + } + + let digits: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() { + let after = &trimmed[digits.len()..]; + if let Some(rest) = [". ", ") "].iter().find_map(|p| after.strip_prefix(p)) { + let (checked, rest) = checkbox(rest); + return Some((indent, format!("{}. ", digits), checked, rest)); + } + } + + None +} + +fn checkbox(s: &str) -> (Option, &str) { + if let Some(rest) = s.strip_prefix("[ ] ") { + return (Some(false), rest); + } + if let Some(rest) = ["[x] ", "[X] "].iter().find_map(|p| s.strip_prefix(p)) { + return (Some(true), rest); + } + (None, s) +} + +/// Render a single line of inline Markdown (emphasis, code spans, links, +/// images, strikethrough) into styled spans, falling back to plain text for +/// anything it doesn't recognize. +fn parse_inline(text: &str, base: Style) -> Vec> { + let chars: Vec = text.chars().collect(); + let len = chars.len(); + let mut spans = Vec::new(); + let mut buf = String::new(); + let mut i = 0; + + while i < len { + if chars[i] == '!' + && i + 1 < len + && chars[i + 1] == '[' + && let Some((alt, _url, consumed)) = parse_link_like(&chars, i + 1) + { + flush(&mut buf, &mut spans, base); + spans.push(Span::styled(format!("🖼 {}", alt), base.fg(Color::Magenta))); + i += 1 + consumed; + continue; + } + + if chars[i] == '[' + && let Some((label, _url, consumed)) = parse_link_like(&chars, i) + { + flush(&mut buf, &mut spans, base); + spans.push(Span::styled( + label, + base.fg(Color::Blue).add_modifier(Modifier::UNDERLINED), + )); + i += consumed; + continue; + } + + if chars[i] == '`' + && let Some((code, consumed)) = parse_delim(&chars, i, 1) + { + flush(&mut buf, &mut spans, base); + spans.push(Span::styled( + code, + Style::default().fg(Color::White).bg(Color::Rgb(40, 40, 40)), + )); + i += consumed; + continue; + } + + if chars[i] == '~' + && i + 1 < len + && chars[i + 1] == '~' + && let Some((inner, consumed)) = parse_delim(&chars, i, 2) + { + flush(&mut buf, &mut spans, base); + spans.extend(parse_inline( + &inner, + base.add_modifier(Modifier::CROSSED_OUT), + )); + i += consumed; + continue; + } + + if (chars[i] == '*' || chars[i] == '_') + && i + 1 < len + && chars[i + 1] == chars[i] + && let Some((inner, consumed)) = parse_delim(&chars, i, 2) + && !inner.is_empty() + { + flush(&mut buf, &mut spans, base); + spans.extend(parse_inline(&inner, base.add_modifier(Modifier::BOLD))); + i += consumed; + continue; + } + + if (chars[i] == '*' || chars[i] == '_') + && let Some((inner, consumed)) = parse_delim(&chars, i, 1) + && !inner.is_empty() + { + flush(&mut buf, &mut spans, base); + spans.extend(parse_inline(&inner, base.add_modifier(Modifier::ITALIC))); + i += consumed; + continue; + } + + buf.push(chars[i]); + i += 1; + } + + flush(&mut buf, &mut spans, base); + spans +} + +fn flush(buf: &mut String, spans: &mut Vec>, style: Style) { + if !buf.is_empty() { + spans.push(Span::styled(std::mem::take(buf), style)); + } +} + +/// Find a closing run of `width` identical delimiter chars starting at `start`, +/// returning the text between the delimiters and the total chars consumed +/// (including both delimiter runs). +fn parse_delim(chars: &[char], start: usize, width: usize) -> Option<(String, usize)> { + let delim = &chars[start..start + width]; + let mut j = start + width; + while j + width <= chars.len() { + if &chars[j..j + width] == delim { + let inner: String = chars[start + width..j].iter().collect(); + return Some((inner, j + width - start)); + } + j += 1; + } + None +} + +/// Parse a `[label](url)` or `[label]` construct starting at `chars[start] == '['`. +/// Returns the label text, the URL (empty if omitted), and total chars consumed. +fn parse_link_like(chars: &[char], start: usize) -> Option<(String, String, usize)> { + let mut j = start + 1; + while j < chars.len() && chars[j] != ']' && chars[j] != '\n' { + j += 1; + } + if j >= chars.len() || chars[j] != ']' { + return None; + } + let label: String = chars[start + 1..j].iter().collect(); + + if j + 1 < chars.len() && chars[j + 1] == '(' { + let mut k = j + 2; + while k < chars.len() && chars[k] != ')' { + k += 1; + } + if k < chars.len() { + let url: String = chars[j + 2..k].iter().collect(); + return Some((label, url, k + 1 - start)); + } + } + + Some((label, String::new(), j + 1 - start)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn test_heading_levels() { + let lines = render_preview("# One\n## Two\n###### Six"); + assert_eq!(line_text(&lines[0]), "One"); + assert_eq!(line_text(&lines[1]), "Two"); + assert_eq!(line_text(&lines[2]), "Six"); + assert!( + lines[0].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD) + ); + } + + #[test] + fn test_bold_and_italic() { + let lines = render_preview("**bold** and _italic_ text"); + let text = line_text(&lines[0]); + assert_eq!(text, "bold and italic text"); + + let bold_span = lines[0] + .spans + .iter() + .find(|s| s.content.as_ref() == "bold") + .unwrap(); + assert!(bold_span.style.add_modifier.contains(Modifier::BOLD)); + + let italic_span = lines[0] + .spans + .iter() + .find(|s| s.content.as_ref() == "italic") + .unwrap(); + assert!(italic_span.style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn test_inline_code() { + let lines = render_preview("Use `cargo build` to compile"); + let code_span = lines[0] + .spans + .iter() + .find(|s| s.content.as_ref() == "cargo build") + .unwrap(); + assert_eq!(code_span.style.bg, Some(Color::Rgb(40, 40, 40))); + } + + #[test] + fn test_link_shows_label_only() { + let lines = render_preview("[mq](https://github.com/harehare/mq)"); + assert_eq!(line_text(&lines[0]), "mq"); + } + + #[test] + fn test_unordered_list_item() { + let lines = render_preview("- first item"); + assert_eq!(line_text(&lines[0]), "• first item"); + } + + #[test] + fn test_ordered_list_item() { + let lines = render_preview("1. first item"); + assert_eq!(line_text(&lines[0]), "1. first item"); + } + + #[test] + fn test_task_list_checked() { + let lines = render_preview("- [x] done\n- [ ] todo"); + assert_eq!(line_text(&lines[0]), "• ☑ done"); + assert_eq!(line_text(&lines[1]), "• ☐ todo"); + } + + #[test] + fn test_blockquote() { + let lines = render_preview("> quoted text"); + assert_eq!(line_text(&lines[0]), "┃ quoted text"); + } + + #[test] + fn test_code_block() { + let lines = render_preview("```rust\nfn main() {}\n```"); + assert_eq!(lines.len(), 3); + assert_eq!(line_text(&lines[1]), "fn main() {}"); + assert_eq!(lines[1].spans[0].style.fg, Some(Color::Green)); + } + + #[test] + fn test_horizontal_rule() { + let lines = render_preview("---"); + assert!(line_text(&lines[0]).chars().all(|c| c == '─')); + } + + #[test] + fn test_table() { + let lines = render_preview("| A | B |\n| - | - |\n| 1 | 2 |"); + assert_eq!(line_text(&lines[0]), "A │ B"); + assert_eq!(line_text(&lines[2]), "1 │ 2"); + } + + #[test] + fn test_strikethrough() { + let lines = render_preview("~~removed~~"); + let span = &lines[0].spans[0]; + assert_eq!(span.content.as_ref(), "removed"); + assert!(span.style.add_modifier.contains(Modifier::CROSSED_OUT)); + } + + #[test] + fn test_image_shows_alt() { + let lines = render_preview("![alt text](image.png)"); + assert_eq!(line_text(&lines[0]), "🖼 alt text"); + } + + #[test] + fn test_empty_lines_preserved() { + let lines = render_preview("a\n\nb"); + assert_eq!(lines.len(), 3); + assert_eq!(line_text(&lines[1]), ""); + } +} diff --git a/src/watcher.rs b/src/watcher.rs new file mode 100644 index 0000000..f11f905 --- /dev/null +++ b/src/watcher.rs @@ -0,0 +1,141 @@ +//! Event-driven file watching for `--watch`, backed by the OS's native file +//! system notifications (inotify / FSEvents / ReadDirectoryChangesW) via the +//! `notify` crate, instead of polling files on a timer. + +use notify_debouncer_mini::{ + DebounceEventResult, Debouncer, new_debouncer, + notify::{RecommendedWatcher, RecursiveMode}, +}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::mpsc::{self, Receiver}, + time::Duration, +}; + +/// Watches one or more files for changes and reports changed paths. +/// +/// Internally watches each file's *parent directory* rather than the file +/// itself, so that editor save patterns that replace the file (write to a +/// temp file, then rename over the original) are still detected reliably. +pub struct FileWatcher { + debouncer: Debouncer, + receiver: Receiver, + watched_dirs: HashSet, +} + +impl FileWatcher { + pub fn new() -> notify_debouncer_mini::notify::Result { + let (tx, rx) = mpsc::channel(); + let debouncer = new_debouncer(Duration::from_millis(300), tx)?; + Ok(Self { + debouncer, + receiver: rx, + watched_dirs: HashSet::new(), + }) + } + + /// Start watching `path` for changes. No-op if its parent directory is + /// already watched. + pub fn watch_file(&mut self, path: &Path) { + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + + if self.watched_dirs.insert(dir.clone()) { + let _ = self + .debouncer + .watcher() + .watch(&dir, RecursiveMode::NonRecursive); + } + } + + /// Drain all pending (already debounced) change notifications without + /// blocking, returning the paths that changed. + pub fn drain_changed_paths(&self) -> Vec { + let mut paths = Vec::new(); + while let Ok(result) = self.receiver.try_recv() { + if let Ok(events) = result { + paths.extend(events.into_iter().map(|event| event.path)); + } + } + paths + } +} + +/// Whether `a` and `b` refer to the same file on disk, accounting for one +/// being relative and the other canonicalized (or vice versa). +pub fn paths_refer_to_same_file(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paths_refer_to_same_file_identical() { + let path = Path::new("foo.md"); + assert!(paths_refer_to_same_file(path, path)); + } + + #[test] + fn test_paths_refer_to_same_file_canonicalized() { + let tmp_dir = std::env::temp_dir(); + let file_path = tmp_dir.join(format!("mq_tui_watcher_test_{}.md", std::process::id())); + std::fs::write(&file_path, "content").unwrap(); + + let relative_looking = file_path.clone(); + assert!(paths_refer_to_same_file(&relative_looking, &file_path)); + + std::fs::remove_file(&file_path).ok(); + } + + #[test] + fn test_paths_refer_to_same_file_different() { + assert!(!paths_refer_to_same_file( + Path::new("/tmp/does-not-exist-a.md"), + Path::new("/tmp/does-not-exist-b.md") + )); + } + + #[test] + fn test_watch_file_detects_change() { + let tmp_dir = std::env::temp_dir(); + let file_path = tmp_dir.join(format!("mq_tui_watcher_live_{}.md", std::process::id())); + std::fs::write(&file_path, "before").unwrap(); + + let mut watcher = FileWatcher::new().unwrap(); + watcher.watch_file(&file_path); + + // Give the OS watcher a moment to register before we write. + std::thread::sleep(Duration::from_millis(100)); + std::fs::write(&file_path, "after").unwrap(); + + // The debouncer waits ~300ms before emitting; poll a little longer + // than that for the change notification. + let mut found = false; + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(50)); + if watcher + .drain_changed_paths() + .iter() + .any(|p| paths_refer_to_same_file(p, &file_path)) + { + found = true; + break; + } + } + + std::fs::remove_file(&file_path).ok(); + assert!(found, "expected a change notification for the watched file"); + } +}