diff --git a/src/app.rs b/src/app.rs index 982d4c36..f88a9bc3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2063,27 +2063,47 @@ impl App { } } - pub fn poll_persisted_session_changes(&mut self) { + /// Returns `true` if visible state changed (external comments merged or a + /// warning was raised) so the main loop can schedule a redraw without an + /// input event. + pub fn poll_persisted_session_changes(&mut self) -> bool { let Some(interval) = self.review_watch_interval else { - return; + return false; }; let now = Instant::now(); if now < self.next_review_watch_at { - return; + return false; } self.next_review_watch_at = now + interval; // Do not mutate the session while the user is composing or editing a // comment. The next poll after the editor closes will merge changes. if self.input_mode == InputMode::Comment { - return; + return false; } - if let Err(err) = self.reload_persisted_session_if_changed(false) { - self.set_warning(format!("Review reload failed: {err}")); + match self.reload_persisted_session_if_changed(false) { + Ok(0) => false, + Ok(_) => true, + Err(err) => { + self.set_warning(format!("Review reload failed: {err}")); + true + } } } + /// True while any forge background fetch (PR list/open/reload/threads/ + /// submit) is in flight. Used by the main loop to keep redrawing so + /// spinners animate and results land without waiting for input. + pub fn has_pending_pr_work(&self) -> bool { + self.pr_load_rx.is_some() + || self.pr_open_rx.is_some() + || self.pr_reload_rx.is_some() + || self.pr_range_reload_rx.is_some() + || self.pr_threads_rx.is_some() + || self.pr_submit_rx.is_some() + } + pub fn reload_persisted_session_if_changed(&mut self, force: bool) -> Result { let path = match self.session_path.clone() { Some(path) => path, @@ -4340,7 +4360,9 @@ impl App { }); } - pub fn clear_expired_message(&mut self) { + /// Returns `true` if a message was cleared so the main loop can + /// schedule a redraw. + pub fn clear_expired_message(&mut self) -> bool { let expired = self .message .as_ref() @@ -4349,6 +4371,7 @@ impl App { if expired { self.message = None; } + expired } pub fn cursor_down(&mut self, lines: usize) { diff --git a/src/main.rs b/src/main.rs index 9997b44f..6e076049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -297,6 +297,9 @@ fn main() -> anyhow::Result<()> { let mut pending_leader = false; // Track pending Ctrl+C for "press twice to exit" (with timestamp for 2s timeout) let mut pending_ctrl_c: Option = None; + // Only re-render when state actually changed; the diff renderer rebuilds + // every line on each draw, so idle redraws are expensive on large diffs. + let mut needs_redraw = true; // Main loop loop { @@ -308,6 +311,7 @@ fn main() -> anyhow::Result<()> { ) = rx.try_recv() { app.update_info = Some(info); + needs_redraw = true; } // Auto-clear expired pending Ctrl+C state and message @@ -316,30 +320,44 @@ fn main() -> anyhow::Result<()> { { pending_ctrl_c = None; app.message = None; + needs_redraw = true; } - app.clear_expired_message(); + needs_redraw |= app.clear_expired_message(); + + // Snapshot before polling so the tick that drains a channel (clearing + // its rx) still triggers a redraw with the applied result. While work + // is pending we redraw every tick anyway so spinners animate. + let pr_pending = app.has_pending_pr_work(); app.poll_pr_load_events(); app.poll_pr_open_events(); app.poll_pr_reload_events(); app.poll_pr_range_reload_events(); app.poll_pr_threads_events(); app.poll_pr_submit_events(); - app.poll_persisted_session_changes(); - - // Render. Bracket the frame in a synchronized-output pair - // (CSI ?2026h/l) so terminals (and zellij) buffer the whole repaint - // and present it atomically. Without this, scrolling over a slow link - // visibly tears as escape sequences arrive in chunks. Terminals that - // do not support DEC 2026 ignore it. - queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; - terminal.draw(|frame| { - ui::render(frame, &mut app); - })?; - execute!(terminal.backend_mut(), EndSynchronizedUpdate)?; + needs_redraw |= app.poll_persisted_session_changes(); + needs_redraw |= pr_pending; + + if needs_redraw { + // Bracket the frame in a synchronized-output pair (CSI ?2026h/l) + // so terminals (and zellij) buffer the whole repaint and present + // it atomically. Without this, scrolling over a slow link visibly + // tears as escape sequences arrive in chunks. Terminals that do + // not support DEC 2026 ignore it. + queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; + terminal.draw(|frame| { + ui::render(frame, &mut app); + })?; + execute!(terminal.backend_mut(), EndSynchronizedUpdate)?; + needs_redraw = false; + } // Handle events if event::poll(Duration::from_millis(100))? { + // Set before reading so the many `continue` paths below (leader + // keys, zz/ZZ, dd, {count}G, …) still schedule a redraw on the + // next iteration even though they short-circuit past the match. + needs_redraw = true; let event = event::read()?; // Down/Up Release flips the `*_released_since_arm` flag so the // primed two-press file walk in single-file view requires a