From dd892c389ce7f6815111dc92d7594b5915ceab5b Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 8 Mar 2026 17:10:13 +0900 Subject: [PATCH 01/13] Define suspend user command type --- src/app.rs | 7 +++++++ src/config.rs | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/app.rs b/src/app.rs index 7b10a6d..857f06a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -441,6 +441,9 @@ impl App<'_> { Ok(UserCommandType::Silent) => { self.open_user_command_silent(user_command_number); } + Ok(UserCommandType::Suspend) => { + self.open_user_command_suspend(user_command_number); + } Err(err) => { self.tx.send(AppEvent::NotifyError(err)); } @@ -522,6 +525,10 @@ impl App<'_> { } } + fn open_user_command_suspend(&mut self, user_command_number: usize) { + // todo + } + fn close_user_command(&mut self) { if let View::UserCommand(ref mut view) = self.view { let commit_list_state = view.take_list_state(); diff --git a/src/config.rs b/src/config.rs index 126c2ef..f9e8cd4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -255,6 +255,7 @@ pub enum UserCommandType { #[default] Inline, Silent, + Suspend, } #[optional(derives = [Deserialize])] @@ -514,6 +515,7 @@ mod tests { commands_1 = { name = "git diff no color", commands = ["git", "diff", "{{first_parent_hash}}", "{{target_hash}}"] } commands_2 = { name = "echo hello", type = "silent", commands = ["echo", "hello"], refresh = true } commands_10 = { name = "echo world", type = "inline", commands = ["echo", "world"], refresh = false } + commands_3 = { name = "open vim", type = "suspend", commands = ["vim"] } tab_width = 2 [ui.common] cursor_type = { Virtual = "|" } @@ -576,6 +578,15 @@ mod tests { refresh: true, }, ), + ( + "3".into(), + UserCommand { + name: "open vim".into(), + r#type: UserCommandType::Suspend, + commands: vec!["vim".into()], + refresh: false, + }, + ), ( "10".into(), UserCommand { From c665ed9f5a96c9d6a6b4cabb8f7445fbe52223cd Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 8 Mar 2026 23:22:18 +0900 Subject: [PATCH 02/13] Define exec_user_command_suspend function --- src/external.rs | 49 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/external.rs b/src/external.rs index 411ef6f..1f52a29 100644 --- a/src/external.rs +++ b/src/external.rs @@ -83,20 +83,7 @@ pub struct ExternalCommandParameters { } pub fn exec_user_command(params: ExternalCommandParameters) -> Result { - let mut command = Vec::new(); - for arg in ¶ms.command { - match arg.as_str() { - // If the marker is used as a standalone argument, expand it into multiple arguments. - // This allows the command to receive each item as a separate argument and correctly handle items that contain spaces. - USER_COMMAND_BRANCHES_MARKER => command.extend(params.branches.clone()), - USER_COMMAND_REMOTE_BRANCHES_MARKER => command.extend(params.remote_branches.clone()), - USER_COMMAND_TAGS_MARKER => command.extend(params.tags.clone()), - USER_COMMAND_REFS_MARKER => command.extend(params.all_refs.clone()), - USER_COMMAND_PARENT_HASHES_MARKER => command.extend(params.parent_hashes.clone()), - // Otherwise, replace the marker within the single argument string. - _ => command.push(replace_command_arg(arg, ¶ms)), - } - } + let command = build_user_command(¶ms); let output = Command::new(&command[0]) .args(&command[1..]) @@ -115,6 +102,40 @@ pub fn exec_user_command(params: ExternalCommandParameters) -> Result Result<(), String> { + let command = build_user_command(¶ms); + + let output = Command::new(&command[0]) + .args(&command[1..]) + .status() + .map_err(|e| format!("Failed to execute command: {e:?}"))?; + + if !output.success() { + let msg = format!("Command exited with non-zero status: {}", output); + return Err(msg); + } + + Ok(()) +} + +fn build_user_command(params: &ExternalCommandParameters) -> Vec { + let mut command = Vec::new(); + for arg in ¶ms.command { + match arg.as_str() { + // If the marker is used as a standalone argument, expand it into multiple arguments. + // This allows the command to receive each item as a separate argument and correctly handle items that contain spaces. + USER_COMMAND_BRANCHES_MARKER => command.extend(params.branches.clone()), + USER_COMMAND_REMOTE_BRANCHES_MARKER => command.extend(params.remote_branches.clone()), + USER_COMMAND_TAGS_MARKER => command.extend(params.tags.clone()), + USER_COMMAND_REFS_MARKER => command.extend(params.all_refs.clone()), + USER_COMMAND_PARENT_HASHES_MARKER => command.extend(params.parent_hashes.clone()), + // Otherwise, replace the marker within the single argument string. + _ => command.push(replace_command_arg(arg, params)), + } + } + command +} + fn replace_command_arg(s: &str, params: &ExternalCommandParameters) -> String { if !s.contains(USER_COMMAND_MARKER_PREFIX) { return s.to_string(); From 542395009dc377d0322a817e20ca2c676aba949e Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 8 Mar 2026 23:33:51 +0900 Subject: [PATCH 03/13] Define EventController --- src/app.rs | 5 +++- src/event.rs | 83 +++++++++++++++++++++++++++++++++++++++------------- src/lib.rs | 3 +- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/app.rs b/src/app.rs index 857f06a..ebb2197 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use rustc_hash::FxHashMap; use crate::{ color::{ColorTheme, GraphColorSet}, config::{CoreConfig, CursorType, UiConfig, UserCommand, UserCommandType}, - event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount}, + event::{AppEvent, EventController, Receiver, Sender, UserEvent, UserEventWithCount}, external::{copy_to_clipboard, exec_user_command, ExternalCommandParameters}, git::{Commit, Head, Ref, Repository}, graph::{CellWidthType, Graph, GraphImageManager}, @@ -75,6 +75,7 @@ pub struct App<'a> { app_status: AppStatus, ctx: Rc, tx: Sender, + ec: &'a EventController, } impl<'a> App<'a> { @@ -87,6 +88,7 @@ impl<'a> App<'a> { initial_selection: InitialSelection, ctx: Rc, tx: Sender, + ec: &'a EventController, refresh_view_context: Option, ) -> Self { let mut ref_name_to_commit_index_map = FxHashMap::default(); @@ -133,6 +135,7 @@ impl<'a> App<'a> { app_status: AppStatus::default(), ctx, tx, + ec, }; if let Some(context) = refresh_view_context { diff --git a/src/event.rs b/src/event.rs index 3ecb298..3b7fabe 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,6 +1,9 @@ use std::{ fmt::{self, Debug, Formatter}, - sync::mpsc, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, thread, }; @@ -12,6 +15,7 @@ use serde::{ use crate::view::RefreshViewContext; +#[derive(Debug)] pub enum AppEvent { Key(KeyEvent), Resize(usize, usize), @@ -68,30 +72,69 @@ impl Receiver { } } -pub fn init() -> (Sender, Receiver) { - let (tx, rx) = mpsc::channel(); - let tx = Sender { tx }; - let rx = Receiver { rx }; +#[derive(Debug)] +pub struct EventController { + tx: Sender, + stop: Arc, + handle: Arc>>>, +} + +impl EventController { + pub fn init() -> (Self, Sender, Receiver) { + let (tx, rx) = mpsc::channel(); + let tx = Sender { tx }; + let rx = Receiver { rx }; - let event_tx = tx.clone(); - thread::spawn(move || loop { - match ratatui::crossterm::event::read() { - Ok(e) => match e { - ratatui::crossterm::event::Event::Key(key) => { - event_tx.send(AppEvent::Key(key)); + let controller = EventController { + tx: tx.clone(), + stop: Arc::new(AtomicBool::new(false)), + handle: Arc::new(Mutex::new(None)), + }; + controller.start(); + + (controller, tx, rx) + } + + pub fn start(&self) { + self.stop.store(false, Ordering::Relaxed); + let stop = self.stop.clone(); + let tx = self.tx.clone(); + let handle = thread::spawn(move || loop { + if stop.load(Ordering::Relaxed) { + break; + } + match ratatui::crossterm::event::poll(std::time::Duration::from_millis(100)) { + Ok(true) => match ratatui::crossterm::event::read() { + Ok(e) => match e { + ratatui::crossterm::event::Event::Key(key) => { + tx.send(AppEvent::Key(key)); + } + ratatui::crossterm::event::Event::Resize(w, h) => { + tx.send(AppEvent::Resize(w as usize, h as usize)); + } + _ => {} + }, + Err(e) => { + panic!("Failed to read event: {e}"); + } + }, + Ok(false) => { + continue; } - ratatui::crossterm::event::Event::Resize(w, h) => { - event_tx.send(AppEvent::Resize(w as usize, h as usize)); + Err(e) => { + panic!("Failed to poll event: {e}"); } - _ => {} - }, - Err(e) => { - panic!("Failed to read event: {e}"); } - } - }); + }); + *self.handle.lock().unwrap() = Some(handle); + } - (tx, rx) + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.lock().unwrap().take() { + handle.join().unwrap(); + } + } } // The event triggered by user's key input diff --git a/src/lib.rs b/src/lib.rs index d902adf..f6605b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,7 +157,7 @@ pub fn run() -> Result<()> { image_protocol, }); - let (tx, mut rx) = event::init(); + let (ec, tx, mut rx) = event::EventController::init(); let mut refresh_view_context = None; let mut terminal = None; @@ -190,6 +190,7 @@ pub fn run() -> Result<()> { initial_selection, ctx.clone(), tx.clone(), + &ec, refresh_view_context, ); From 67eddda18424cf8ebfdea38684d673443d0bf3aa Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 8 Mar 2026 23:34:17 +0900 Subject: [PATCH 04/13] Implement open_user_command_suspend --- src/app.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index ebb2197..0603b01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,9 @@ use crate::{ color::{ColorTheme, GraphColorSet}, config::{CoreConfig, CursorType, UiConfig, UserCommand, UserCommandType}, event::{AppEvent, EventController, Receiver, Sender, UserEvent, UserEventWithCount}, - external::{copy_to_clipboard, exec_user_command, ExternalCommandParameters}, + external::{ + copy_to_clipboard, exec_user_command, exec_user_command_suspend, ExternalCommandParameters, + }, git::{Commit, Head, Ref, Repository}, graph::{CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, @@ -529,7 +531,61 @@ impl App<'_> { } fn open_user_command_suspend(&mut self, user_command_number: usize) { - // todo + let commit_list_state = match self.view { + View::List(ref mut view) => view.as_list_state(), + View::Detail(ref mut view) => view.as_list_state(), + View::UserCommand(ref mut view) => view.as_list_state(), + _ => return, + }; + let selected = commit_list_state.selected_commit_hash().clone(); + let (commit, _) = self.repository.commit_detail(&selected); + let refs: Vec = self + .repository + .refs(&selected) + .into_iter() + .cloned() + .collect(); + match build_external_command_parameters( + &commit, + &refs, + user_command_number, + self.app_status.view_area, + &self.ctx, + ) { + Ok(params) => { + // fixme + ratatui::crossterm::terminal::disable_raw_mode().unwrap(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::terminal::LeaveAlternateScreen + ) + .unwrap(); + + self.ec.stop(); + + if let Err(err) = exec_user_command_suspend(params) { + self.tx.send(AppEvent::NotifyError(err)); + } + + ratatui::crossterm::terminal::enable_raw_mode().unwrap(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::terminal::EnterAlternateScreen + ) + .unwrap(); + + while ratatui::crossterm::event::poll(std::time::Duration::from_millis(0)) + .unwrap_or(false) + { + let _ = ratatui::crossterm::event::read(); + } + + self.ec.start(); + } + Err(err) => { + self.tx.send(AppEvent::NotifyError(err)); + } + } } fn close_user_command(&mut self) { From df35b3eefb92f96866793181156978c6d3a75380 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 8 Mar 2026 23:56:32 +0900 Subject: [PATCH 05/13] Extract functions --- src/app.rs | 27 ++------------------------- src/event.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0603b01..acab5a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -553,34 +553,11 @@ impl App<'_> { &self.ctx, ) { Ok(params) => { - // fixme - ratatui::crossterm::terminal::disable_raw_mode().unwrap(); - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::terminal::LeaveAlternateScreen - ) - .unwrap(); - - self.ec.stop(); - + self.ec.suspend(); if let Err(err) = exec_user_command_suspend(params) { self.tx.send(AppEvent::NotifyError(err)); } - - ratatui::crossterm::terminal::enable_raw_mode().unwrap(); - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::terminal::EnterAlternateScreen - ) - .unwrap(); - - while ratatui::crossterm::event::poll(std::time::Duration::from_millis(0)) - .unwrap_or(false) - { - let _ = ratatui::crossterm::event::read(); - } - - self.ec.start(); + self.ec.resume(); } Err(err) => { self.tx.send(AppEvent::NotifyError(err)); diff --git a/src/event.rs b/src/event.rs index 3b7fabe..059342c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -129,12 +129,41 @@ impl EventController { *self.handle.lock().unwrap() = Some(handle); } - pub fn stop(&self) { + pub fn resume(&self) { + ratatui::crossterm::terminal::enable_raw_mode().unwrap(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::terminal::EnterAlternateScreen + ) + .unwrap(); + + self.drain_crossterm_event(); + self.start(); + } + + pub fn suspend(&self) { + ratatui::crossterm::terminal::disable_raw_mode().unwrap(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::terminal::LeaveAlternateScreen + ) + .unwrap(); + + self.stop(); + } + + fn stop(&self) { self.stop.store(true, Ordering::Relaxed); if let Some(handle) = self.handle.lock().unwrap().take() { handle.join().unwrap(); } } + + fn drain_crossterm_event(&self) { + while let Ok(true) = ratatui::crossterm::event::poll(std::time::Duration::from_millis(0)) { + let _ = ratatui::crossterm::event::read(); + } + } } // The event triggered by user's key input From 577ba2ca8a1e79b39eb7ed76ae0eefe36ef53987 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Mon, 9 Mar 2026 12:50:14 +0900 Subject: [PATCH 06/13] Use DefaultTerminal --- src/app.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index acab5a4..f43bb37 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,12 @@ use std::rc::Rc; use ratatui::{ - backend::Backend, crossterm::event::{KeyCode, KeyEvent}, layout::{Constraint, Layout, Rect}, style::{Modifier, Style, Stylize}, text::Line, widgets::{Block, Borders, Clear, Padding, Paragraph}, - Frame, Terminal, + DefaultTerminal, Frame, }; use rustc_hash::FxHashMap; @@ -149,11 +148,11 @@ impl<'a> App<'a> { } impl App<'_> { - pub fn run( + pub fn run( &mut self, - terminal: &mut Terminal, + terminal: &mut DefaultTerminal, rx: Receiver, - ) -> Result { + ) -> Result { loop { terminal.draw(|f| self.render(f))?; match rx.recv() { From 9c0847073ff02ce2ad61e41f86d28f79e1eda236 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Mon, 9 Mar 2026 12:57:19 +0900 Subject: [PATCH 07/13] Fix resume and suspend --- src/event.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/event.rs b/src/event.rs index 059342c..873fd81 100644 --- a/src/event.rs +++ b/src/event.rs @@ -130,26 +130,26 @@ impl EventController { } pub fn resume(&self) { - ratatui::crossterm::terminal::enable_raw_mode().unwrap(); ratatui::crossterm::execute!( std::io::stdout(), ratatui::crossterm::terminal::EnterAlternateScreen ) .unwrap(); + ratatui::crossterm::terminal::enable_raw_mode().unwrap(); self.drain_crossterm_event(); self.start(); } pub fn suspend(&self) { + self.stop(); + ratatui::crossterm::terminal::disable_raw_mode().unwrap(); ratatui::crossterm::execute!( std::io::stdout(), ratatui::crossterm::terminal::LeaveAlternateScreen ) .unwrap(); - - self.stop(); } fn stop(&self) { From 51397da92778dfe99a1436c6f0812e03246c4ee3 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Mon, 9 Mar 2026 23:57:22 +0900 Subject: [PATCH 08/13] Call Terminal::clear --- src/app.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index f43bb37..ee19b52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -234,7 +234,7 @@ impl App<'_> { self.clear_detail(); } AppEvent::OpenUserCommand(n) => { - self.open_user_command(n); + self.open_user_command(n, Some(terminal)); } AppEvent::CloseUserCommand => { self.close_user_command(); @@ -437,7 +437,11 @@ impl App<'_> { } } - fn open_user_command(&mut self, user_command_number: usize) { + fn open_user_command( + &mut self, + user_command_number: usize, + terminal: Option<&mut DefaultTerminal>, + ) { match extract_user_command_by_number(user_command_number, &self.ctx).map(|c| &c.r#type) { Ok(UserCommandType::Inline) => { self.open_user_command_inline(user_command_number); @@ -452,6 +456,12 @@ impl App<'_> { self.tx.send(AppEvent::NotifyError(err)); } } + if let Some(t) = terminal { + if let Err(err) = t.clear() { + let msg = format!("Failed to clear terminal: {err:?}"); + self.tx.send(AppEvent::NotifyError(msg)); + } + } } fn open_user_command_inline(&mut self, user_command_number: usize) { @@ -658,7 +668,7 @@ impl App<'_> { user_command_context, .. } => { - self.open_user_command(user_command_context.n); + self.open_user_command(user_command_context.n, None); } RefreshViewContext::Refs { refs_context, .. } => { self.open_refs(); From df95fa64dc517e1ec89af013b769c8ede3479c90 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Tue, 10 Mar 2026 00:01:40 +0900 Subject: [PATCH 09/13] Refactor event handling --- src/app.rs | 50 +++++++++++++++++++++----------------------------- src/event.rs | 26 +++++++++++++++++++++++--- src/lib.rs | 6 ++---- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index ee19b52..4d04a69 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,7 @@ use rustc_hash::FxHashMap; use crate::{ color::{ColorTheme, GraphColorSet}, config::{CoreConfig, CursorType, UiConfig, UserCommand, UserCommandType}, - event::{AppEvent, EventController, Receiver, Sender, UserEvent, UserEventWithCount}, + event::{AppEvent, EventController, UserEvent, UserEventWithCount}, external::{ copy_to_clipboard, exec_user_command, exec_user_command_suspend, ExternalCommandParameters, }, @@ -48,7 +48,6 @@ pub enum Ret { } pub struct RefreshRequest { - pub rx: Receiver, pub context: RefreshViewContext, } @@ -75,7 +74,6 @@ pub struct App<'a> { view: View<'a>, app_status: AppStatus, ctx: Rc, - tx: Sender, ec: &'a EventController, } @@ -88,7 +86,6 @@ impl<'a> App<'a> { cell_width_type: CellWidthType, initial_selection: InitialSelection, ctx: Rc, - tx: Sender, ec: &'a EventController, refresh_view_context: Option, ) -> Self { @@ -128,14 +125,13 @@ impl<'a> App<'a> { Head::None => {} } } - let view = View::of_list(commit_list_state, ctx.clone(), tx.clone()); + let view = View::of_list(commit_list_state, ctx.clone(), ec.sender()); let mut app = Self { repository, view, app_status: AppStatus::default(), ctx, - tx, ec, }; @@ -148,14 +144,10 @@ impl<'a> App<'a> { } impl App<'_> { - pub fn run( - &mut self, - terminal: &mut DefaultTerminal, - rx: Receiver, - ) -> Result { + pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result { loop { terminal.draw(|f| self.render(f))?; - match rx.recv() { + match self.ec.recv() { AppEvent::Key(key) => { match self.app_status.status_line { StatusLine::None | StatusLine::Input(_, _, _) => { @@ -186,7 +178,7 @@ impl App<'_> { match user_event { Some(UserEvent::ForceQuit) => { - self.tx.send(AppEvent::Quit); + self.ec.send(AppEvent::Quit); } Some(ue) => { let event_with_count = @@ -270,7 +262,7 @@ impl App<'_> { self.copy_to_clipboard(name, value); } AppEvent::Refresh(context) => { - let request = RefreshRequest { rx, context }; + let request = RefreshRequest { context }; return Ok(Ret::Refresh(request)); } AppEvent::ClearStatusLine => { @@ -420,14 +412,14 @@ impl App<'_> { changes, refs, self.ctx.clone(), - self.tx.clone(), + self.ec.sender(), ); } fn close_detail(&mut self) { if let View::Detail(ref mut view) = self.view { let commit_list_state = view.take_list_state(); - self.view = View::of_list(commit_list_state, self.ctx.clone(), self.tx.clone()); + self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender()); } } @@ -453,13 +445,13 @@ impl App<'_> { self.open_user_command_suspend(user_command_number); } Err(err) => { - self.tx.send(AppEvent::NotifyError(err)); + self.ec.send(AppEvent::NotifyError(err)); } } if let Some(t) = terminal { if let Err(err) = t.clear() { let msg = format!("Failed to clear terminal: {err:?}"); - self.tx.send(AppEvent::NotifyError(msg)); + self.ec.send(AppEvent::NotifyError(msg)); } } } @@ -492,11 +484,11 @@ impl App<'_> { params, user_command_number, self.ctx.clone(), - self.tx.clone(), + self.ec.sender(), ); } Err(err) => { - self.tx.send(AppEvent::NotifyError(err)); + self.ec.send(AppEvent::NotifyError(err)); } }; } @@ -534,7 +526,7 @@ impl App<'_> { } } Err(err) => { - self.tx.send(AppEvent::NotifyError(err)); + self.ec.send(AppEvent::NotifyError(err)); } } } @@ -564,12 +556,12 @@ impl App<'_> { Ok(params) => { self.ec.suspend(); if let Err(err) = exec_user_command_suspend(params) { - self.tx.send(AppEvent::NotifyError(err)); + self.ec.send(AppEvent::NotifyError(err)); } self.ec.resume(); } Err(err) => { - self.tx.send(AppEvent::NotifyError(err)); + self.ec.send(AppEvent::NotifyError(err)); } } } @@ -577,7 +569,7 @@ impl App<'_> { fn close_user_command(&mut self) { if let View::UserCommand(ref mut view) = self.view { let commit_list_state = view.take_list_state(); - self.view = View::of_list(commit_list_state, self.ctx.clone(), self.tx.clone()); + self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender()); } } @@ -591,20 +583,20 @@ impl App<'_> { if let View::List(ref mut view) = self.view { let commit_list_state = view.take_list_state(); let refs = self.repository.all_refs().into_iter().cloned().collect(); - self.view = View::of_refs(commit_list_state, refs, self.ctx.clone(), self.tx.clone()); + self.view = View::of_refs(commit_list_state, refs, self.ctx.clone(), self.ec.sender()); } } fn close_refs(&mut self) { if let View::Refs(ref mut view) = self.view { let commit_list_state = view.take_list_state(); - self.view = View::of_list(commit_list_state, self.ctx.clone(), self.tx.clone()); + self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender()); } } fn open_help(&mut self) { let before_view = std::mem::take(&mut self.view); - self.view = View::of_help(before_view, self.ctx.clone(), self.tx.clone()); + self.view = View::of_help(before_view, self.ctx.clone(), self.ec.sender()); } fn close_help(&mut self) { @@ -712,10 +704,10 @@ impl App<'_> { match copy_to_clipboard(value, &self.ctx.core_config.external.clipboard) { Ok(_) => { let msg = format!("Copied {name} to clipboard successfully"); - self.tx.send(AppEvent::NotifySuccess(msg)); + self.ec.send(AppEvent::NotifySuccess(msg)); } Err(msg) => { - self.tx.send(AppEvent::NotifyError(msg)); + self.ec.send(AppEvent::NotifyError(msg)); } } } diff --git a/src/event.rs b/src/event.rs index 873fd81..ea31af8 100644 --- a/src/event.rs +++ b/src/event.rs @@ -67,32 +67,40 @@ pub struct Receiver { } impl Receiver { - pub fn recv(&self) -> AppEvent { + fn recv(&self) -> AppEvent { self.rx.recv().unwrap() } } +impl Debug for Receiver { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "Receiver") + } +} + #[derive(Debug)] pub struct EventController { tx: Sender, + rx: Receiver, stop: Arc, handle: Arc>>>, } impl EventController { - pub fn init() -> (Self, Sender, Receiver) { + pub fn init() -> Self { let (tx, rx) = mpsc::channel(); let tx = Sender { tx }; let rx = Receiver { rx }; let controller = EventController { tx: tx.clone(), + rx, stop: Arc::new(AtomicBool::new(false)), handle: Arc::new(Mutex::new(None)), }; controller.start(); - (controller, tx, rx) + controller } pub fn start(&self) { @@ -164,6 +172,18 @@ impl EventController { let _ = ratatui::crossterm::event::read(); } } + + pub fn sender(&self) -> Sender { + self.tx.clone() + } + + pub fn send(&self, event: AppEvent) { + self.tx.send(event); + } + + pub fn recv(&self) -> AppEvent { + self.rx.recv() + } } // The event triggered by user's key input diff --git a/src/lib.rs b/src/lib.rs index f6605b7..5daf7d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,7 +157,7 @@ pub fn run() -> Result<()> { image_protocol, }); - let (ec, tx, mut rx) = event::EventController::init(); + let ec = event::EventController::init(); let mut refresh_view_context = None; let mut terminal = None; @@ -189,17 +189,15 @@ pub fn run() -> Result<()> { cell_width_type, initial_selection, ctx.clone(), - tx.clone(), &ec, refresh_view_context, ); - match app.run(terminal.as_mut().unwrap(), rx) { + match app.run(terminal.as_mut().unwrap()) { Ok(Ret::Quit) => { break Ok(()); } Ok(Ret::Refresh(request)) => { - rx = request.rx; refresh_view_context = Some(request.context); continue; } From bf87bbc264b638e8ba134cec9f4abe19d70fa707 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Tue, 10 Mar 2026 21:05:47 +0900 Subject: [PATCH 10/13] Call refresh after execute command --- src/app.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app.rs b/src/app.rs index 4d04a69..b28b05f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -559,6 +559,13 @@ impl App<'_> { self.ec.send(AppEvent::NotifyError(err)); } self.ec.resume(); + + if extract_user_command_by_number(user_command_number, &self.ctx) + .map(|c| c.refresh) + .unwrap_or_default() + { + self.view.refresh(); + } } Err(err) => { self.ec.send(AppEvent::NotifyError(err)); From 8e42b632feeb158c63aab14609decb1c7263d097 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Tue, 10 Mar 2026 21:12:54 +0900 Subject: [PATCH 11/13] Extract functions --- src/app.rs | 48 +++++++++++++++--------------------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/src/app.rs b/src/app.rs index b28b05f..b9f9fea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ use crate::{ external::{ copy_to_clipboard, exec_user_command, exec_user_command_suspend, ExternalCommandParameters, }, - git::{Commit, Head, Ref, Repository}, + git::{Commit, FileChange, Head, Ref, Repository}, graph::{CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, @@ -398,14 +398,7 @@ impl App<'_> { View::UserCommand(ref mut view) => view.take_list_state(), _ => return, }; - let selected = commit_list_state.selected_commit_hash().clone(); - let (commit, changes) = self.repository.commit_detail(&selected); - let refs = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let (commit, changes, refs) = selected_commit_details(self.repository, &commit_list_state); self.view = View::of_detail( commit_list_state, commit, @@ -463,14 +456,7 @@ impl App<'_> { View::UserCommand(ref mut view) => view.take_list_state(), _ => return, }; - let selected = commit_list_state.selected_commit_hash().clone(); - let (commit, _) = self.repository.commit_detail(&selected); - let refs: Vec = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let (commit, _, refs) = selected_commit_details(self.repository, &commit_list_state); match build_external_command_parameters( &commit, &refs, @@ -500,14 +486,7 @@ impl App<'_> { View::UserCommand(ref mut view) => view.as_list_state(), _ => return, }; - let selected = commit_list_state.selected_commit_hash().clone(); - let (commit, _) = self.repository.commit_detail(&selected); - let refs: Vec = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let (commit, _, refs) = selected_commit_details(self.repository, commit_list_state); let result = build_external_command_parameters( &commit, &refs, @@ -538,14 +517,7 @@ impl App<'_> { View::UserCommand(ref mut view) => view.as_list_state(), _ => return, }; - let selected = commit_list_state.selected_commit_hash().clone(); - let (commit, _) = self.repository.commit_detail(&selected); - let refs: Vec = self - .repository - .refs(&selected) - .into_iter() - .cloned() - .collect(); + let (commit, _, refs) = selected_commit_details(self.repository, commit_list_state); match build_external_command_parameters( &commit, &refs, @@ -720,6 +692,16 @@ impl App<'_> { } } +fn selected_commit_details( + repository: &Repository, + commit_list_state: &CommitListState, +) -> (Commit, Vec, Vec) { + let selected = commit_list_state.selected_commit_hash().clone(); + let (commit, changes) = repository.commit_detail(&selected); + let refs: Vec = repository.refs(&selected).into_iter().cloned().collect(); + (commit, changes, refs) +} + fn process_numeric_prefix( numeric_prefix: &str, user_event: UserEvent, From db154bb862a76ebc831f5fa77a81b60d28088c95 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Tue, 10 Mar 2026 21:17:16 +0900 Subject: [PATCH 12/13] Extract functions --- src/app.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9f9fea..11bf8c7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -497,10 +497,7 @@ impl App<'_> { .and_then(exec_user_command); match result { Ok(_) => { - if extract_user_command_by_number(user_command_number, &self.ctx) - .map(|c| c.refresh) - .unwrap_or_default() - { + if extract_user_command_refresh_by_number(user_command_number, &self.ctx) { self.view.refresh(); } } @@ -532,10 +529,7 @@ impl App<'_> { } self.ec.resume(); - if extract_user_command_by_number(user_command_number, &self.ctx) - .map(|c| c.refresh) - .unwrap_or_default() - { + if extract_user_command_refresh_by_number(user_command_number, &self.ctx) { self.view.refresh(); } } @@ -735,6 +729,12 @@ fn extract_user_command_by_number( }) } +fn extract_user_command_refresh_by_number(user_command_number: usize, ctx: &AppContext) -> bool { + extract_user_command_by_number(user_command_number, ctx) + .map(|c| c.refresh) + .unwrap_or_default() +} + fn build_external_command_parameters( commit: &Commit, refs: &[Ref], From 8ec1b7cfae2f23a58b012fcbfedccbf7130b96b3 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Tue, 10 Mar 2026 21:33:02 +0900 Subject: [PATCH 13/13] Update docs --- README.md | 2 +- config.schema.json | 5 +++-- docs/src/configurations/config-file-format.md | 4 +++- docs/src/features/user-command.md | 12 +++++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 024085d..37b6208 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ For detailed information about the config file format, see [Config File Format]( ### User command The User command feature allows you to execute custom external commands. -You can display the output of commands like `git diff` in a dedicated view, or execute commands like branch deletion in the background. +You can display the output of commands like `git diff` in a dedicated view, execute commands like branch deletion in the background, or run interactive commands like `vim` by suspending the application. For details on how to set commands, see [User Command](https://lusingander.github.io/serie/features/user-command.html). diff --git a/config.schema.json b/config.schema.json index 835ea99..49e8fe3 100644 --- a/config.schema.json +++ b/config.schema.json @@ -103,7 +103,8 @@ "description": "The type of user command.", "enum": [ "inline", - "silent" + "silent", + "suspend" ], "default": "inline" }, @@ -116,7 +117,7 @@ }, "refresh": { "type": "boolean", - "description": "Whether to reload the repository and refresh the display after executing the command. Only available for 'silent' commands.", + "description": "Whether to reload the repository and refresh the display after executing the command. Available for 'silent' and 'suspend' commands.", "default": false } }, diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 784f60a..79828a6 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -195,12 +195,14 @@ For details about user command, see the separate [User command](../features/user - possible values: - `inline`: Display the output of the command in the user command view. - `silent`: Execute the command in the background without opening a view. + - `suspend`: Execute the command by suspending the application. This is useful for interactive commands. - `commands`: `array of strings` - The command and its arguments. - - `refresh`: `boolean` - Whether to reload the repository and refresh the display after executing the command. Only available for `silent` commands. + - `refresh`: `boolean` - Whether to reload the repository and refresh the display after executing the command. Available for `silent` and `suspend` commands. - default: `false` - examples: - `commands_1 = { name = "git diff", commands = ["git", "--no-pager", "diff", "--color=always", "{{first_parent_hash}}", "{{target_hash}}"]}` - `commands_2 = { name = "delete branch", type = "silent", commands = ["git", "branch", "-D", "{{branches}}"], refresh = true }` + - `commands_3 = { name = "amend commit", type = "suspend", commands = ["git", "commit", "--amend"], refresh = true }` ### `core.user_command.tab_width` diff --git a/docs/src/features/user-command.md b/docs/src/features/user-command.md index 36857cc..3cc2c3f 100644 --- a/docs/src/features/user-command.md +++ b/docs/src/features/user-command.md @@ -1,7 +1,7 @@ # User Command The User command feature allows you to execute custom external commands. -There are two types of user commands: `inline` and `silent`. +There are three types of user commands: `inline`, `silent` and `suspend`. - `inline` (default) - Displays the output (stdout) of the command in a dedicated view within the TUI. @@ -9,6 +9,9 @@ There are two types of user commands: `inline` and `silent`. - `silent` - Executes the command in the background without opening a view. - This is useful for operations that don't require checking output, such as deleting branches or adding tags. +- `suspend` + - Executes the command by suspending the application. + - This is useful for interactive commands that require terminal control, such as `git commit --amend` (which opens an editor) or `git diff` with a pager. To define a user command, you need to configure the following two settings: - Keybinding definition. Specify the key to execute each user command. @@ -16,13 +19,14 @@ To define a user command, you need to configure the following two settings: - Command definition. Specify the actual command you want to execute. - Config: `core.user_command.commands_{n}` -**Configuration example:** +Example configuration in `config.toml`: ```toml [keybind] user_command_1 = ["d"] user_command_2 = ["shift-d"] user_command_3 = ["b"] +user_command_4 = ["a"] [core.user_command] # Inline command (default) @@ -31,11 +35,13 @@ commands_1 = { "name" = "git diff", commands = ["git", "--no-pager", "diff", "-- commands_2 = { "name" = "xxx", commands = ["xxx", "{{first_parent_hash}}", "{{target_hash}}", "--width", "{{area_width}}", "--height", "{{area_height}}"] } # Silent command with refresh commands_3 = { "name" = "delete branch", type = "silent", commands = ["git", "branch", "-D", "{{branches}}"], refresh = true } +# Suspend command with refresh +commands_4 = { "name" = "amend commit", type = "suspend", commands = ["git", "commit", "--amend"], refresh = true } ``` ## Refresh -For `silent` commands, you can set `refresh = true` to automatically reload the repository and refresh the display (e.g., commit list) after the command is executed. +For `silent` and `suspend` commands, you can set `refresh = true` to automatically reload the repository and refresh the display (e.g., commit list) after the command is executed. This is useful when the command modifies the repository state. Note that `refresh = true` cannot be used with `inline` commands.