From c87661d6eee4ed0debaf6000375b9a6f580f253e Mon Sep 17 00:00:00 2001 From: epaez Date: Thu, 13 Nov 2025 19:33:02 -0500 Subject: [PATCH 1/4] loader for git operations --- Cargo.lock | 10 ++++ Cargo.toml | 1 + src/main.rs | 54 +++++++++++------ src/ui/loader_popup.rs | 130 +++++++++++++++++++++++++++++++++++++++++ src/ui/log_tab.rs | 64 ++++++-------------- src/ui/mod.rs | 1 + 6 files changed, 195 insertions(+), 65 deletions(-) create mode 100644 src/ui/loader_popup.rs diff --git a/Cargo.lock b/Cargo.lock index 4aaef10d..73b6ea7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,6 +560,7 @@ dependencies = [ "shell-words", "tempdir", "thiserror 2.0.12", + "throbber-widgets-tui", "toml", "tracing", "tracing-chrome", @@ -1224,6 +1225,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "throbber-widgets-tui" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e58887883fb0a259717b16d68d251983e40850b4b2f45c2da410f46d4333dc" +dependencies = [ + "ratatui", +] + [[package]] name = "time" version = "0.3.41" diff --git a/Cargo.toml b/Cargo.toml index 65f58498..2c6759f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ serde_with = "3.12.0" shell-words = "1.1.0" tempdir = "0.3.7" thiserror = "2.0.11" +throbber-widgets-tui = "0.9.0" toml = "0.8.19" tracing = { version = "0.1.41", features = ["attributes"] } tracing-chrome = "0.7.2" diff --git a/src/main.rs b/src/main.rs index 2eff93d7..30f20e2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,6 +154,7 @@ fn run_app( commander: &mut Commander, ) -> Result<()> { let mut start_time = Instant::now(); + let mut drawing_popup = false; loop { // Draw let mut terminal_draw_res = Ok(()); @@ -161,6 +162,17 @@ fn run_app( // Update current tab let update_span = trace_span!("update"); terminal_draw_res = update_span.in_scope(|| -> Result<()> { + // Update popup if present + if let Some(popup) = app.popup.as_mut() { + if let Some(component_action) = popup.update(commander)? { + app.handle_action(component_action, commander)?; + } + drawing_popup = true; + } else { + drawing_popup = false; + } + + // Update current tab if let Some(component_action) = app.get_or_init_current_tab(commander)?.update(commander)? { @@ -196,29 +208,33 @@ fn run_app( // Input let input_spawn = trace_span!("input"); - let event = loop { - match event::read()? { - event::Event::FocusLost => continue, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - .. - }) => continue, - event => break event, - } - }; - start_time = Instant::now(); + // Poll for events with a timeout to enable animations + if !drawing_popup || event::poll(std::time::Duration::from_millis(100))? { + let event = loop { + match event::read()? { + event::Event::FocusLost => continue, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + .. + }) => continue, + event => break event, + } + }; - let should_stop = input_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); - } + start_time = Instant::now(); - Ok(false) - })?; + let should_stop = input_spawn.in_scope(|| -> Result { + if app.input(event, commander)? { + return Ok(true); + } - if should_stop { - return Ok(()); + Ok(false) + })?; + + if should_stop { + return Ok(()); + } } } } diff --git a/src/ui/loader_popup.rs b/src/ui/loader_popup.rs new file mode 100644 index 00000000..eba55b5c --- /dev/null +++ b/src/ui/loader_popup.rs @@ -0,0 +1,130 @@ +use ansi_to_tui::IntoText; +use anyhow::Result; +use ratatui::{ + Frame, + crossterm::event::Event, + layout::Rect, + style::{Color, Style}, + widgets::{Block, BorderType, Clear}, +}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use throbber_widgets_tui::{Throbber, ThrobberState}; + +use crate::{ + ComponentInputResult, + commander::{CommandError, Commander}, + ui::{Component, ComponentAction, message_popup::MessagePopup}, +}; + +type OperationResult = Result; + +pub struct LoaderPopup { + operation_name: String, + result_rx: Receiver, + throbber_state: ThrobberState, + completed: bool, +} + +impl LoaderPopup { + pub fn new(operation_name: String, operation: F) -> Self + where + F: FnOnce() -> OperationResult + Send + 'static, + { + let (tx, rx): (Sender, Receiver) = mpsc::channel(); + + // Spawn thread to run the operation + thread::spawn(move || { + let result = operation(); + tx.send(result) + }); + + Self { + operation_name, + result_rx: rx, + throbber_state: ThrobberState::default(), + completed: false, + } + } +} + +impl Component for LoaderPopup { + fn update(&mut self, _commander: &mut Commander) -> Result> { + if let Ok(result) = self.result_rx.try_recv() { + self.completed = true; + + match result { + Ok(output) if !output.is_empty() => { + return Ok(Some(ComponentAction::Multiple(vec![ + ComponentAction::SetPopup(Some(Box::new(MessagePopup { + title: format!("{} message", self.operation_name).into(), + messages: output.into_text()?, + text_align: None, + }))), + ComponentAction::RefreshTab(), + ]))); + } + Ok(_) => { + return Ok(Some(ComponentAction::Multiple(vec![ + ComponentAction::SetPopup(None), + ComponentAction::RefreshTab(), + ]))); + } + Err(err) => { + return Ok(Some(ComponentAction::SetPopup(Some(Box::new( + MessagePopup { + title: format!("{} error", self.operation_name).into(), + messages: err.into_text("")?, + text_align: None, + }, + ))))); + } + } + } + + self.throbber_state.calc_next(); + + Ok(None) + } + + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Green)); + + let label = format!("{}...", self.operation_name); + let content_width = 2 + label.len() as u16; + let content_height = 1; + + let popup_width = content_width + 2; + let popup_height = content_height + 2; + + let popup_area = centered_rect_fixed(area, popup_width, popup_height); + f.render_widget(Clear, popup_area); + f.render_widget(&block, popup_area); + + let inner = block.inner(popup_area); + + let throbber = Throbber::default().label(label).style(Style::default()); + f.render_stateful_widget(throbber, inner, &mut self.throbber_state); + + Ok(()) + } + + fn input(&mut self, _commander: &mut Commander, _event: Event) -> Result { + // Block all input while loading + Ok(ComponentInputResult::Handled) + } +} + +fn centered_rect_fixed(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index c3da788c..9912987d 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -21,6 +21,7 @@ use crate::{ Component, ComponentAction, bookmark_set_popup::BookmarkSetPopup, help_popup::HelpPopup, + loader_popup::LoaderPopup, message_popup::MessagePopup, panel::DetailsPanel, panel::LogPanel, @@ -370,56 +371,27 @@ impl<'a> LogTab<'a> { all_bookmarks, allow_new, } => { - match commander.git_push(all_bookmarks, allow_new, &self.head.commit_id) { - Ok(result) if !result.is_empty() => { - return Ok(ComponentInputResult::HandledAction( - ComponentAction::SetPopup(Some(Box::new(MessagePopup { - title: "Push message".into(), - messages: result.into_text()?, - text_align: None, - }))), - )); - } - Err(err) => { - return Ok(ComponentInputResult::HandledAction( - ComponentAction::SetPopup(Some(Box::new(MessagePopup { - title: "Push error".into(), - messages: err.into_text("")?, - text_align: None, - }))), - )); - } - _ => (), - } + let commit_id = self.head.commit_id.clone(); + let commander_clone = Commander::new(&commander.env); - self.log_panel.refresh_log_output(commander); - self.refresh_head_output(commander); + let loader = LoaderPopup::new("Pushing".to_string(), move || { + commander_clone.git_push(all_bookmarks, allow_new, &commit_id) + }); + + return Ok(ComponentInputResult::HandledAction( + ComponentAction::SetPopup(Some(Box::new(loader))), + )); } LogTabEvent::Fetch { all_remotes } => { - match commander.git_fetch(all_remotes) { - Ok(result) if !result.is_empty() => { - return Ok(ComponentInputResult::HandledAction( - ComponentAction::SetPopup(Some(Box::new(MessagePopup { - title: "Fetch message".into(), - messages: result.into_text()?, - text_align: None, - }))), - )); - } - Err(err) => { - return Ok(ComponentInputResult::HandledAction( - ComponentAction::SetPopup(Some(Box::new(MessagePopup { - title: "Fetch error".into(), - messages: err.into_text("")?, - text_align: None, - }))), - )); - } - _ => (), - } + let commander_clone = Commander::new(&commander.env); - self.log_panel.refresh_log_output(commander); - self.refresh_head_output(commander); + let loader = LoaderPopup::new("Fetching".to_string(), move || { + commander_clone.git_fetch(all_remotes) + }); + + return Ok(ComponentInputResult::HandledAction( + ComponentAction::SetPopup(Some(Box::new(loader))), + )); } LogTabEvent::OpenHelp => { return Ok(ComponentInputResult::HandledAction( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2fd8c769..89b9deaa 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ pub mod command_log_tab; pub mod command_popup; pub mod files_tab; pub mod help_popup; +pub mod loader_popup; pub mod log_tab; pub mod message_popup; pub mod panel; From fa492efe97fee5bbd70511f627a1f6abd854e797 Mon Sep 17 00:00:00 2001 From: epaez Date: Fri, 9 Jan 2026 19:15:47 -0500 Subject: [PATCH 2/4] fix freeze loader when moving the mouse. update loader at fixed intervals --- src/main.rs | 6 +++--- src/ui/loader_popup.rs | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 30f20e2c..c962aeaf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ fn run_app( ) -> Result<()> { let mut start_time = Instant::now(); let mut drawing_popup = false; - loop { + 'main: loop { // Draw let mut terminal_draw_res = Ok(()); terminal.draw(|f| { @@ -213,11 +213,11 @@ fn run_app( if !drawing_popup || event::poll(std::time::Duration::from_millis(100))? { let event = loop { match event::read()? { - event::Event::FocusLost => continue, + event::Event::FocusLost => continue 'main, Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. - }) => continue, + }) => continue 'main, event => break event, } }; diff --git a/src/ui/loader_popup.rs b/src/ui/loader_popup.rs index eba55b5c..df2583f9 100644 --- a/src/ui/loader_popup.rs +++ b/src/ui/loader_popup.rs @@ -9,6 +9,7 @@ use ratatui::{ }; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; +use std::time::{Duration, Instant}; use throbber_widgets_tui::{Throbber, ThrobberState}; use crate::{ @@ -24,6 +25,7 @@ pub struct LoaderPopup { result_rx: Receiver, throbber_state: ThrobberState, completed: bool, + last_animation_update: Instant, } impl LoaderPopup { @@ -44,6 +46,7 @@ impl LoaderPopup { result_rx: rx, throbber_state: ThrobberState::default(), completed: false, + last_animation_update: Instant::now(), } } } @@ -82,7 +85,10 @@ impl Component for LoaderPopup { } } - self.throbber_state.calc_next(); + if self.last_animation_update.elapsed() >= Duration::from_millis(100) { + self.throbber_state.calc_next(); + self.last_animation_update = Instant::now(); + } Ok(None) } From 0f965dfd5e6a366c3696454eaa9fae295db518af Mon Sep 17 00:00:00 2001 From: epaez Date: Fri, 9 Jan 2026 19:25:53 -0500 Subject: [PATCH 3/4] centered_rect_fixed --- src/ui/loader_popup.rs | 14 +------------- src/ui/rebase_popup.rs | 25 +++---------------------- src/ui/utils.rs | 13 +++++++++++++ 3 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/ui/loader_popup.rs b/src/ui/loader_popup.rs index df2583f9..f238a862 100644 --- a/src/ui/loader_popup.rs +++ b/src/ui/loader_popup.rs @@ -15,7 +15,7 @@ use throbber_widgets_tui::{Throbber, ThrobberState}; use crate::{ ComponentInputResult, commander::{CommandError, Commander}, - ui::{Component, ComponentAction, message_popup::MessagePopup}, + ui::{Component, ComponentAction, message_popup::MessagePopup, utils::centered_rect_fixed}, }; type OperationResult = Result; @@ -122,15 +122,3 @@ impl Component for LoaderPopup { Ok(ComponentInputResult::Handled) } } - -fn centered_rect_fixed(area: Rect, width: u16, height: u16) -> Rect { - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - - Rect { - x, - y, - width: width.min(area.width), - height: height.min(area.height), - } -} diff --git a/src/ui/rebase_popup.rs b/src/ui/rebase_popup.rs index 7a3a8646..4121265a 100644 --- a/src/ui/rebase_popup.rs +++ b/src/ui/rebase_popup.rs @@ -25,7 +25,7 @@ use ratatui::{ Frame, crossterm::event::Event, layout::{Alignment, Rect}, - prelude::{Buffer, Constraint, Direction, Layout, Size}, + prelude::{Buffer, Constraint, Direction, Layout}, style::{Color, Style, Stylize}, text::{Line, Span, Text}, widgets::{Block, BorderType, Clear, Paragraph, StatefulWidget}, @@ -35,7 +35,7 @@ use crate::{ ComponentInputResult, commander::{Commander, log::Head}, keybinds::rebase_popup::{CutOption, PasteOption, PopupAction}, - ui::Component, + ui::{Component, utils::centered_rect_fixed}, }; type Keybinds = crate::keybinds::rebase_popup::Keybinds; @@ -65,13 +65,7 @@ impl RebasePopup { /// Collect all the rendering code that would have been in /// log_tab.rs/draw pub fn render_widget(&mut self, frame: &mut Frame) { - let area = center_rect( - frame.area(), - Size { - width: 32, - height: 12, - }, - ); + let area = centered_rect_fixed(frame.area(), 32, 12); self.draw(frame, area) .expect("Expected drawing without failues"); } @@ -212,19 +206,6 @@ impl Component for RebasePopup { } } -/****************************************************************/ -// TODO(@peso): Move this function to ui::utils - -/// Find a rect of the given size at the center of an outside rect -fn center_rect(outside: Rect, area: Size) -> Rect { - Rect { - x: outside.x + (outside.width - area.width) / 2, - y: outside.y + (outside.height - area.height) / 2, - width: area.width, - height: area.height, - } -} - /****************************************************************/ // TODO(@peso): Move this widget to a separate file diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 7b066ea1..b60fd88f 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -40,6 +40,19 @@ pub fn centered_rect_line_height(r: Rect, percent_x: u16, lines_y: u16) -> Rect .split(popup_layout[1])[1] } +/// Center a rect of fixed width and height within an outside rect +pub fn centered_rect_fixed(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} + /// replaces tabs in a string by spaces /// /// ratatui doesn't work well displaying tabs, so any From 44a37ea7680c966a2657339d21837d85afde20bc Mon Sep 17 00:00:00 2001 From: epaez Date: Fri, 9 Jan 2026 21:28:04 -0500 Subject: [PATCH 4/4] remove unnecessary event loop --- src/main.rs | 55 +++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/main.rs b/src/main.rs index c962aeaf..f55ce404 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ fn run_app( ) -> Result<()> { let mut start_time = Instant::now(); let mut drawing_popup = false; - 'main: loop { + loop { // Draw let mut terminal_draw_res = Ok(()); terminal.draw(|f| { @@ -209,31 +209,36 @@ fn run_app( // Input let input_spawn = trace_span!("input"); - // Poll for events with a timeout to enable animations - if !drawing_popup || event::poll(std::time::Duration::from_millis(100))? { - let event = loop { - match event::read()? { - event::Event::FocusLost => continue 'main, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - .. - }) => continue 'main, - event => break event, - } - }; - - start_time = Instant::now(); - - let should_stop = input_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); + // 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))? + } else { + true + }; + + 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(()); + } } - - Ok(false) - })?; - - if should_stop { - return Ok(()); } } }