From 52e53aad0b7428cec07393f436b3ddfb5b854b2b Mon Sep 17 00:00:00 2001 From: garrett Date: Sun, 11 Jan 2026 12:34:33 -0800 Subject: [PATCH] feat: auto-refresh when jj operations complete Add file system watching to automatically refresh the UI when external jj operations modify the repository. This enables real-time updates when working with multiple terminals or when LLMs make changes. Implementation: - Watch .jj/repo/op_heads/heads (only changes when jj ops complete) - Use notify crate for cross-platform file watching - Debounce rapid events (250ms) for LLM workflows - Use --ignore-working-copy during refresh to avoid conflicts - Disable watcher during our own operations This addresses the use case of monitoring jj repos while AI assistants or other tools make changes in separate terminals. Closes #130 Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 118 +++++++++++++++++++++++- Cargo.toml | 1 + src/commander/mod.rs | 16 ++++ src/event.rs | 213 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 82 +++++++++++------ 5 files changed, 396 insertions(+), 34 deletions(-) create mode 100644 src/event.rs diff --git a/Cargo.lock b/Cargo.lock index 73b6ea7..71185ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -257,7 +263,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "mio", "parking_lot", @@ -367,6 +373,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -472,6 +487,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.1", + "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 = "insta" version = "1.43.1" @@ -537,6 +572,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -553,6 +608,7 @@ dependencies = [ "clap", "insta", "itertools 0.14.0", + "notify", "ratatui", "regex", "serde", @@ -642,6 +698,30 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -831,7 +911,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cassowary", "compact_str", "crossterm", @@ -863,7 +943,7 @@ version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -930,7 +1010,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -949,6 +1029,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[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 = "schemars" version = "0.9.0" @@ -1451,6 +1540,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +[[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" @@ -1540,6 +1639,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.60.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1766,7 +1874,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2c6759f..0753653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ tracing-subscriber = "0.3.19" tui-textarea = "0.7.0" tui_confirm_dialog = "0.3.1" version-compare = "0.2.0" +notify = "8.0.0" # Release build optimize size. # Run strip manually after build to reduce further. diff --git a/src/commander/mod.rs b/src/commander/mod.rs index 1290550..fdb74e9 100644 --- a/src/commander/mod.rs +++ b/src/commander/mod.rs @@ -109,6 +109,10 @@ pub struct Commander { env_var: Arc>>, pub command_history: Arc>>, + /// When true, add --ignore-working-copy to jj commands. + /// Used during auto-refresh to avoid conflicts with external operations. + ignore_working_copy: bool, + // Used for testing pub jj_config_toml: Option>, pub force_no_color: bool, @@ -120,11 +124,18 @@ impl Commander { env: env.clone(), env_var: Arc::new(Mutex::new(Vec::new())), command_history: Arc::new(Mutex::new(Vec::new())), + ignore_working_copy: false, jj_config_toml: None, force_no_color: false, } } + /// Set whether to use --ignore-working-copy for jj commands. + /// Used during auto-refresh to avoid conflicts with external operations. + pub fn set_ignore_working_copy(&mut self, ignore: bool) { + self.ignore_working_copy = ignore; + } + /// Tell jj to limit the with of output of secondary programs, like diff tools /// Will ignore too narrow width requests. /// You should call this before calling a jj command. @@ -210,6 +221,11 @@ impl Commander { command.args(args); command.args(get_output_args(!self.force_no_color && color, quiet)); + // Add --ignore-working-copy if set (used during auto-refresh) + if self.ignore_working_copy { + command.arg("--ignore-working-copy"); + } + if let Some(jj_config_toml) = &self.jj_config_toml { for cfg in jj_config_toml { command.args(["--config", cfg]); diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..2ad662b --- /dev/null +++ b/src/event.rs @@ -0,0 +1,213 @@ +//! Event handling for lazyjj. +//! +//! This module provides an event source that combines: +//! - User input events (keyboard, mouse) from crossterm +//! - File system events from notify (watching .jj/repo/op_heads/heads) +//! +//! The file watcher triggers a refresh when jj operations complete, +//! enabling real-time updates when working with multiple terminals +//! or when LLMs make changes to the repository. + +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + thread, + time::{Duration, Instant}, +}; + +use notify::{RecursiveMode, Watcher}; +use ratatui::crossterm; +use tracing::{error, trace}; + +/// Minimum time between idle events (reduces CPU when nothing is happening) +const IDLE_TIMEOUT: Duration = Duration::from_secs(1); + +/// Debounce delay for filesystem events. +/// jj operations may touch multiple files rapidly, so we wait for things to settle. +/// 250ms works well for LLMs making rapid changes. +const NOTIFY_DEBOUNCE: Duration = Duration::from_millis(250); + +/// Events that can be sent to the application +#[derive(Debug, Clone, PartialEq)] +pub enum AppEvent { + /// User input from keyboard or mouse + Input(crossterm::event::Event), + /// The jj repository state changed (operation completed) + RepoChanged, +} + +/// Manages event sources and provides a unified event stream +pub struct EventSource { + /// Channel for receiving events + rx: mpsc::Receiver, + /// Channel for sending events (cloned to background threads) + tx: mpsc::Sender, + /// File watcher handle (kept alive) + _watcher: Option, + /// Flag to temporarily disable watcher events during our own operations + watcher_enabled: Arc, + /// Track when we last received an event for debouncing + last_event_time: Instant, + /// Track if last recv returned None (for idle timeout) + was_idle: bool, +} + +impl EventSource { + /// Create a new event source + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + rx, + tx, + _watcher: None, + watcher_enabled: Arc::new(AtomicBool::new(true)), + last_event_time: Instant::now(), + was_idle: false, + } + } + + /// Start the user input listener thread + pub fn start_input_listener(&self) { + let tx = self.tx.clone(); + thread::Builder::new() + .name("input-listener".to_string()) + .spawn(move || { + trace!("input listener started"); + loop { + match crossterm::event::read() { + Ok(event) => { + if tx.send(AppEvent::Input(event)).is_err() { + break; // Channel closed + } + } + Err(e) => { + error!("input read error: {:?}", e); + break; + } + } + } + trace!("input listener stopped"); + }) + .expect("failed to spawn input listener thread"); + } + + /// Start watching the jj repository for changes + pub fn start_repo_watcher(&mut self, repo_root: PathBuf) { + let op_heads_dir = repo_root + .join(".jj") + .join("repo") + .join("op_heads") + .join("heads"); + + // Create channel for notify events + let (notify_tx, notify_rx) = mpsc::channel(); + + // Create watcher + let watcher = match notify::recommended_watcher(notify_tx) { + Ok(w) => w, + Err(e) => { + error!("failed to create file watcher: {:?}", e); + return; + } + }; + + // Store watcher to keep it alive + self._watcher = Some(watcher); + + // Start watching (get mutable reference after storing) + if let Some(ref mut watcher) = self._watcher { + if let Err(e) = watcher.watch(&op_heads_dir, RecursiveMode::NonRecursive) { + error!("failed to watch {:?}: {:?}", op_heads_dir, e); + self._watcher = None; + return; + } + } + + // Spawn thread to forward notify events + let tx = self.tx.clone(); + let watcher_enabled = self.watcher_enabled.clone(); + thread::Builder::new() + .name("repo-watcher".to_string()) + .spawn(move || { + trace!("repo watcher started, watching {:?}", op_heads_dir); + for _event in notify_rx { + // Only forward if watcher is enabled + if watcher_enabled.load(Ordering::Relaxed) { + if tx.send(AppEvent::RepoChanged).is_err() { + break; // Channel closed + } + trace!("repo change event forwarded"); + } else { + trace!("repo change event ignored (watcher disabled)"); + } + } + trace!("repo watcher stopped"); + }) + .expect("failed to spawn repo watcher thread"); + } + + /// Temporarily disable the repo watcher (e.g., during our own jj operations) + pub fn disable_watcher(&self) { + self.watcher_enabled.store(false, Ordering::Relaxed); + } + + /// Re-enable the repo watcher + pub fn enable_watcher(&self) { + self.watcher_enabled.store(true, Ordering::Relaxed); + } + + /// Try to receive an event. + /// Returns Some(event) if an event is available, None for idle timeout. + /// Handles debouncing of rapid RepoChanged events. + pub fn try_recv(&mut self) -> Option { + // Use longer timeout if we were idle (reduces CPU usage) + let timeout = if self.was_idle { + IDLE_TIMEOUT + } else { + Duration::ZERO + }; + + // Enable watcher while waiting for events + self.enable_watcher(); + + let result = loop { + match self.rx.recv_timeout(timeout) { + Ok(event) => { + // Debounce rapid RepoChanged events + if event == AppEvent::RepoChanged { + if self.last_event_time.elapsed() < NOTIFY_DEBOUNCE { + trace!("debouncing RepoChanged event"); + continue; + } + } + break Some(event); + } + Err(mpsc::RecvTimeoutError::Timeout) => break None, + Err(mpsc::RecvTimeoutError::Disconnected) => break None, + } + }; + + // Disable watcher while processing + self.disable_watcher(); + + // Update state + if let Some(ref event) = result { + self.last_event_time = Instant::now(); + self.was_idle = false; + trace!("received event: {:?}", event); + } else { + self.was_idle = true; + } + + result + } +} + +impl Default for EventSource { + fn default() -> Self { + Self::new() + } +} diff --git a/src/main.rs b/src/main.rs index f55ce40..05588ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use ratatui::{ backend::{Backend, CrosstermBackend}, crossterm::{ event::{ - self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, + DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event, KeyboardEnhancementFlags, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, @@ -35,6 +35,7 @@ use tracing_subscriber::layer::SubscriberExt; mod app; mod commander; mod env; +mod event; mod keybinds; mod ui; @@ -42,7 +43,8 @@ use crate::{ app::App, commander::Commander, env::Env, - ui::{ComponentAction, ui}, + event::{AppEvent, EventSource}, + ui::{ui, ComponentAction}, }; /// Simple program to greet a person @@ -153,8 +155,14 @@ fn run_app( app: &mut App, commander: &mut Commander, ) -> Result<()> { + // Set up event source with input listener and repo watcher + let mut event_source = EventSource::new(); + event_source.start_input_listener(); + event_source.start_repo_watcher(std::path::PathBuf::from(&app.env.root)); + let mut start_time = Instant::now(); let mut drawing_popup = false; + loop { // Draw let mut terminal_draw_res = Ok(()); @@ -206,40 +214,56 @@ fn run_app( })?; terminal_draw_res?; - // Input - let input_spawn = trace_span!("input"); + // Input - get next event from unified event source + let input_span = trace_span!("input"); - // if drawing a loader, wait for events for 100ms or redraw - // if not drawing a loader, block and wait for events - let should_read_event = if drawing_popup { - event::poll(std::time::Duration::from_millis(100))? + // Get event (blocks with idle timeout when no popup, polls when popup showing) + let event = if drawing_popup { + // Non-blocking check when drawing popup (for animation) + event_source.try_recv() } else { - true + // Blocking wait when no popup + event_source.try_recv() }; - if should_read_event { - match event::read()? { - event::Event::FocusLost => continue, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - .. - }) => continue, - event => { - start_time = Instant::now(); - - let should_stop = input_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); - } - - Ok(false) - })?; - - if should_stop { - return Ok(()); + let Some(app_event) = event else { + // Idle timeout - just redraw + continue; + }; + + match app_event { + AppEvent::Input(term_event) => { + // Filter out events we don't care about + match term_event { + Event::FocusLost => continue, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + .. + }) => continue, + _ => {} + } + + start_time = Instant::now(); + + let should_stop = input_span.in_scope(|| -> Result { + if app.input(term_event, commander)? { + return Ok(true); } + Ok(false) + })?; + + if should_stop { + return Ok(()); } } + AppEvent::RepoChanged => { + // Repository changed externally - refresh current tab + // Use --ignore-working-copy to avoid conflicts + commander.set_ignore_working_copy(true); + app.get_or_init_current_tab(commander)?.focus(commander)?; + commander.set_ignore_working_copy(false); + start_time = Instant::now(); + } } } }