diff --git a/README.md b/README.md index 0e979d5..829ba7e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Options: -g, --graph-width Commit graph image cell width [default: auto] [possible values: auto, double, single] -s, --graph-style Commit graph image edge style [default: rounded] [possible values: rounded, angular] -i, --initial-selection Initial selection of commit [default: latest] [possible values: latest, head] + --watch [] Auto-refresh the view on an interval (seconds) [default: 30] + --fetch Run `git fetch --all` before each auto-refresh (implies --watch) -h, --help Print help -V, --version Print version ``` diff --git a/docs/src/getting-started/command-line-options.md b/docs/src/getting-started/command-line-options.md index 806306e..a098b48 100644 --- a/docs/src/getting-started/command-line-options.md +++ b/docs/src/getting-started/command-line-options.md @@ -66,3 +66,29 @@ _Possible values:_ `latest`, `head` `latest` will select the latest commit. `head` will select the commit at HEAD. + +## --watch \[\\] + +Enable automatic refresh of the view on a fixed interval. + +If passed without a value, the interval defaults to 30 seconds. +Values below 5 seconds are clamped to 5. + +The refresh only re-reads local git state — it does not contact any remote. +External git activity (commits made in another terminal, fetches done by +another tool) is picked up automatically as soon as it lands on disk. + +The current cursor / scroll position is preserved across refreshes. +Ticks that arrive while you are typing in the search prompt are silently +skipped so they do not interrupt input. + +## --fetch + +Run `git fetch --all --quiet` before each auto-refresh tick. + +Implies `--watch`: if `--watch` is not specified, the interval defaults +to 30 seconds. Combine with `--watch ` to override. + +Fetch errors (network unreachable, authentication failures, etc.) are +silently ignored so the refresh tick still fires with whatever local +state is available. diff --git a/src/app.rs b/src/app.rs index 44f1f3c..7a17681 100644 --- a/src/app.rs +++ b/src/app.rs @@ -269,6 +269,14 @@ impl App<'_> { let request = RefreshRequest { context }; return Ok(Ret::Refresh(request)); } + AppEvent::AutoRefresh => { + // Skip ticks that arrive while the user is typing in the status line + // (e.g. a search query) so we don't tear down their input. + if matches!(self.app_status.status_line, StatusLine::Input(_, _, _)) { + continue; + } + self.view.refresh(); + } AppEvent::ClearStatusLine => { self.clear_status_line(); } diff --git a/src/event.rs b/src/event.rs index 80cf90d..e3de89d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,10 +1,12 @@ use std::{ fmt::{self, Debug, Formatter}, + process::{Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, thread, + time::Duration, }; use ratatui::crossterm::event::KeyEvent; @@ -33,6 +35,7 @@ pub enum AppEvent { SelectParentCommit, CopyToClipboard { name: String, value: String }, Refresh(RefreshViewContext), + AutoRefresh, ClearStatusLine, UpdateStatusInput(String, Option, Option), NotifyInfo(String), @@ -80,10 +83,14 @@ pub struct EventController { rx: Receiver, stop: Arc, handle: Arc>>>, + watch_interval: Option, + watch_fetch: bool, + watch_stop: Arc, + watch_handle: Arc>>>, } impl EventController { - pub fn init() -> Self { + pub fn init(watch_interval: Option, watch_fetch: bool) -> Self { let (tx, rx) = mpsc::channel(); let tx = Sender { tx }; let rx = Receiver { rx }; @@ -93,6 +100,10 @@ impl EventController { rx, stop: Arc::new(AtomicBool::new(false)), handle: Arc::new(Mutex::new(None)), + watch_interval, + watch_fetch, + watch_stop: Arc::new(AtomicBool::new(false)), + watch_handle: Arc::new(Mutex::new(None)), }; controller.start(); @@ -131,6 +142,46 @@ impl EventController { } }); *self.handle.lock().unwrap() = Some(handle); + + if let Some(interval) = self.watch_interval { + self.watch_stop.store(false, Ordering::Relaxed); + let stop = self.watch_stop.clone(); + let tx = self.tx.clone(); + let fetch = self.watch_fetch; + let handle = thread::spawn(move || loop { + // Sleep in small slices so we react to stop within ~100ms. + let mut remaining = interval; + while remaining > Duration::ZERO { + if stop.load(Ordering::Relaxed) { + return; + } + let slice = remaining.min(Duration::from_millis(100)); + thread::sleep(slice); + remaining = remaining.saturating_sub(slice); + } + if stop.load(Ordering::Relaxed) { + return; + } + if fetch { + // Best-effort: ignore network / auth failures so the + // refresh tick still fires. Force non-interactive so + // the fetch can't hang on a prompt the user can't see + // (TUI owns the terminal). + let _ = Command::new("git") + .args(["fetch", "--all", "--quiet"]) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + if stop.load(Ordering::Relaxed) { + return; + } + tx.send(AppEvent::AutoRefresh); + }); + *self.watch_handle.lock().unwrap() = Some(handle); + } } pub fn resume(&self) { @@ -161,6 +212,10 @@ impl EventController { if let Some(handle) = self.handle.lock().unwrap().take() { handle.join().unwrap(); } + self.watch_stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.watch_handle.lock().unwrap().take() { + handle.join().unwrap(); + } } fn drain_crossterm_event(&self) { diff --git a/src/lib.rs b/src/lib.rs index 0ec111a..2028bda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,14 @@ struct Args { /// Initial selection of commit [default: latest] #[arg(short, long, value_name = "TYPE")] initial_selection: Option, + + /// Auto-refresh the view on an interval (seconds) [default: 30] + #[arg(long, value_name = "SECONDS", num_args = 0..=1, default_missing_value = "30")] + watch: Option, + + /// Run `git fetch --all` before each auto-refresh (implies --watch) + #[arg(long)] + fetch: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize)] @@ -158,7 +166,9 @@ pub fn run() -> Result<()> { image_protocol, }); - let ec = event::EventController::init(); + let watch_seconds = args.watch.or(if args.fetch { Some(30) } else { None }); + let watch_interval = watch_seconds.map(|s| std::time::Duration::from_secs(s.max(5))); + let ec = event::EventController::init(watch_interval, args.fetch); let mut refresh_view_context = None; let mut terminal = None;