From d4f287c9c9b8ae9e53323f08d1f7db161b4ff864 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 01:24:35 +0200 Subject: [PATCH 1/4] Add --watch flag for periodic auto-refresh Spawns a worker thread that emits AppEvent::AutoRefresh every N seconds (default 30, minimum 5). The handler skips ticks while the user is typing in the status line, then dispatches to the active view's existing refresh() method, reusing the manual-refresh path end-to-end so cursor and scroll state are preserved. --- src/app.rs | 8 ++++++++ src/event.rs | 37 ++++++++++++++++++++++++++++++++++++- src/lib.rs | 7 ++++++- 3 files changed, 50 insertions(+), 2 deletions(-) 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..09ff6b9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,6 +5,7 @@ use std::{ mpsc, Arc, Mutex, }, thread, + time::Duration, }; use ratatui::crossterm::event::KeyEvent; @@ -33,6 +34,7 @@ pub enum AppEvent { SelectParentCommit, CopyToClipboard { name: String, value: String }, Refresh(RefreshViewContext), + AutoRefresh, ClearStatusLine, UpdateStatusInput(String, Option, Option), NotifyInfo(String), @@ -80,10 +82,13 @@ pub struct EventController { rx: Receiver, stop: Arc, handle: Arc>>>, + watch_interval: Option, + watch_stop: Arc, + watch_handle: Arc>>>, } impl EventController { - pub fn init() -> Self { + pub fn init(watch_interval: Option) -> Self { let (tx, rx) = mpsc::channel(); let tx = Sender { tx }; let rx = Receiver { rx }; @@ -93,6 +98,9 @@ impl EventController { rx, stop: Arc::new(AtomicBool::new(false)), handle: Arc::new(Mutex::new(None)), + watch_interval, + watch_stop: Arc::new(AtomicBool::new(false)), + watch_handle: Arc::new(Mutex::new(None)), }; controller.start(); @@ -131,6 +139,29 @@ 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 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; + } + tx.send(AppEvent::AutoRefresh); + }); + *self.watch_handle.lock().unwrap() = Some(handle); + } } pub fn resume(&self) { @@ -161,6 +192,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..ad15957 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,10 @@ 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize)] @@ -158,7 +162,8 @@ pub fn run() -> Result<()> { image_protocol, }); - let ec = event::EventController::init(); + let watch_interval = args.watch.map(|s| std::time::Duration::from_secs(s.max(5))); + let ec = event::EventController::init(watch_interval); let mut refresh_view_context = None; let mut terminal = None; From e6a305433d21f2248ea2c18ec0a02edb03c3dbce Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 01:26:16 +0200 Subject: [PATCH 2/4] Add --fetch flag to fetch from remotes before each refresh Implies --watch (defaults to 30s when not set). Runs `git fetch --all --quiet` on the watch worker thread before sending each AutoRefresh, so commits and refs pushed to remotes show up automatically. Errors (offline, auth failures) are swallowed so the refresh tick still fires. --- src/event.rs | 18 +++++++++++++++++- src/lib.rs | 9 +++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/event.rs b/src/event.rs index 09ff6b9..5795a54 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,5 +1,6 @@ use std::{ fmt::{self, Debug, Formatter}, + process::{Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, @@ -83,12 +84,13 @@ pub struct EventController { stop: Arc, handle: Arc>>>, watch_interval: Option, + watch_fetch: bool, watch_stop: Arc, watch_handle: Arc>>>, } impl EventController { - pub fn init(watch_interval: Option) -> Self { + pub fn init(watch_interval: Option, watch_fetch: bool) -> Self { let (tx, rx) = mpsc::channel(); let tx = Sender { tx }; let rx = Receiver { rx }; @@ -99,6 +101,7 @@ impl EventController { 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)), }; @@ -144,6 +147,7 @@ impl EventController { 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; @@ -158,6 +162,18 @@ impl EventController { if stop.load(Ordering::Relaxed) { return; } + if fetch { + // Best-effort: ignore network / auth failures so the + // refresh tick still fires. + let _ = Command::new("git") + .args(["fetch", "--all", "--quiet"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + if stop.load(Ordering::Relaxed) { + return; + } tx.send(AppEvent::AutoRefresh); }); *self.watch_handle.lock().unwrap() = Some(handle); diff --git a/src/lib.rs b/src/lib.rs index ad15957..2028bda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,10 @@ struct Args { /// 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)] @@ -162,8 +166,9 @@ pub fn run() -> Result<()> { image_protocol, }); - let watch_interval = args.watch.map(|s| std::time::Duration::from_secs(s.max(5))); - let ec = event::EventController::init(watch_interval); + 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; From ccf7e9535180b9213af916570aef05b02d172313 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 01:31:34 +0200 Subject: [PATCH 3/4] Document --watch and --fetch flags Update the help block in README.md and add dedicated sections to the command-line options page in the mdBook docs. --- README.md | 2 ++ .../getting-started/command-line-options.md | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) 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. From 3e146b3051c11884398947fb0aa126b57746f710 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 12:29:32 +0200 Subject: [PATCH 4/4] Prevent --fetch from hanging on terminal prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch worker thread inherits the TUI's terminal, which is in raw mode under the alternate screen — so any prompt git's child wants to show (credential prompts, host-key confirmations) blocks indefinitely because the user can't see or answer it. Stderr is also swallowed, so the hang is invisible. Force the fetch fully non-interactive: set GIT_TERMINAL_PROMPT=0 to disable git's own prompts and close stdin on the child so anything that falls back to reading stdin fails fast instead of waiting. --- src/event.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/event.rs b/src/event.rs index 5795a54..e3de89d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -164,9 +164,13 @@ impl EventController { } if fetch { // Best-effort: ignore network / auth failures so the - // refresh tick still fires. + // 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();