From c30242c3651128b2ef033a549e0e257c4e9e77c6 Mon Sep 17 00:00:00 2001 From: SAKAMOTO_Yasuaki Date: Fri, 22 Aug 2025 02:21:35 +0900 Subject: [PATCH 1/2] feat: add ai-powered commit message helpers --- commit_helpers.ron.example | 68 +++++++++ src/commit_helpers.rs | 156 +++++++++++++++++++ src/keys/key_list.rs | 2 + src/main.rs | 3 + src/popups/commit.rs | 297 ++++++++++++++++++++++++++++++++++++- src/strings.rs | 12 ++ 6 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 commit_helpers.ron.example create mode 100644 src/commit_helpers.rs diff --git a/commit_helpers.ron.example b/commit_helpers.ron.example new file mode 100644 index 0000000000..d6b4406e25 --- /dev/null +++ b/commit_helpers.ron.example @@ -0,0 +1,68 @@ +// Example configuration for GitUI commit helpers +// Copy this file to your GitUI config directory as "commit_helpers.ron" +// +// Config directory locations: +// - Linux: ~/.config/gitui/ +// - macOS: ~/Library/Application Support/gitui/ +// - Windows: %APPDATA%/gitui/ +// +// Template variables available in commands: +// - {staged_diff} - Output of 'git diff --staged --no-color' +// - {staged_files} - List of staged files from 'git diff --staged --name-only' +// - {branch_name} - Current branch name +// +// Helper navigation: +// - Ctrl+G: Open helper selection (if multiple helpers configured) +// - Arrow keys: Navigate between helpers in selection mode +// - Enter: Execute selected helper +// - Hotkeys: Press configured hotkey to run helper directly +// - ESC: Cancel selection or running helper + +CommitHelpers( + helpers: [ + // Claude AI helper example (using template variables) + CommitHelper( + name: "Claude AI", + command: "echo '{staged_diff}' | claude -p 'Based on the following git diff of staged changes, generate a concise, conventional commit message. Follow this format:\n\n: \n\nWhere is one of: feat, fix, docs, style, refactor, test, chore\nThe should be lowercase and concise (50 chars or less).\n\nFor multiple types of changes, use the most significant one.\nOutput ONLY the commit message, no explanation or quotes.'", + description: Some("Generate conventional commit messages using Claude AI"), + hotkey: Some('c'), + timeout_secs: Some(30), + ), + + // OpenAI ChatGPT helper example (using template variables) + CommitHelper( + name: "ChatGPT", + command: "echo '{staged_diff}' | chatgpt 'Generate a concise conventional commit message for this diff. Format: : . Types: feat, fix, docs, style, refactor, test, chore. Max 50 chars.'", + description: Some("Generate commit messages using ChatGPT"), + hotkey: Some('g'), + timeout_secs: Some(25), + ), + + // Local AI helper example (using template variables) + CommitHelper( + name: "Local AI", + command: "echo '{staged_diff}' | ollama run codellama 'Generate a conventional commit message for this git diff. Use format: type: description. Keep under 50 characters.'", + description: Some("Generate commit messages using local Ollama model"), + hotkey: Some('l'), + timeout_secs: Some(45), + ), + + // Branch-specific helper example + CommitHelper( + name: "Branch Fix", + command: "echo 'fix({branch_name}): address issues in {staged_files}'", + description: Some("Generate branch-specific fix message"), + hotkey: Some('b'), + timeout_secs: Some(5), + ), + + // Simple template-based helper + CommitHelper( + name: "Quick Fix", + command: "echo 'fix: address code issues'", + description: Some("Quick fix commit message"), + hotkey: Some('f'), + timeout_secs: Some(5), + ), + ] +) \ No newline at end of file diff --git a/src/commit_helpers.rs b/src/commit_helpers.rs new file mode 100644 index 0000000000..81cb803b74 --- /dev/null +++ b/src/commit_helpers.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use ron::de::from_reader; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + path::PathBuf, + process::{Command, Stdio}, + sync::Arc, +}; + +use crate::args::get_app_config_path; + +pub type SharedCommitHelpers = Arc; + +const COMMIT_HELPERS_FILENAME: &str = "commit_helpers.ron"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitHelper { + /// Display name for the helper + pub name: String, + /// Command to execute (will be run through shell) + pub command: String, + /// Optional description of what this helper does + pub description: Option, + /// Optional hotkey for quick access + pub hotkey: Option, + /// Optional timeout in seconds (defaults to 30) + pub timeout_secs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitHelpers { + pub helpers: Vec, +} + +impl Default for CommitHelpers { + fn default() -> Self { + Self { + helpers: Vec::new(), + } + } +} + +impl CommitHelpers { + fn get_config_file() -> Result { + let app_home = get_app_config_path()?; + let config_file = app_home.join(COMMIT_HELPERS_FILENAME); + Ok(config_file) + } + + pub fn init() -> Result { + let config_file = Self::get_config_file()?; + + if config_file.exists() { + let file = File::open(&config_file).map_err(|e| { + anyhow::anyhow!("Failed to open commit_helpers.ron: {}. Check file permissions.", e) + })?; + + match from_reader::<_, CommitHelpers>(file) { + Ok(config) => { + log::info!("Loaded {} commit helpers from config", config.helpers.len()); + Ok(config) + }, + Err(e) => { + log::error!("Failed to parse commit_helpers.ron: {}", e); + anyhow::bail!( + "Invalid RON syntax in commit_helpers.ron: {}. \ + Check the example file or remove the config to reset.", e + ) + } + } + } else { + log::info!("No commit_helpers.ron found, using empty config. \ + See commit_helpers.ron.example for configuration options."); + Ok(Self::default()) + } + } + + pub fn get_helpers(&self) -> &[CommitHelper] { + &self.helpers + } + + pub fn find_by_hotkey(&self, hotkey: char) -> Option { + self.helpers.iter().position(|h| h.hotkey == Some(hotkey)) + } + + pub fn execute_helper(&self, helper_index: usize) -> Result { + if helper_index >= self.helpers.len() { + anyhow::bail!("Invalid helper index"); + } + + let helper = &self.helpers[helper_index]; + + // Process template variables in command + let processed_command = self.process_template_variables(&helper.command)?; + + // Execute command through shell to support pipes and redirects + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", &processed_command]) + .stdin(Stdio::null()) + .output()? + } else { + Command::new("sh") + .args(["-c", &processed_command]) + .stdin(Stdio::null()) + .output()? + }; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Command failed: {}", error); + } + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if result.is_empty() { + anyhow::bail!("Command returned empty output"); + } + + Ok(result) + } + + fn process_template_variables(&self, command: &str) -> Result { + let mut processed = command.to_string(); + + // {staged_diff} - staged git diff + if processed.contains("{staged_diff}") { + let diff_output = Command::new("git") + .args(["diff", "--staged", "--no-color"]) + .output()?; + let diff = String::from_utf8_lossy(&diff_output.stdout); + processed = processed.replace("{staged_diff}", &diff); + } + + // {staged_files} - list of staged files + if processed.contains("{staged_files}") { + let files_output = Command::new("git") + .args(["diff", "--staged", "--name-only"]) + .output()?; + let files = String::from_utf8_lossy(&files_output.stdout); + processed = processed.replace("{staged_files}", files.trim()); + } + + // {branch_name} - current branch name + if processed.contains("{branch_name}") { + let branch_output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output()?; + let branch = String::from_utf8_lossy(&branch_output.stdout); + processed = processed.replace("{branch_name}", branch.trim()); + } + + Ok(processed) + } +} \ No newline at end of file diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 0f2909a2fe..164041271b 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -128,6 +128,7 @@ pub struct KeysList { pub commit_history_next: GituiKeyEvent, pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, + pub commit_helper: GituiKeyEvent, } #[rustfmt::skip] @@ -225,6 +226,7 @@ impl Default for KeysList { commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + commit_helper: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), } } } diff --git a/src/main.rs b/src/main.rs index 50d7a98b93..77232ef9a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,7 @@ mod args; mod bug_report; mod clipboard; mod cmdbar; +mod commit_helpers; mod components; mod input; mod keys; @@ -271,6 +272,8 @@ fn run_app( if matches!(event, QueueEvent::SpinnerUpdate) { spinner.update(); spinner.draw(terminal)?; + // Also update app for commit helper animations + app.update()?; continue; } diff --git a/src/popups/commit.rs b/src/popups/commit.rs index 008fc6f8a7..e90014a1ac 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -4,13 +4,14 @@ use crate::components::{ }; use crate::{ app::Environment, + commit_helpers::{CommitHelpers, SharedCommitHelpers}, keys::{key_match, SharedKeyConfig}, options::SharedOptions, queue::{InternalEvent, NeedsUpdate, Queue}, strings, try_or_popup, ui::style::SharedTheme, }; -use anyhow::{bail, Ok, Result}; +use anyhow::{bail, Result}; use asyncgit::sync::commit::commit_message_prettify; use asyncgit::{ cached, @@ -34,6 +35,9 @@ use std::{ io::{Read, Write}, path::PathBuf, str::FromStr, + sync::mpsc::{self, Receiver}, + thread, + time::Instant, }; use super::ExternalEditorPopup; @@ -51,6 +55,21 @@ enum Mode { Reword(CommitId), } +#[derive(Clone)] +enum HelperState { + Idle, + Selection { + selected_index: usize, + }, + Running { + helper_name: String, + frame_index: usize, + start_time: std::time::Instant, + }, + Success(String), // generated message + Error(String), // error message +} + pub struct CommitPopup { repo: RepoPathRef, input: TextInputComponent, @@ -63,10 +82,18 @@ pub struct CommitPopup { commit_msg_history_idx: usize, options: SharedOptions, verify: bool, + commit_helpers: SharedCommitHelpers, + helper_state: HelperState, + helper_receiver: Option>>, } const FIRST_LINE_LIMIT: usize = 50; +// Spinner animation frames +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS: u64 = 80; +const HELPER_TIMEOUT_SECS: u64 = 30; + impl CommitPopup { /// pub fn new(env: &Environment) -> Self { @@ -89,12 +116,75 @@ impl CommitPopup { commit_msg_history_idx: 0, options: env.options.clone(), verify: true, + commit_helpers: std::sync::Arc::new( + CommitHelpers::init().unwrap_or_default(), + ), + helper_state: HelperState::Idle, + helper_receiver: None, } } /// - pub fn update(&mut self) { + pub fn update(&mut self) -> bool { self.git_branch_name.lookup().ok(); + self.check_helper_result(); + self.update_helper_animation() + } + + fn check_helper_result(&mut self) { + if let Some(receiver) = &self.helper_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(generated_msg) => { + let current_msg = self.input.get_text(); + let new_msg = if current_msg.is_empty() { + generated_msg + } else { + format!("{}\n\n{}", current_msg, generated_msg) + }; + self.input.set_text(new_msg); + self.helper_state = HelperState::Success("Generated successfully".to_string()); + } + Err(error_msg) => { + self.helper_state = HelperState::Error(error_msg); + } + } + self.helper_receiver = None; + } + } + } + + pub fn clear_helper_message(&mut self) { + if matches!(self.helper_state, HelperState::Success(_) | HelperState::Error(_) | HelperState::Selection { .. }) { + self.helper_state = HelperState::Idle; + } + } + + pub fn cancel_helper(&mut self) { + if matches!(self.helper_state, HelperState::Running { .. }) { + self.helper_state = HelperState::Error("Cancelled by user".to_string()); + self.helper_receiver = None; + } + } + + pub fn update_helper_animation(&mut self) -> bool { + if let HelperState::Running { frame_index, start_time, helper_name: _ } = &mut self.helper_state { + let elapsed = start_time.elapsed(); + + // Check timeout + if elapsed.as_secs() > HELPER_TIMEOUT_SECS { + self.helper_state = HelperState::Error("Timeout: Helper took too long".to_string()); + return true; + } + + // Update frame + let new_frame = (elapsed.as_millis() / SPINNER_INTERVAL_MS as u128) as usize % SPINNER_FRAMES.len(); + if *frame_index != new_frame { + *frame_index = new_frame; + return true; // Animation frame changed, needs redraw + } + } + false // No update needed } fn draw_branch_name(&self, f: &mut Frame) { @@ -144,6 +234,56 @@ impl CommitPopup { } } + fn draw_helper_status(&self, f: &mut Frame) { + use ratatui::widgets::{Paragraph, Wrap}; + use ratatui::style::Style; + + let (msg, style) = match &self.helper_state { + HelperState::Idle => return, + HelperState::Selection { selected_index } => { + let helpers = self.commit_helpers.get_helpers(); + if let Some(helper) = helpers.get(*selected_index) { + let hotkey_hint = helper.hotkey + .map(|h| format!(" [{}]", h)) + .unwrap_or_default(); + (format!("Select helper: {} ({}/{}){}. [↑↓] to navigate, [Enter] to run, [ESC] to cancel", + helper.name, selected_index + 1, helpers.len(), hotkey_hint), + Style::default().fg(ratatui::style::Color::Cyan)) + } else { + (String::from("No helpers available"), self.theme.text_danger()) + } + } + HelperState::Running { helper_name, frame_index, start_time } => { + let spinner = SPINNER_FRAMES[*frame_index]; + let elapsed = start_time.elapsed().as_secs(); + (format!("{} Generating with {}... ({}s) [ESC to cancel]", spinner, helper_name, elapsed), + Style::default().fg(ratatui::style::Color::Yellow)) + } + HelperState::Success(msg) => { + (format!("✅ {}", msg), + Style::default().fg(ratatui::style::Color::Green)) + } + HelperState::Error(err) => { + (format!("❌ {}", err), self.theme.text_danger()) + } + }; + + let msg_length: u16 = msg.chars().count().try_into().unwrap_or(0); + let paragraph = Paragraph::new(msg) + .style(style) + .wrap(Wrap { trim: true }); + + let rect = { + let mut rect = self.input.get_area(); + rect.y = rect.y.saturating_add(rect.height); + rect.height = 1; + rect.width = msg_length.min(rect.width); + rect + }; + + f.render_widget(paragraph, rect); + } + const fn item_status_char( item_type: StatusItemType, ) -> &'static str { @@ -347,6 +487,76 @@ impl CommitPopup { self.verify = !self.verify; } + fn run_commit_helper(&mut self) -> Result<()> { + self.open_helper_selection() + } + + fn open_helper_selection(&mut self) -> Result<()> { + // Check if already running + if matches!(self.helper_state, HelperState::Running { .. }) { + return Ok(()); + } + + let helpers = self.commit_helpers.get_helpers(); + if helpers.is_empty() { + let config_path = if cfg!(target_os = "macos") { + "~/Library/Application Support/gitui/commit_helpers.ron" + } else if cfg!(target_os = "windows") { + "%APPDATA%/gitui/commit_helpers.ron" + } else { + "~/.config/gitui/commit_helpers.ron" + }; + self.helper_state = HelperState::Error( + format!("No commit helpers configured. Create {} (see .example file)", config_path) + ); + return Ok(()); + } + + if helpers.len() == 1 { + // Only one helper, run it directly + self.execute_helper_by_index(0) + } else { + // Multiple helpers, show selection UI + self.helper_state = HelperState::Selection { selected_index: 0 }; + Ok(()) + } + } + + fn execute_helper_by_index(&mut self, helper_index: usize) -> Result<()> { + let helpers = self.commit_helpers.get_helpers(); + if helper_index >= helpers.len() { + self.helper_state = HelperState::Error("Invalid helper index".to_string()); + return Ok(()); + } + + let helper = helpers[helper_index].clone(); + + // Set running state with animation + self.helper_state = HelperState::Running { + helper_name: helper.name.clone(), + frame_index: 0, + start_time: Instant::now(), + }; + + // Create channel for communication + let (tx, rx) = mpsc::channel(); + self.helper_receiver = Some(rx); + + // Clone helpers for thread + let commit_helpers = self.commit_helpers.clone(); + + // Execute helper in background thread + thread::spawn(move || { + let result = commit_helpers.execute_helper(helper_index) + .map_err(|e| format!("Failed: {}", e)); + + // Send result back (ignore if receiver is dropped) + let _ = tx.send(result); + }); + + Ok(()) + } + pub fn open(&mut self, reword: Option) -> Result<()> { //only clear text if it was not a normal commit dlg before, so to preserve old commit msg that was edited if !matches!(self.mode, Mode::Normal) { @@ -485,6 +695,7 @@ impl DrawableComponent for CommitPopup { self.input.draw(f, rect)?; self.draw_branch_name(f); self.draw_warnings(f); + self.draw_helper_status(f); } Ok(()) @@ -548,6 +759,14 @@ impl Component for CommitPopup { true, true, )); + + if !self.commit_helpers.get_helpers().is_empty() { + out.push(CommandInfo::new( + strings::commands::commit_helper(&self.key_config), + true, + true, + )); + } } visibility_blocking(self) @@ -555,6 +774,70 @@ impl Component for CommitPopup { fn event(&mut self, ev: &Event) -> Result { if self.is_visible() { + // Handle helper selection navigation + if let Event::Key(key) = ev { + // Handle helper selection state + if let HelperState::Selection { selected_index } = &self.helper_state { + match key.code { + crossterm::event::KeyCode::Esc => { + self.helper_state = HelperState::Idle; + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Up => { + let helpers_len = self.commit_helpers.get_helpers().len(); + if helpers_len > 0 { + let new_index = if *selected_index == 0 { + helpers_len - 1 + } else { + *selected_index - 1 + }; + self.helper_state = HelperState::Selection { selected_index: new_index }; + } + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Down => { + let helpers_len = self.commit_helpers.get_helpers().len(); + if helpers_len > 0 { + let new_index = (*selected_index + 1) % helpers_len; + self.helper_state = HelperState::Selection { selected_index: new_index }; + } + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Enter => { + let selected = *selected_index; + try_or_popup!( + self, + "helper execution error:", + self.execute_helper_by_index(selected) + ); + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Char(c) => { + // Check for hotkey match + if let Some(index) = self.commit_helpers.find_by_hotkey(c) { + try_or_popup!( + self, + "helper execution error:", + self.execute_helper_by_index(index) + ); + return Ok(EventState::Consumed); + } + } + _ => {} + } + } + + // Handle ESC to cancel running helper + if key.code == crossterm::event::KeyCode::Esc + && matches!(self.helper_state, HelperState::Running { .. }) { + self.cancel_helper(); + return Ok(EventState::Consumed); + } + + // Clear success/error messages on key press + self.clear_helper_message(); + } + if let Event::Key(e) = ev { let input_consumed = if key_match(e, self.key_config.keys.commit) @@ -608,6 +891,16 @@ impl Component for CommitPopup { ) { self.signoff_commit(); true + } else if key_match( + e, + self.key_config.keys.commit_helper, + ) { + try_or_popup!( + self, + "commit helper error:", + self.run_commit_helper() + ); + true } else { false }; diff --git a/src/strings.rs b/src/strings.rs index c4cff10f70..f49eaf8ff3 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1175,6 +1175,18 @@ pub mod commands { CMD_GROUP_COMMIT_POPUP, ) } + pub fn commit_helper( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Generate [{}]", + key_config.get_hint(key_config.keys.commit_helper), + ), + "generate commit message using helper", + CMD_GROUP_COMMIT_POPUP, + ) + } pub fn edit_item(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( From 4bb2369802d644c1f34c4a61ea41a6bfdff02cbf Mon Sep 17 00:00:00 2001 From: SAKAMOTO_Yasuaki Date: Fri, 22 Aug 2025 16:31:35 +0900 Subject: [PATCH 2/2] feat: add ai-powered commit message helpers --- CHANGELOG.md | 1 + src/commit_helpers.rs | 408 ++++++++++++++++++++++++++++-------------- src/popups/commit.rs | 188 ++++++++++++------- 3 files changed, 400 insertions(+), 197 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e60180cdc5..f073f88207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * execute git-hooks directly if possible (on *nix) else use sh instead of bash (without reading SHELL variable) [[@Joshix](https://github.com/Joshix-1)] ([#2483](https://github.com/extrawurst/gitui/pull/2483)) ### Added +* Configurable commit helper system with RON-based configuration, supporting multiple helpers with selection UI, hotkeys, and template variables * Files and status tab support pageUp and pageDown [[@fatpandac](https://github.com/fatpandac)] ([#1951](https://github.com/extrawurst/gitui/issues/1951)) * support loading custom syntax highlighting themes from a file [[@acuteenvy](https://github.com/acuteenvy)] ([#2565](https://github.com/gitui-org/gitui/pull/2565)) * Select syntax highlighting theme out of the defaults from syntect [[@vasilismanol](https://github.com/vasilismanol)] ([#1931](https://github.com/extrawurst/gitui/issues/1931)) diff --git a/src/commit_helpers.rs b/src/commit_helpers.rs index 81cb803b74..9ce03fbc22 100644 --- a/src/commit_helpers.rs +++ b/src/commit_helpers.rs @@ -2,10 +2,10 @@ use anyhow::Result; use ron::de::from_reader; use serde::{Deserialize, Serialize}; use std::{ - fs::File, - path::PathBuf, - process::{Command, Stdio}, - sync::Arc, + fs::File, + path::PathBuf, + process::{Command, Stdio}, + sync::Arc, }; use crate::args::get_app_config_path; @@ -16,141 +16,283 @@ const COMMIT_HELPERS_FILENAME: &str = "commit_helpers.ron"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitHelper { - /// Display name for the helper - pub name: String, - /// Command to execute (will be run through shell) - pub command: String, - /// Optional description of what this helper does - pub description: Option, - /// Optional hotkey for quick access - pub hotkey: Option, - /// Optional timeout in seconds (defaults to 30) - pub timeout_secs: Option, + /// Display name for the helper + pub name: String, + /// Command to execute (will be run through shell) + pub command: String, + /// Optional description of what this helper does + pub description: Option, + /// Optional hotkey for quick access + pub hotkey: Option, + /// Optional timeout in seconds (defaults to 30) + pub timeout_secs: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CommitHelpers { - pub helpers: Vec, -} - -impl Default for CommitHelpers { - fn default() -> Self { - Self { - helpers: Vec::new(), - } - } + pub helpers: Vec, } impl CommitHelpers { - fn get_config_file() -> Result { - let app_home = get_app_config_path()?; - let config_file = app_home.join(COMMIT_HELPERS_FILENAME); - Ok(config_file) - } - - pub fn init() -> Result { - let config_file = Self::get_config_file()?; - - if config_file.exists() { - let file = File::open(&config_file).map_err(|e| { - anyhow::anyhow!("Failed to open commit_helpers.ron: {}. Check file permissions.", e) + fn get_config_file() -> Result { + let app_home = get_app_config_path()?; + let config_file = app_home.join(COMMIT_HELPERS_FILENAME); + Ok(config_file) + } + + pub fn init() -> Result { + let config_file = Self::get_config_file()?; + + if config_file.exists() { + let file = File::open(&config_file).map_err(|e| { + anyhow::anyhow!("Failed to open commit_helpers.ron: {e}. Check file permissions.") })?; - - match from_reader::<_, CommitHelpers>(file) { - Ok(config) => { - log::info!("Loaded {} commit helpers from config", config.helpers.len()); - Ok(config) - }, - Err(e) => { - log::error!("Failed to parse commit_helpers.ron: {}", e); - anyhow::bail!( - "Invalid RON syntax in commit_helpers.ron: {}. \ - Check the example file or remove the config to reset.", e + + match from_reader::<_, Self>(file) { + Ok(config) => { + log::info!( + "Loaded {} commit helpers from config", + config.helpers.len() + ); + Ok(config) + } + Err(e) => { + log::error!( + "Failed to parse commit_helpers.ron: {e}" + ); + anyhow::bail!( + "Invalid RON syntax in commit_helpers.ron: {e}. \ + Check the example file or remove the config to reset." ) - } - } - } else { - log::info!("No commit_helpers.ron found, using empty config. \ + } + } + } else { + log::info!("No commit_helpers.ron found, using empty config. \ See commit_helpers.ron.example for configuration options."); - Ok(Self::default()) - } - } - - pub fn get_helpers(&self) -> &[CommitHelper] { - &self.helpers - } - - pub fn find_by_hotkey(&self, hotkey: char) -> Option { - self.helpers.iter().position(|h| h.hotkey == Some(hotkey)) - } - - pub fn execute_helper(&self, helper_index: usize) -> Result { - if helper_index >= self.helpers.len() { - anyhow::bail!("Invalid helper index"); - } - - let helper = &self.helpers[helper_index]; - - // Process template variables in command - let processed_command = self.process_template_variables(&helper.command)?; - - // Execute command through shell to support pipes and redirects - let output = if cfg!(target_os = "windows") { - Command::new("cmd") - .args(["/C", &processed_command]) - .stdin(Stdio::null()) - .output()? - } else { - Command::new("sh") - .args(["-c", &processed_command]) - .stdin(Stdio::null()) - .output()? - }; - - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Command failed: {}", error); - } - - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - if result.is_empty() { - anyhow::bail!("Command returned empty output"); - } - - Ok(result) - } - - fn process_template_variables(&self, command: &str) -> Result { - let mut processed = command.to_string(); - - // {staged_diff} - staged git diff - if processed.contains("{staged_diff}") { - let diff_output = Command::new("git") - .args(["diff", "--staged", "--no-color"]) - .output()?; - let diff = String::from_utf8_lossy(&diff_output.stdout); - processed = processed.replace("{staged_diff}", &diff); - } - - // {staged_files} - list of staged files - if processed.contains("{staged_files}") { - let files_output = Command::new("git") - .args(["diff", "--staged", "--name-only"]) - .output()?; - let files = String::from_utf8_lossy(&files_output.stdout); - processed = processed.replace("{staged_files}", files.trim()); - } - - // {branch_name} - current branch name - if processed.contains("{branch_name}") { - let branch_output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .output()?; - let branch = String::from_utf8_lossy(&branch_output.stdout); - processed = processed.replace("{branch_name}", branch.trim()); - } - - Ok(processed) - } -} \ No newline at end of file + Ok(Self::default()) + } + } + + pub fn get_helpers(&self) -> &[CommitHelper] { + &self.helpers + } + + pub fn find_by_hotkey(&self, hotkey: char) -> Option { + self.helpers.iter().position(|h| h.hotkey == Some(hotkey)) + } + + pub fn execute_helper( + &self, + helper_index: usize, + ) -> Result { + if helper_index >= self.helpers.len() { + anyhow::bail!("Invalid helper index"); + } + + let helper = &self.helpers[helper_index]; + + // Process template variables in command + let processed_command = + Self::process_template_variables(&helper.command)?; + + // Execute command through shell to support pipes and redirects + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", &processed_command]) + .stdin(Stdio::null()) + .output()? + } else { + Command::new("sh") + .args(["-c", &processed_command]) + .stdin(Stdio::null()) + .output()? + }; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Command failed: {error}"); + } + + let result = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + + if result.is_empty() { + anyhow::bail!("Command returned empty output"); + } + + Ok(result) + } + + fn process_template_variables(command: &str) -> Result { + let mut processed = command.to_string(); + + // {staged_diff} - staged git diff + if processed.contains("{staged_diff}") { + let diff_output = Command::new("git") + .args(["diff", "--staged", "--no-color"]) + .output()?; + let diff = String::from_utf8_lossy(&diff_output.stdout); + processed = processed.replace("{staged_diff}", &diff); + } + + // {staged_files} - list of staged files + if processed.contains("{staged_files}") { + let files_output = Command::new("git") + .args(["diff", "--staged", "--name-only"]) + .output()?; + let files = String::from_utf8_lossy(&files_output.stdout); + processed = + processed.replace("{staged_files}", files.trim()); + } + + // {branch_name} - current branch name + if processed.contains("{branch_name}") { + let branch_output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output()?; + let branch = + String::from_utf8_lossy(&branch_output.stdout); + processed = + processed.replace("{branch_name}", branch.trim()); + } + + Ok(processed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_default_config() { + let config = CommitHelpers::default(); + assert!(config.helpers.is_empty()); + } + + #[test] + fn test_find_by_hotkey() { + let config = CommitHelpers { + helpers: vec![ + CommitHelper { + name: "Test Helper 1".to_string(), + command: "echo test1".to_string(), + description: None, + hotkey: Some('a'), + timeout_secs: None, + }, + CommitHelper { + name: "Test Helper 2".to_string(), + command: "echo test2".to_string(), + description: None, + hotkey: Some('b'), + timeout_secs: None, + }, + ], + }; + + assert_eq!(config.find_by_hotkey('a'), Some(0)); + assert_eq!(config.find_by_hotkey('b'), Some(1)); + assert_eq!(config.find_by_hotkey('c'), None); + } + + #[test] + fn test_process_template_variables() { + // Test basic template processing (these will use actual git commands) + let result = CommitHelpers::process_template_variables( + "test {branch_name} test", + ); + assert!(result.is_ok()); + + // Test no template variables + let result = CommitHelpers::process_template_variables( + "no templates here", + ) + .unwrap(); + assert_eq!(result, "no templates here"); + } + + #[test] + fn test_execute_helper_invalid_index() { + let config = CommitHelpers::default(); + let result = config.execute_helper(0); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid helper index")); + } + + #[test] + fn test_execute_helper_success() { + let config = CommitHelpers { + helpers: vec![CommitHelper { + name: "Echo Test".to_string(), + command: "echo 'test message'".to_string(), + description: None, + hotkey: None, + timeout_secs: None, + }], + }; + + let result = config.execute_helper(0); + assert!(result.is_ok()); + assert_eq!(result.unwrap().trim(), "test message"); + } + + #[test] + fn test_execute_helper_empty_output() { + let config = CommitHelpers { + helpers: vec![CommitHelper { + name: "Empty Test".to_string(), + command: "true".to_string(), // Command that succeeds but produces no output + description: None, + hotkey: None, + timeout_secs: None, + }], + }; + + let result = config.execute_helper(0); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Command returned empty output")); + } + + #[test] + fn test_config_file_parsing() { + let temp_dir = TempDir::new().unwrap(); + let config_content = r#"CommitHelpers( + helpers: [ + CommitHelper( + name: "Test Helper", + command: "echo test", + description: Some("A test helper"), + hotkey: Some('t'), + timeout_secs: Some(15), + ) + ] +)"#; + + let config_path = temp_dir.path().join("test_helpers.ron"); + fs::write(&config_path, config_content).unwrap(); + + let file = std::fs::File::open(&config_path).unwrap(); + let config: CommitHelpers = + ron::de::from_reader(file).unwrap(); + + assert_eq!(config.helpers.len(), 1); + assert_eq!(config.helpers[0].name, "Test Helper"); + assert_eq!(config.helpers[0].command, "echo test"); + assert_eq!( + config.helpers[0].description, + Some("A test helper".to_string()) + ); + assert_eq!(config.helpers[0].hotkey, Some('t')); + assert_eq!(config.helpers[0].timeout_secs, Some(15)); + } +} diff --git a/src/popups/commit.rs b/src/popups/commit.rs index e90014a1ac..2114a3d124 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -90,7 +90,8 @@ pub struct CommitPopup { const FIRST_LINE_LIMIT: usize = 50; // Spinner animation frames -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_FRAMES: &[&str] = + &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_INTERVAL_MS: u64 = 80; const HELPER_TIMEOUT_SECS: u64 = 30; @@ -137,16 +138,20 @@ impl CommitPopup { match result { Ok(generated_msg) => { let current_msg = self.input.get_text(); - let new_msg = if current_msg.is_empty() { - generated_msg - } else { - format!("{}\n\n{}", current_msg, generated_msg) - }; + let new_msg = + if current_msg.is_empty() { + generated_msg + } else { + format!("{current_msg}\n\n{generated_msg}") + }; self.input.set_text(new_msg); - self.helper_state = HelperState::Success("Generated successfully".to_string()); + self.helper_state = HelperState::Success( + "Generated successfully".to_string(), + ); } Err(error_msg) => { - self.helper_state = HelperState::Error(error_msg); + self.helper_state = + HelperState::Error(error_msg); } } self.helper_receiver = None; @@ -155,30 +160,45 @@ impl CommitPopup { } pub fn clear_helper_message(&mut self) { - if matches!(self.helper_state, HelperState::Success(_) | HelperState::Error(_) | HelperState::Selection { .. }) { + if matches!( + self.helper_state, + HelperState::Success(_) + | HelperState::Error(_) + | HelperState::Selection { .. } + ) { self.helper_state = HelperState::Idle; } } pub fn cancel_helper(&mut self) { if matches!(self.helper_state, HelperState::Running { .. }) { - self.helper_state = HelperState::Error("Cancelled by user".to_string()); + self.helper_state = + HelperState::Error("Cancelled by user".to_string()); self.helper_receiver = None; } } pub fn update_helper_animation(&mut self) -> bool { - if let HelperState::Running { frame_index, start_time, helper_name: _ } = &mut self.helper_state { + if let HelperState::Running { + frame_index, + start_time, + helper_name: _, + } = &mut self.helper_state + { let elapsed = start_time.elapsed(); - + // Check timeout if elapsed.as_secs() > HELPER_TIMEOUT_SECS { - self.helper_state = HelperState::Error("Timeout: Helper took too long".to_string()); + self.helper_state = HelperState::Error( + "Timeout: Helper took too long".to_string(), + ); return true; } - + // Update frame - let new_frame = (elapsed.as_millis() / SPINNER_INTERVAL_MS as u128) as usize % SPINNER_FRAMES.len(); + let new_frame = (elapsed.as_millis() + / u128::from(SPINNER_INTERVAL_MS)) + as usize % SPINNER_FRAMES.len(); if *frame_index != new_frame { *frame_index = new_frame; return true; // Animation frame changed, needs redraw @@ -235,40 +255,46 @@ impl CommitPopup { } fn draw_helper_status(&self, f: &mut Frame) { - use ratatui::widgets::{Paragraph, Wrap}; use ratatui::style::Style; - + use ratatui::widgets::{Paragraph, Wrap}; + let (msg, style) = match &self.helper_state { HelperState::Idle => return, HelperState::Selection { selected_index } => { let helpers = self.commit_helpers.get_helpers(); - if let Some(helper) = helpers.get(*selected_index) { - let hotkey_hint = helper.hotkey - .map(|h| format!(" [{}]", h)) - .unwrap_or_default(); - (format!("Select helper: {} ({}/{}){}. [↑↓] to navigate, [Enter] to run, [ESC] to cancel", - helper.name, selected_index + 1, helpers.len(), hotkey_hint), - Style::default().fg(ratatui::style::Color::Cyan)) - } else { - (String::from("No helpers available"), self.theme.text_danger()) - } + helpers.get(*selected_index).map_or_else( + || (String::from("No helpers available"), self.theme.text_danger()), + |helper| { + let hotkey_hint = helper.hotkey + .map(|h| format!(" [{h}]")) + .unwrap_or_default(); + (format!("Select helper: {} ({}/{}){}. [↑↓] to navigate, [Enter] to run, [ESC] to cancel", + helper.name, selected_index + 1, helpers.len(), hotkey_hint), + Style::default().fg(ratatui::style::Color::Cyan)) + } + ) } - HelperState::Running { helper_name, frame_index, start_time } => { + HelperState::Running { + helper_name, + frame_index, + start_time, + } => { let spinner = SPINNER_FRAMES[*frame_index]; let elapsed = start_time.elapsed().as_secs(); - (format!("{} Generating with {}... ({}s) [ESC to cancel]", spinner, helper_name, elapsed), + (format!("{spinner} Generating with {helper_name}... ({elapsed}s) [ESC to cancel]"), Style::default().fg(ratatui::style::Color::Yellow)) } - HelperState::Success(msg) => { - (format!("✅ {}", msg), - Style::default().fg(ratatui::style::Color::Green)) - } + HelperState::Success(msg) => ( + format!("✅ {msg}"), + Style::default().fg(ratatui::style::Color::Green), + ), HelperState::Error(err) => { - (format!("❌ {}", err), self.theme.text_danger()) + (format!("❌ {err}"), self.theme.text_danger()) } }; - let msg_length: u16 = msg.chars().count().try_into().unwrap_or(0); + let msg_length: u16 = + msg.chars().count().try_into().unwrap_or(0); let paragraph = Paragraph::new(msg) .style(style) .wrap(Wrap { trim: true }); @@ -507,7 +533,7 @@ impl CommitPopup { "~/.config/gitui/commit_helpers.ron" }; self.helper_state = HelperState::Error( - format!("No commit helpers configured. Create {} (see .example file)", config_path) + format!("No commit helpers configured. Create {config_path} (see .example file)") ); return Ok(()); } @@ -517,23 +543,29 @@ impl CommitPopup { self.execute_helper_by_index(0) } else { // Multiple helpers, show selection UI - self.helper_state = HelperState::Selection { selected_index: 0 }; + self.helper_state = + HelperState::Selection { selected_index: 0 }; Ok(()) } } - fn execute_helper_by_index(&mut self, helper_index: usize) -> Result<()> { + fn execute_helper_by_index( + &mut self, + helper_index: usize, + ) -> Result<()> { let helpers = self.commit_helpers.get_helpers(); if helper_index >= helpers.len() { - self.helper_state = HelperState::Error("Invalid helper index".to_string()); + self.helper_state = HelperState::Error( + "Invalid helper index".to_string(), + ); return Ok(()); } let helper = helpers[helper_index].clone(); - + // Set running state with animation self.helper_state = HelperState::Running { - helper_name: helper.name.clone(), + helper_name: helper.name, frame_index: 0, start_time: Instant::now(), }; @@ -544,12 +576,13 @@ impl CommitPopup { // Clone helpers for thread let commit_helpers = self.commit_helpers.clone(); - + // Execute helper in background thread thread::spawn(move || { - let result = commit_helpers.execute_helper(helper_index) - .map_err(|e| format!("Failed: {}", e)); - + let result = commit_helpers + .execute_helper(helper_index) + .map_err(|e| format!("Failed: {e}")); + // Send result back (ignore if receiver is dropped) let _ = tx.send(result); }); @@ -762,7 +795,9 @@ impl Component for CommitPopup { if !self.commit_helpers.get_helpers().is_empty() { out.push(CommandInfo::new( - strings::commands::commit_helper(&self.key_config), + strings::commands::commit_helper( + &self.key_config, + ), true, true, )); @@ -777,29 +812,45 @@ impl Component for CommitPopup { // Handle helper selection navigation if let Event::Key(key) = ev { // Handle helper selection state - if let HelperState::Selection { selected_index } = &self.helper_state { + if let HelperState::Selection { selected_index } = + &self.helper_state + { match key.code { crossterm::event::KeyCode::Esc => { self.helper_state = HelperState::Idle; return Ok(EventState::Consumed); } crossterm::event::KeyCode::Up => { - let helpers_len = self.commit_helpers.get_helpers().len(); + let helpers_len = self + .commit_helpers + .get_helpers() + .len(); if helpers_len > 0 { - let new_index = if *selected_index == 0 { - helpers_len - 1 - } else { - *selected_index - 1 - }; - self.helper_state = HelperState::Selection { selected_index: new_index }; + let new_index = + if *selected_index == 0 { + helpers_len - 1 + } else { + *selected_index - 1 + }; + self.helper_state = + HelperState::Selection { + selected_index: new_index, + }; } return Ok(EventState::Consumed); } crossterm::event::KeyCode::Down => { - let helpers_len = self.commit_helpers.get_helpers().len(); + let helpers_len = self + .commit_helpers + .get_helpers() + .len(); if helpers_len > 0 { - let new_index = (*selected_index + 1) % helpers_len; - self.helper_state = HelperState::Selection { selected_index: new_index }; + let new_index = (*selected_index + 1) + % helpers_len; + self.helper_state = + HelperState::Selection { + selected_index: new_index, + }; } return Ok(EventState::Consumed); } @@ -808,17 +859,23 @@ impl Component for CommitPopup { try_or_popup!( self, "helper execution error:", - self.execute_helper_by_index(selected) + self.execute_helper_by_index( + selected + ) ); return Ok(EventState::Consumed); } crossterm::event::KeyCode::Char(c) => { // Check for hotkey match - if let Some(index) = self.commit_helpers.find_by_hotkey(c) { + if let Some(index) = + self.commit_helpers.find_by_hotkey(c) + { try_or_popup!( self, "helper execution error:", - self.execute_helper_by_index(index) + self.execute_helper_by_index( + index + ) ); return Ok(EventState::Consumed); } @@ -828,16 +885,19 @@ impl Component for CommitPopup { } // Handle ESC to cancel running helper - if key.code == crossterm::event::KeyCode::Esc - && matches!(self.helper_state, HelperState::Running { .. }) { + if key.code == crossterm::event::KeyCode::Esc + && matches!( + self.helper_state, + HelperState::Running { .. } + ) { self.cancel_helper(); return Ok(EventState::Consumed); } - + // Clear success/error messages on key press self.clear_helper_message(); } - + if let Event::Key(e) = ev { let input_consumed = if key_match(e, self.key_config.keys.commit)