diff --git a/Cargo.lock b/Cargo.lock index bf6d673..d472a45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom", + "ratatui", + "simdutf8", + "smallvec", + "thiserror", +] + [[package]] name = "anstream" version = "0.6.14" @@ -1669,6 +1682,7 @@ dependencies = [ name = "serie" version = "0.4.7" dependencies = [ + "ansi-to-tui", "arboard", "base64 0.22.1", "chrono", @@ -1739,6 +1753,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index b3c333a..4b28721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ rust-version = "1.87.0" exclude = ["/.github", "/img"] [dependencies] +ansi-to-tui = "7.0.0" arboard = "3.6.0" base64 = "0.22.1" chrono = "0.4.41" diff --git a/README.md b/README.md index 96cf706..6c77f1f 100644 --- a/README.md +++ b/README.md @@ -195,15 +195,17 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom | Ctrl-g | Toggle ignore case (if searching) | `ignore_case_toggle` | | Ctrl-x | Toggle fuzzy match (if searching) | `fuzzy_toggle` | | c/C | Copy commit short/full hash | `short_copy` `full_copy` | +| d | Toggle custom user command view | `user_command_view_toggle_1` | #### Commit Detail -| Key | Description | Corresponding keybind | -| ----------------------------------- | --------------------------- | ----------------------------- | -| Esc Backspace | Close commit details | `close` `cancel` | -| Down/Up j/k | Scroll down/up | `navigate_down` `navigate_up` | -| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` | -| c/C | Copy commit short/full hash | `short_copy` `full_copy` | +| Key | Description | Corresponding keybind | +| ----------------------------------- | ------------------------------- | ----------------------------- | +| Esc Backspace | Close commit details | `close` `cancel` | +| Down/Up j/k | Scroll down/up | `navigate_down` `navigate_up` | +| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` | +| c/C | Copy commit short/full hash | `short_copy` `full_copy` | +| d | Toggle custom user command view | `user_command_view_toggle_1` | #### Refs List @@ -215,6 +217,14 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom | Right/Left l/h | Open/Close node | `navigate_right` `navigate_left` | | c | Copy ref name | `short_copy` | +#### User Command + +| Key | Description | Corresponding keybind | +| ------------------------------------------------ | ------------------ | ------------------------------ | +| Esc Backspace ? | Close user command | `close` `cancel` `help_toggle` | +| Down/Up j/k | Scroll down/up | `navigate_down` `navigate_up` | +| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` | + #### Help | Key | Description | Corresponding keybind | @@ -253,6 +263,13 @@ ignore_case = false # type: boolean fuzzy = false +[core.user_command] +# The command definition for generating the content displayed in the user command view. +# Multiple commands can be specified in the format commands_{n}. +# For details about user command, see the separate User command section. +# type: object +commands_1 = { name = "git diff", commands = ["git", "--no-pager", "diff", "--color=always", "{{first_parent_hash}}", "{{target_hash}}"]} + [ui.common] # The type of a cursor to display in the input. # If `cursor_type = "Native"` is set, the terminal native cursor is used. @@ -324,6 +341,50 @@ background = "#00000000" +### User command + +The User command view allows you to display the output (stdout) of your custom external commands. +This allows you to do things like view commit diffs using your favorite tools. + +To define a user command, you need to configure the following two settings: +- Keybinding definition. Specify the key to display each user command. + - Config: `keybind.user_command_view_toggle_{n}` +- Command definition. Specify the actual command you want to execute. + - Config: `core.user_command.commands_{n}` + +
+Configuration example + +```toml +[keybind] +user_command_view_toggle_1 = ["d"] +user_command_view_toggle_2 = ["shift-d"] + +[core.user_command] +commands_1 = { "name" = "git diff", commands = ["git", "--no-pager", "diff", "--color=always", "{{first_parent_hash}}", "{{target_hash}}"] } +commands_2 = { "name" = "xxx", commands = ["xxx", "{{first_parent_hash}}", "{{target_hash}}", "--width", "{{area_width}}", "--height", "{{area_height}}"] } +``` + +
+ +#### Variables + +The following variables can be used in command definitions. +They will be replaced with their respective values command is executed. + +- `{{target_hash}}` + - The hash of the selected commit. + - example: `b0ce4cb9c798576af9b4accc9f26ddce5e72063d` +- `{{first_parent_hash}}` + - The hash of the first parent of the selected commit. + - example: `c103d9744df8ebf100773a11345f011152ec5581` +- `{{area_width}}` + - Width of the user command display area (number of cells). + - example: 80 +- `{{area_height}}` + - Height of the user command display area (number of cells). + - example: 30 + ## Compatibility ### Supported terminals @@ -370,6 +431,8 @@ Contributions that do not follow these guidelines may not be accepted. + + The following repositories are used as these examples: diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index 7e4b7be..1b6bc8e 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -30,6 +30,8 @@ search = ["/"] ignore_case_toggle = ["ctrl-g"] fuzzy_toggle = ["ctrl-x"] +user_command_view_toggle_1 = ["d"] + # copy part of information, ex: copy the short commit hash not all short_copy = ["c"] full_copy = ["shift-c"] diff --git a/img/diff_delta.png b/img/diff_delta.png new file mode 100644 index 0000000..5c7d2bc Binary files /dev/null and b/img/diff_delta.png differ diff --git a/img/diff_difft.png b/img/diff_difft.png new file mode 100644 index 0000000..0d443fd Binary files /dev/null and b/img/diff_difft.png differ diff --git a/img/diff_git.png b/img/diff_git.png new file mode 100644 index 0000000..53e91a4 Binary files /dev/null and b/img/diff_git.png differ diff --git a/src/app.rs b/src/app.rs index 9b0c6e9..908d811 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,11 +40,14 @@ pub struct App<'a> { status_line: StatusLine, keybind: &'a KeyBind, + core_config: &'a CoreConfig, ui_config: &'a UiConfig, color_theme: &'a ColorTheme, image_protocol: ImageProtocol, tx: Sender, + numeric_prefix: String, + view_area: Rect, } impl<'a> App<'a> { @@ -97,11 +100,13 @@ impl<'a> App<'a> { status_line: StatusLine::None, view, keybind, + core_config, ui_config, color_theme, image_protocol, tx, numeric_prefix: String::new(), + view_area: Rect::default(), } } } @@ -189,6 +194,15 @@ impl App<'_> { AppEvent::ClearDetail => { self.clear_detail(); } + AppEvent::OpenUserCommand(n) => { + self.open_user_command(n); + } + AppEvent::CloseUserCommand => { + self.close_user_command(); + } + AppEvent::ClearUserCommand => { + self.clear_user_command(); + } AppEvent::OpenRefs => { self.open_refs(); } @@ -238,6 +252,8 @@ impl App<'_> { let [view_area, status_line_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).areas(f.area()); + self.update_state(view_area); + self.view.render(f, view_area); self.render_status_line(f, status_line_area); } @@ -305,6 +321,10 @@ impl App<'_> { } impl App<'_> { + fn update_state(&mut self, view_area: Rect) { + self.view_area = view_area; + } + fn open_detail(&mut self) { if let View::List(ref mut view) = self.view { let commit_list_state = view.take_list_state(); @@ -347,6 +367,108 @@ impl App<'_> { } } + fn open_user_command(&mut self, user_command_number: usize) { + if let View::List(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let selected = commit_list_state.selected_commit_hash().clone(); + let (commit, _) = self.repository.commit_detail(&selected); + self.view = View::of_user_command_from_list( + commit_list_state, + commit, + user_command_number, + self.view_area, + self.core_config, + self.ui_config, + self.color_theme, + self.image_protocol, + self.tx.clone(), + ); + } else if let View::Detail(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let selected = commit_list_state.selected_commit_hash().clone(); + let (commit, _) = self.repository.commit_detail(&selected); + self.view = View::of_user_command_from_detail( + commit_list_state, + commit, + user_command_number, + self.view_area, + self.core_config, + self.ui_config, + self.color_theme, + self.image_protocol, + self.tx.clone(), + ); + } else if let View::UserCommand(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + let selected = commit_list_state.selected_commit_hash().clone(); + let (commit, _) = self.repository.commit_detail(&selected); + if view.before_view_is_list() { + self.view = View::of_user_command_from_list( + commit_list_state, + commit, + user_command_number, + self.view_area, + self.core_config, + self.ui_config, + self.color_theme, + self.image_protocol, + self.tx.clone(), + ); + } else { + self.view = View::of_user_command_from_detail( + commit_list_state, + commit, + user_command_number, + self.view_area, + self.core_config, + self.ui_config, + self.color_theme, + self.image_protocol, + self.tx.clone(), + ); + } + } + } + + fn close_user_command(&mut self) { + if let View::UserCommand(ref mut view) = self.view { + let commit_list_state = view.take_list_state(); + 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(); + if view.before_view_is_list() { + self.view = View::of_list( + commit_list_state, + self.ui_config, + self.color_theme, + self.tx.clone(), + ); + } else { + self.view = View::of_detail( + commit_list_state, + commit, + changes, + refs, + self.ui_config, + self.color_theme, + self.image_protocol, + self.tx.clone(), + ); + } + } + } + + fn clear_user_command(&mut self) { + if let View::UserCommand(ref mut view) = self.view { + view.clear(); + } + } + fn open_refs(&mut self) { if let View::List(ref mut view) = self.view { let commit_list_state = view.take_list_state(); @@ -381,6 +503,7 @@ impl App<'_> { self.image_protocol, self.tx.clone(), self.keybind, + self.core_config, ); } diff --git a/src/config.rs b/src/config.rs index 13d3a28..09ecc90 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, env, path::{Path, PathBuf}, }; @@ -75,6 +76,8 @@ struct Config { pub struct CoreConfig { #[nested] pub search: CoreSearchConfig, + #[nested] + pub user_command: CoreUserCommandConfig, } #[optional(derives = [Deserialize])] @@ -86,6 +89,84 @@ pub struct CoreSearchConfig { pub fuzzy: bool, } +#[optional] +#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] +pub struct CoreUserCommandConfig { + #[default(HashMap::from([("1".into(), UserCommand { + name: "git diff".into(), + commands: vec![ + "git".into(), + "--no-pager".into(), + "diff".into(), + "--color=always".into(), + "{{first_parent_hash}}".into(), + "{{target_hash}}".into(), + ], + })]))] + pub commands: HashMap, +} + +impl<'de> Deserialize<'de> for OptionalCoreUserCommandConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{Error, MapAccess, Visitor}; + use std::fmt; + + struct OptionalCoreUserCommandConfigVisitor; + + impl<'de> Visitor<'de> for OptionalCoreUserCommandConfigVisitor { + type Value = HashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of user commands") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: MapAccess<'de>, + { + let mut commands = HashMap::new(); + while let Some(key) = map.next_key::()? { + if let Some(suffix) = key.strip_prefix("commands_") { + let command_key = suffix.to_string(); + if command_key.is_empty() { + return Err(V::Error::custom( + "command key cannot be empty, like `commands_`", + )); + } + let command_value: UserCommand = map.next_value()?; + commands.insert(command_key, command_value); + } else if key == "commands" { + return Err(V::Error::custom( + "invalid key `commands`, use `commands_n` format instead", + )); + } else { + let _: serde::de::IgnoredAny = map.next_value()?; + } + } + Ok(commands) + } + } + + let commands = deserializer.deserialize_map(OptionalCoreUserCommandConfigVisitor)?; + let commands = if commands.is_empty() { + None + } else { + Some(commands) + }; + + Ok(OptionalCoreUserCommandConfig { commands }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct UserCommand { + pub name: String, + pub commands: Vec, +} + #[optional(derives = [Deserialize])] #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct UiConfig { @@ -96,6 +177,8 @@ pub struct UiConfig { #[nested] pub detail: UiDetailConfig, #[nested] + pub user_command: UiUserCommandConfig, + #[nested] pub refs: UiRefsConfig, } @@ -138,6 +221,13 @@ pub struct UiDetailConfig { pub date_local: bool, } +#[optional(derives = [Deserialize])] +#[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] +pub struct UiUserCommandConfig { + #[default = 20] + pub height: u16, +} + #[optional(derives = [Deserialize])] #[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] pub struct UiRefsConfig { @@ -183,6 +273,22 @@ mod tests { ignore_case: false, fuzzy: false, }, + user_command: CoreUserCommandConfig { + commands: HashMap::from([( + "1".into(), + UserCommand { + name: "git diff".into(), + commands: vec![ + "git".into(), + "--no-pager".into(), + "diff".into(), + "--color=always".into(), + "{{first_parent_hash}}".into(), + "{{target_hash}}".into(), + ], + }, + )]), + }, }, ui: UiConfig { common: UiCommonConfig { @@ -200,6 +306,7 @@ mod tests { date_format: "%Y-%m-%d %H:%M:%S %z".into(), date_local: true, }, + user_command: UiUserCommandConfig { height: 20 }, refs: UiRefsConfig { width: 26 }, }, graph: GraphConfig { @@ -227,6 +334,10 @@ mod tests { [core.search] ignore_case = true fuzzy = true + [core.user_command] + commands_1 = { name = "git diff no color", commands = ["git", "diff", "{{first_parent_hash}}", "{{target_hash}}"] } + commands_2 = { name = "echo hello", commands = ["echo", "hello"] } + commands_10 = { name = "echo world", commands = ["echo", "world"] } [ui.common] cursor_type = { Virtual = "|" } [ui.list] @@ -239,6 +350,8 @@ mod tests { height = 30 date_format = "%Y/%m/%d %H:%M:%S" date_local = false + [ui.user_command] + height = 30 [ui.refs] width = 40 [graph.color] @@ -253,6 +366,36 @@ mod tests { ignore_case: true, fuzzy: true, }, + user_command: CoreUserCommandConfig { + commands: HashMap::from([ + ( + "1".into(), + UserCommand { + name: "git diff no color".into(), + commands: vec![ + "git".into(), + "diff".into(), + "{{first_parent_hash}}".into(), + "{{target_hash}}".into(), + ], + }, + ), + ( + "2".into(), + UserCommand { + name: "echo hello".into(), + commands: vec!["echo".into(), "hello".into()], + }, + ), + ( + "10".into(), + UserCommand { + name: "echo world".into(), + commands: vec!["echo".into(), "world".into()], + }, + ), + ]), + }, }, ui: UiConfig { common: UiCommonConfig { @@ -270,6 +413,7 @@ mod tests { date_format: "%Y/%m/%d %H:%M:%S".into(), date_local: false, }, + user_command: UiUserCommandConfig { height: 30 }, refs: UiRefsConfig { width: 40 }, }, graph: GraphConfig { @@ -297,6 +441,22 @@ mod tests { ignore_case: false, fuzzy: false, }, + user_command: CoreUserCommandConfig { + commands: HashMap::from([( + "1".into(), + UserCommand { + name: "git diff".into(), + commands: vec![ + "git".into(), + "--no-pager".into(), + "diff".into(), + "--color=always".into(), + "{{first_parent_hash}}".into(), + "{{target_hash}}".into(), + ], + }, + )]), + }, }, ui: UiConfig { common: UiCommonConfig { @@ -314,6 +474,7 @@ mod tests { date_format: "%Y-%m-%d %H:%M:%S %z".into(), date_local: true, }, + user_command: UiUserCommandConfig { height: 20 }, refs: UiRefsConfig { width: 26 }, }, graph: GraphConfig { diff --git a/src/event.rs b/src/event.rs index 9d324b4..8ef4031 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,7 +5,10 @@ use std::{ }; use ratatui::crossterm::event::KeyEvent; -use serde::Deserialize; +use serde::{ + de::{self, Deserializer, Visitor}, + Deserialize, +}; pub enum AppEvent { Key(KeyEvent), @@ -14,6 +17,9 @@ pub enum AppEvent { OpenDetail, CloseDetail, ClearDetail, + OpenUserCommand(usize), + CloseUserCommand, + ClearUserCommand, OpenRefs, CloseRefs, OpenHelp, @@ -82,8 +88,7 @@ pub fn init() -> (Sender, Receiver) { } // The event triggered by user's key input -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum UserEvent { ForceQuit, Quit, @@ -111,6 +116,7 @@ pub enum UserEvent { Confirm, RefListToggle, Search, + UserCommandViewToggle(usize), IgnoreCaseToggle, FuzzyToggle, ShortCopy, @@ -118,6 +124,76 @@ pub enum UserEvent { Unknown, } +impl<'de> Deserialize<'de> for UserEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct UserEventVisitor; + + impl<'de> Visitor<'de> for UserEventVisitor { + type Value = UserEvent; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string representing a user event") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if let Some(num_str) = value.strip_prefix("user_command_view_toggle_") { + if let Ok(num) = num_str.parse::() { + Ok(UserEvent::UserCommandViewToggle(num)) + } else { + let msg = format!("Invalid user_command_view_toggle_n format: {}", value); + Err(de::Error::custom(msg)) + } + } else { + match value { + "force_quit" => Ok(UserEvent::ForceQuit), + "quit" => Ok(UserEvent::Quit), + "help_toggle" => Ok(UserEvent::HelpToggle), + "cancel" => Ok(UserEvent::Cancel), + "close" => Ok(UserEvent::Close), + "navigate_up" => Ok(UserEvent::NavigateUp), + "navigate_down" => Ok(UserEvent::NavigateDown), + "navigate_right" => Ok(UserEvent::NavigateRight), + "navigate_left" => Ok(UserEvent::NavigateLeft), + "go_to_top" => Ok(UserEvent::GoToTop), + "go_to_bottom" => Ok(UserEvent::GoToBottom), + "go_to_parent" => Ok(UserEvent::GoToParent), + "scroll_up" => Ok(UserEvent::ScrollUp), + "scroll_down" => Ok(UserEvent::ScrollDown), + "page_up" => Ok(UserEvent::PageUp), + "page_down" => Ok(UserEvent::PageDown), + "half_page_up" => Ok(UserEvent::HalfPageUp), + "half_page_down" => Ok(UserEvent::HalfPageDown), + "select_top" => Ok(UserEvent::SelectTop), + "select_middle" => Ok(UserEvent::SelectMiddle), + "select_bottom" => Ok(UserEvent::SelectBottom), + "go_to_next" => Ok(UserEvent::GoToNext), + "go_to_previous" => Ok(UserEvent::GoToPrevious), + "confirm" => Ok(UserEvent::Confirm), + "ref_list_toggle" => Ok(UserEvent::RefListToggle), + "search" => Ok(UserEvent::Search), + "ignore_case_toggle" => Ok(UserEvent::IgnoreCaseToggle), + "fuzzy_toggle" => Ok(UserEvent::FuzzyToggle), + "short_copy" => Ok(UserEvent::ShortCopy), + "full_copy" => Ok(UserEvent::FullCopy), + _ => { + let msg = format!("Unknown user event: {}", value); + Err(de::Error::custom(msg)) + } + } + } + } + } + + deserializer.deserialize_str(UserEventVisitor) + } +} + impl UserEvent { pub fn is_countable(&self) -> bool { matches!( diff --git a/src/external.rs b/src/external.rs index acd8aeb..c64be68 100644 --- a/src/external.rs +++ b/src/external.rs @@ -1,7 +1,48 @@ +use std::process::Command; + use arboard::Clipboard; +const USER_COMMAND_TARGET_HASH_MARKER: &str = "{{target_hash}}"; +const USER_COMMAND_FIRST_PARENT_HASH_MARKER: &str = "{{first_parent_hash}}"; +const USER_COMMAND_AREA_WIDTH_MARKER: &str = "{{area_width}}"; +const USER_COMMAND_AREA_HEIGHT_MARKER: &str = "{{area_height}}"; + pub fn copy_to_clipboard(value: String) -> Result<(), String> { Clipboard::new() .and_then(|mut c| c.set_text(value)) .map_err(|e| format!("Failed to copy to clipboard: {e:?}")) } + +pub fn exec_user_command( + command: &[&str], + target_hash: &str, + first_parent_hash: &str, + area_width: u16, + area_height: u16, +) -> Result { + let command = command + .iter() + .map(|s| { + s.replace(USER_COMMAND_TARGET_HASH_MARKER, target_hash) + .replace(USER_COMMAND_FIRST_PARENT_HASH_MARKER, first_parent_hash) + .replace(USER_COMMAND_AREA_WIDTH_MARKER, &area_width.to_string()) + .replace(USER_COMMAND_AREA_HEIGHT_MARKER, &area_height.to_string()) + }) + .collect::>(); + + let output = Command::new(&command[0]) + .args(&command[1..]) + .output() + .map_err(|e| format!("Failed to execute command: {e:?}"))?; + + if !output.status.success() { + let msg = format!( + "Command exited with non-zero status: {}, stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + return Err(msg); + } + + Ok(String::from_utf8_lossy(&output.stdout).into()) +} diff --git a/src/keybind.rs b/src/keybind.rs index 44df301..62120e0 100644 --- a/src/keybind.rs +++ b/src/keybind.rs @@ -48,6 +48,21 @@ impl KeyBind { key_events.sort_by(|a, b| a.partial_cmp(b).unwrap()); // At least when used for key bindings, it doesn't seem to be a problem... key_events.into_iter().map(key_event_to_string).collect() } + + pub fn user_command_view_toggle_event_numbers(&self) -> Vec { + let mut numbers: Vec = self + .values() + .filter_map(|ue| { + if let UserEvent::UserCommandViewToggle(n) = ue { + Some(*n) + } else { + None + } + }) + .collect(); + numbers.sort_unstable(); + numbers + } } impl<'de> Deserialize<'de> for KeyBind { @@ -242,6 +257,8 @@ mod tests { navigate_left = ["ctrl-h", "shift-h", "alt-h"] navigate_right = ["ctrl-shift-l", "alt-shift-ctrl-l"] quit = ["esc", "f12"] + user_command_view_toggle_1 = ["d"] + user_command_view_toggle_10 = ["e"] "#; let expected = KeyBind( @@ -286,6 +303,14 @@ mod tests { KeyEvent::new(KeyCode::F(12), KeyModifiers::empty()), UserEvent::Quit, ), + ( + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty()), + UserEvent::UserCommandViewToggle(1), + ), + ( + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), + UserEvent::UserCommandViewToggle(10), + ), ] .into_iter() .collect(), diff --git a/src/view.rs b/src/view.rs index eb663c4..5b918f4 100644 --- a/src/view.rs +++ b/src/view.rs @@ -4,5 +4,6 @@ mod detail; mod help; mod list; mod refs; +mod user_command; pub use views::*; diff --git a/src/view/detail.rs b/src/view/detail.rs index f55ff85..a69caf4 100644 --- a/src/view/detail.rs +++ b/src/view/detail.rs @@ -85,6 +85,9 @@ impl<'a> DetailView<'a> { UserEvent::FullCopy => { self.copy_commit_hash(); } + UserEvent::UserCommandViewToggle(n) => { + self.tx.send(AppEvent::OpenUserCommand(n)); + } UserEvent::HelpToggle => { self.tx.send(AppEvent::OpenHelp); } diff --git a/src/view/help.rs b/src/view/help.rs index 8ed9a97..a039815 100644 --- a/src/view/help.rs +++ b/src/view/help.rs @@ -9,6 +9,7 @@ use ratatui::{ use crate::{ color::ColorTheme, + config::CoreConfig, event::{AppEvent, Sender, UserEvent, UserEventWithCount}, keybind::KeyBind, protocol::ImageProtocol, @@ -37,8 +38,9 @@ impl HelpView<'_> { image_protocol: ImageProtocol, tx: Sender, keybind: &'a KeyBind, + core_config: &'a CoreConfig, ) -> HelpView<'a> { - let (help_key_lines, help_value_lines) = build_lines(color_theme, keybind); + let (help_key_lines, help_value_lines) = build_lines(color_theme, keybind, core_config); let help_key_line_max_width = help_key_lines .iter() .map(|line| line.width()) @@ -169,88 +171,101 @@ impl<'a> HelpView<'a> { } #[rustfmt::skip] -fn build_lines(color_theme: &ColorTheme, keybind: &KeyBind) -> (Vec>, Vec>) { - let (common_key_lines, common_value_lines) = build_block_lines( - "Common:", - &[ - (&[UserEvent::ForceQuit, UserEvent::Quit], "Quit app"), - (&[UserEvent::HelpToggle], "Open help"), - ], - color_theme, - keybind, - ); - let (help_key_lines, help_value_lines) = build_block_lines( - "Help:", - &[ - (&[UserEvent::HelpToggle, UserEvent::Cancel, UserEvent::Close], "Close help"), - (&[UserEvent::NavigateDown], "Scroll down"), - (&[UserEvent::NavigateUp], "Scroll up"), - (&[UserEvent::GoToTop], "Go to top"), - (&[UserEvent::GoToBottom], "Go to bottom"), - ], - color_theme, - keybind, - ); - let (list_key_lines, list_value_lines) = build_block_lines( - "Commit List:", - &[ - (&[UserEvent::NavigateDown], "Move down"), - (&[UserEvent::NavigateUp], "Move up"), - (&[UserEvent::GoToParent], "Go to parent"), - (&[UserEvent::GoToTop], "Go to top"), - (&[UserEvent::GoToBottom], "Go to bottom"), - (&[UserEvent::PageDown], "Scroll page down"), - (&[UserEvent::PageUp], "Scroll page up"), - (&[UserEvent::HalfPageDown], "Scroll half page down"), - (&[UserEvent::HalfPageUp], "Scroll half page up"), - (&[UserEvent::ScrollDown], "Scroll down"), - (&[UserEvent::ScrollUp], "Scroll up"), - (&[UserEvent::SelectTop], "Select top of the screen"), - (&[UserEvent::SelectMiddle], "Select middle of the screen"), - (&[UserEvent::SelectBottom], "Select bottom of the screen"), - (&[UserEvent::Confirm], "Show commit details"), - (&[UserEvent::RefListToggle], "Open refs list"), - (&[UserEvent::Search], "Start search"), - (&[UserEvent::Cancel], "Cancel search"), - (&[UserEvent::GoToNext], "Go to next search match"), - (&[UserEvent::GoToPrevious], "Go to previous search match"), - (&[UserEvent::IgnoreCaseToggle], "Toggle ignore case"), - (&[UserEvent::FuzzyToggle], "Toggle fuzzy match"), - (&[UserEvent::ShortCopy], "Copy commit short hash"), - (&[UserEvent::FullCopy], "Copy commit hash"), - ], - color_theme, - keybind, - ); - let (detail_key_lines, detail_value_lines) = build_block_lines( - "Commit Detail:", - &[ - (&[UserEvent::Cancel, UserEvent::Close], "Close commit details"), - (&[UserEvent::PageDown], "Scroll down"), - (&[UserEvent::PageUp], "Scroll up"), - (&[UserEvent::GoToTop], "Go to top"), - (&[UserEvent::GoToBottom], "Go to bottom"), - (&[UserEvent::ShortCopy], "Copy commit short hash"), - (&[UserEvent::FullCopy], "Copy commit hash"), - ], - color_theme, - keybind, - ); - let (refs_key_lines, refs_value_lines) = build_block_lines( - "Refs List:", - &[ - (&[UserEvent::Cancel, UserEvent::Close, UserEvent::RefListToggle], "Close refs list"), - (&[UserEvent::NavigateDown], "Move down"), - (&[UserEvent::NavigateUp], "Move up"), - (&[UserEvent::GoToTop], "Go to top"), - (&[UserEvent::GoToBottom], "Go to bottom"), - (&[UserEvent::NavigateRight], "Open node"), - (&[UserEvent::NavigateLeft], "Close node"), - (&[UserEvent::ShortCopy], "Copy ref name"), - ], - color_theme, - keybind, - ); +fn build_lines( + color_theme: &ColorTheme, + keybind: &KeyBind, + core_config: &CoreConfig, +) -> (Vec>, Vec>) { + let user_command_view_toggle_helps = keybind + .user_command_view_toggle_event_numbers() + .into_iter() + .flat_map(|n| { + core_config + .user_command + .commands + .get(&n.to_string()) + .map(|c| format!("Toggle user command {} - {}", n, c.name)) + .map(|desc| (vec![UserEvent::UserCommandViewToggle(n)], desc)) + }) + .collect::>(); + + let common_helps = vec![ + (vec![UserEvent::ForceQuit, UserEvent::Quit], "Quit app".into()), + (vec![UserEvent::HelpToggle], "Open help".into()), + ]; + let (common_key_lines, common_value_lines) = build_block_lines("Common:", common_helps, color_theme, keybind); + + let help_helps = vec![ + (vec![UserEvent::HelpToggle, UserEvent::Cancel, UserEvent::Close], "Close help".into()), + (vec![UserEvent::NavigateDown], "Scroll down".into()), + (vec![UserEvent::NavigateUp], "Scroll up".into()), + (vec![UserEvent::GoToTop], "Go to top".into()), + (vec![UserEvent::GoToBottom], "Go to bottom".into()), + ]; + let (help_key_lines, help_value_lines) = build_block_lines("Help:", help_helps, color_theme, keybind); + + let mut list_helps = vec![ + (vec![UserEvent::NavigateDown], "Move down".into()), + (vec![UserEvent::NavigateUp], "Move up".into()), + (vec![UserEvent::GoToParent], "Go to parent".into()), + (vec![UserEvent::GoToTop], "Go to top".into()), + (vec![UserEvent::GoToBottom], "Go to bottom".into()), + (vec![UserEvent::PageDown], "Scroll page down".into()), + (vec![UserEvent::PageUp], "Scroll page up".into()), + (vec![UserEvent::HalfPageDown], "Scroll half page down".into()), + (vec![UserEvent::HalfPageUp], "Scroll half page up".into()), + (vec![UserEvent::ScrollDown], "Scroll down".into()), + (vec![UserEvent::ScrollUp], "Scroll up".into()), + (vec![UserEvent::SelectTop], "Select top of the screen".into()), + (vec![UserEvent::SelectMiddle], "Select middle of the screen".into()), + (vec![UserEvent::SelectBottom], "Select bottom of the screen".into()), + (vec![UserEvent::Confirm], "Show commit details".into()), + (vec![UserEvent::RefListToggle], "Open refs list".into()), + (vec![UserEvent::Search], "Start search".into()), + (vec![UserEvent::Cancel], "Cancel search".into()), + (vec![UserEvent::GoToNext], "Go to next search match".into()), + (vec![UserEvent::GoToPrevious], "Go to previous search match".into()), + (vec![UserEvent::IgnoreCaseToggle], "Toggle ignore case".into()), + (vec![UserEvent::FuzzyToggle], "Toggle fuzzy match".into()), + (vec![UserEvent::ShortCopy], "Copy commit short hash".into()), + (vec![UserEvent::FullCopy], "Copy commit hash".into()), + ]; + list_helps.extend(user_command_view_toggle_helps.clone()); + let (list_key_lines, list_value_lines) = build_block_lines("Commit List:", list_helps, color_theme, keybind); + + let mut detail_helps = vec![ + (vec![UserEvent::Cancel, UserEvent::Close], "Close commit details".into()), + (vec![UserEvent::PageDown], "Scroll down".into()), + (vec![UserEvent::PageUp], "Scroll up".into()), + (vec![UserEvent::GoToTop], "Go to top".into()), + (vec![UserEvent::GoToBottom], "Go to bottom".into()), + (vec![UserEvent::ShortCopy], "Copy commit short hash".into()), + (vec![UserEvent::FullCopy], "Copy commit hash".into()), + ]; + detail_helps.extend(user_command_view_toggle_helps.clone()); + let (detail_key_lines, detail_value_lines) = build_block_lines("Commit Detail:", detail_helps, color_theme, keybind); + + let refs_helps = vec![ + (vec![UserEvent::Cancel, UserEvent::Close, UserEvent::RefListToggle], "Close refs list".into()), + (vec![UserEvent::NavigateDown], "Move down".into()), + (vec![UserEvent::NavigateUp], "Move up".into()), + (vec![UserEvent::GoToTop], "Go to top".into()), + (vec![UserEvent::GoToBottom], "Go to bottom".into()), + (vec![UserEvent::NavigateRight], "Open node".into()), + (vec![UserEvent::NavigateLeft], "Close node".into()), + (vec![UserEvent::ShortCopy], "Copy ref name".into()), + ]; + let (refs_key_lines, refs_value_lines) = build_block_lines("Refs List:", refs_helps, color_theme, keybind); + + let mut user_command_helps = vec![ + (vec![UserEvent::Cancel, UserEvent::Close], "Close user command".into()), + (vec![UserEvent::PageDown], "Scroll down".into()), + (vec![UserEvent::PageUp], "Scroll up".into()), + (vec![UserEvent::GoToTop], "Go to top".into()), + (vec![UserEvent::GoToBottom], "Go to bottom".into()), + ]; + user_command_helps.extend(user_command_view_toggle_helps); + let (user_command_key_lines, user_command_value_lines) = build_block_lines("User Command:", user_command_helps, color_theme, keybind); let key_lines = join_line_groups_with_empty(vec![ common_key_lines, @@ -258,6 +273,7 @@ fn build_lines(color_theme: &ColorTheme, keybind: &KeyBind) -> (Vec (Vec (Vec, String)>, color_theme: &ColorTheme, keybind: &KeyBind, ) -> (Vec>, Vec>) { @@ -284,7 +301,8 @@ fn build_block_lines( .add_modifier(Modifier::BOLD)]; let value_title_lines = vec![Line::from("")]; let key_binding_lines: Vec = helps - .iter() + .clone() + .into_iter() .map(|(events, _)| { join_span_groups_with_space( events @@ -295,8 +313,10 @@ fn build_block_lines( ) }) .collect(); - let value_binding_lines: Vec = - helps.iter().map(|(_, value)| Line::from(*value)).collect(); + let value_binding_lines: Vec = helps + .into_iter() + .map(|(_, value)| Line::raw(value)) + .collect(); key_lines.extend(key_title_lines); key_lines.extend(key_binding_lines); diff --git a/src/view/list.rs b/src/view/list.rs index 7fc3fe1..d5495ea 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -133,6 +133,9 @@ impl<'a> ListView<'a> { self.as_mut_list_state().start_search(); self.update_search_query(); } + UserEvent::UserCommandViewToggle(n) => { + self.tx.send(AppEvent::OpenUserCommand(n)); + } UserEvent::HelpToggle => { self.tx.send(AppEvent::OpenHelp); } diff --git a/src/view/user_command.rs b/src/view/user_command.rs new file mode 100644 index 0000000..ce96c22 --- /dev/null +++ b/src/view/user_command.rs @@ -0,0 +1,214 @@ +use ansi_to_tui::IntoText as _; +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Layout, Rect}, + text::Line, + widgets::Clear, + Frame, +}; + +use crate::{ + color::ColorTheme, + config::{CoreConfig, UiConfig}, + event::{AppEvent, Sender, UserEvent, UserEventWithCount}, + external::exec_user_command, + git::Commit, + protocol::ImageProtocol, + widget::{ + commit_list::{CommitList, CommitListState}, + commit_user_command::{CommitUserCommand, CommitUserCommandState}, + }, +}; + +#[derive(Debug)] +pub enum UserCommandViewBeforeView { + List, + Detail, +} + +#[derive(Debug)] +pub struct UserCommandView<'a> { + commit_list_state: Option>, + commit_user_command_state: CommitUserCommandState, + + user_command_number: usize, + user_command_output_lines: Vec>, + + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + image_protocol: ImageProtocol, + tx: Sender, + before_view: UserCommandViewBeforeView, + clear: bool, +} + +impl<'a> UserCommandView<'a> { + pub fn new( + commit_list_state: CommitListState<'a>, + commit: Commit, + user_command_number: usize, + view_area: Rect, + core_config: &'a CoreConfig, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + image_protocol: ImageProtocol, + tx: Sender, + before_view: UserCommandViewBeforeView, + ) -> UserCommandView<'a> { + let user_command_output_lines = build_user_command_output_lines( + &commit, + user_command_number, + view_area, + core_config, + ui_config, + ) + .unwrap_or_else(|err| { + tx.send(AppEvent::NotifyError(err)); + vec![] + }); + + UserCommandView { + commit_list_state: Some(commit_list_state), + commit_user_command_state: CommitUserCommandState::default(), + user_command_number, + user_command_output_lines, + ui_config, + color_theme, + image_protocol, + tx, + before_view, + clear: false, + } + } + + pub fn handle_event(&mut self, event_with_count: UserEventWithCount, _: KeyEvent) { + let event = event_with_count.event; + let count = event_with_count.count; + + match event { + UserEvent::NavigateDown => { + for _ in 0..count { + self.commit_user_command_state.scroll_down(); + } + } + UserEvent::NavigateUp => { + for _ in 0..count { + self.commit_user_command_state.scroll_up(); + } + } + UserEvent::GoToTop => { + self.commit_user_command_state.select_first(); + } + UserEvent::GoToBottom => { + self.commit_user_command_state.select_last(); + } + UserEvent::HelpToggle => { + self.tx.send(AppEvent::OpenHelp); + } + UserEvent::UserCommandViewToggle(n) => { + if n == self.user_command_number { + self.close(); + } else { + // switch to another user command + self.tx.send(AppEvent::OpenUserCommand(n)); + } + } + UserEvent::Cancel | UserEvent::Close => { + self.close(); + } + _ => {} + } + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let user_command_height = (area.height - 1).min(self.ui_config.user_command.height); + let [list_area, user_command_area] = + Layout::vertical([Constraint::Min(0), Constraint::Length(user_command_height)]) + .areas(area); + + let commit_list = CommitList::new(&self.ui_config.list, self.color_theme); + f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); + + let commit_user_command = + CommitUserCommand::new(&self.user_command_output_lines, self.color_theme); + f.render_stateful_widget( + commit_user_command, + user_command_area, + &mut self.commit_user_command_state, + ); + + if self.clear { + f.render_widget(Clear, user_command_area); + return; + } + + // clear the image area if needed + for y in user_command_area.top()..user_command_area.bottom() { + self.image_protocol.clear_line(y); + } + } +} + +impl<'a> UserCommandView<'a> { + pub fn take_list_state(&mut self) -> CommitListState<'a> { + self.commit_list_state.take().unwrap() + } + + fn as_mut_list_state(&mut self) -> &mut CommitListState<'a> { + self.commit_list_state.as_mut().unwrap() + } + + pub fn clear(&mut self) { + self.clear = true; + } + + pub fn before_view_is_list(&self) -> bool { + matches!(self.before_view, UserCommandViewBeforeView::List) + } + + fn close(&self) { + self.tx.send(AppEvent::ClearUserCommand); // hack: reset the rendering of the image area + self.tx.send(AppEvent::CloseUserCommand); + } +} + +fn build_user_command_output_lines<'a>( + commit: &Commit, + user_command_number: usize, + view_area: Rect, + core_config: &'a CoreConfig, + ui_config: &'a UiConfig, +) -> Result>, String> { + let command = core_config + .user_command + .commands + .get(&user_command_number.to_string()) + .ok_or_else(|| { + format!( + "No user command configured for number {}", + user_command_number + ) + })? + .commands + .iter() + .map(String::as_str) + .collect::>(); + let target_hash = commit.commit_hash.as_str(); + let parent_hash = commit + .parent_commit_hashes + .first() + .map(|c| c.as_str()) + .unwrap_or_default(); + + let area_width = view_area.width - 4; // minus the left and right padding + let area_height = (view_area.height - 1).min(ui_config.user_command.height) - 1; // minus the top border + + exec_user_command(&command, target_hash, parent_hash, area_width, area_height) + .and_then(|output| { + output + .into_text() + .map(|t| t.into_iter().collect()) + .map_err(|e| e.to_string()) + }) + .map_err(|err| format!("Failed to execute command: {}", err)) +} diff --git a/src/view/views.rs b/src/view/views.rs index 5819d3e..7320572 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -2,12 +2,18 @@ use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; use crate::{ color::ColorTheme, - config::UiConfig, + config::{CoreConfig, UiConfig}, event::{Sender, UserEventWithCount}, git::{Commit, FileChange, Ref}, keybind::KeyBind, protocol::ImageProtocol, - view::{detail::DetailView, help::HelpView, list::ListView, refs::RefsView}, + view::{ + detail::DetailView, + help::HelpView, + list::ListView, + refs::RefsView, + user_command::{UserCommandView, UserCommandViewBeforeView}, + }, widget::commit_list::CommitListState, }; @@ -17,6 +23,7 @@ pub enum View<'a> { Default, // dummy variant to make #[default] work List(Box>), Detail(Box>), + UserCommand(Box>), Refs(Box>), Help(Box>), } @@ -27,6 +34,7 @@ impl<'a> View<'a> { View::Default => {} View::List(view) => view.handle_event(event_with_count, key_event), View::Detail(view) => view.handle_event(event_with_count, key_event), + View::UserCommand(view) => view.handle_event(event_with_count, key_event), View::Refs(view) => view.handle_event(event_with_count, key_event), View::Help(view) => view.handle_event(event_with_count, key_event), } @@ -37,6 +45,7 @@ impl<'a> View<'a> { View::Default => {} View::List(view) => view.render(f, area), View::Detail(view) => view.render(f, area), + View::UserCommand(view) => view.render(f, area), View::Refs(view) => view.render(f, area), View::Help(view) => view.render(f, area), } @@ -78,6 +87,56 @@ impl<'a> View<'a> { ))) } + pub fn of_user_command_from_list( + commit_list_state: CommitListState<'a>, + commit: Commit, + user_command_number: usize, + view_area: Rect, + core_config: &'a CoreConfig, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + image_protocol: ImageProtocol, + tx: Sender, + ) -> Self { + View::UserCommand(Box::new(UserCommandView::new( + commit_list_state, + commit, + user_command_number, + view_area, + core_config, + ui_config, + color_theme, + image_protocol, + tx, + UserCommandViewBeforeView::List, + ))) + } + + pub fn of_user_command_from_detail( + commit_list_state: CommitListState<'a>, + commit: Commit, + user_command_number: usize, + view_area: Rect, + core_config: &'a CoreConfig, + ui_config: &'a UiConfig, + color_theme: &'a ColorTheme, + image_protocol: ImageProtocol, + tx: Sender, + ) -> Self { + View::UserCommand(Box::new(UserCommandView::new( + commit_list_state, + commit, + user_command_number, + view_area, + core_config, + ui_config, + color_theme, + image_protocol, + tx, + UserCommandViewBeforeView::Detail, + ))) + } + pub fn of_refs( commit_list_state: CommitListState<'a>, refs: Vec, @@ -100,6 +159,7 @@ impl<'a> View<'a> { image_protocol: ImageProtocol, tx: Sender, keybind: &'a KeyBind, + core_config: &'a CoreConfig, ) -> Self { View::Help(Box::new(HelpView::new( before, @@ -107,6 +167,7 @@ impl<'a> View<'a> { image_protocol, tx, keybind, + core_config, ))) } } diff --git a/src/widget.rs b/src/widget.rs index 72a0a07..4129d63 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,3 +1,4 @@ pub mod commit_detail; pub mod commit_list; +pub mod commit_user_command; pub mod ref_list; diff --git a/src/widget/commit_user_command.rs b/src/widget/commit_user_command.rs new file mode 100644 index 0000000..9be4bd0 --- /dev/null +++ b/src/widget/commit_user_command.rs @@ -0,0 +1,89 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::Line, + widgets::{Block, Borders, Padding, Paragraph, StatefulWidget, Widget}, +}; + +use crate::color::ColorTheme; + +#[derive(Debug, Default)] +pub struct CommitUserCommandState { + offset: usize, +} + +impl CommitUserCommandState { + pub fn scroll_down(&mut self) { + self.offset = self.offset.saturating_add(1); + } + + pub fn scroll_up(&mut self) { + self.offset = self.offset.saturating_sub(1); + } + + pub fn select_first(&mut self) { + self.offset = 0; + } + + pub fn select_last(&mut self) { + self.offset = usize::MAX; + } +} + +pub struct CommitUserCommand<'a> { + lines: &'a Vec>, + color_theme: &'a ColorTheme, +} + +impl<'a> CommitUserCommand<'a> { + pub fn new(lines: &'a Vec>, color_theme: &'a ColorTheme) -> Self { + Self { lines, color_theme } + } +} + +impl StatefulWidget for CommitUserCommand<'_> { + type State = CommitUserCommandState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let content_area_height = area.height as usize - 1; // minus the top border + self.update_state(state, self.lines.len(), content_area_height); + + self.render_user_command_lines(area, buf, state); + } +} + +impl CommitUserCommand<'_> { + fn render_user_command_lines( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut CommitUserCommandState, + ) { + let lines = self + .lines + .iter() + .skip(state.offset) + .take(area.height as usize - 1) + .cloned() + .collect::>(); + let paragraph = Paragraph::new(lines) + .style(Style::default().fg(self.color_theme.fg)) + .block( + Block::default() + .borders(Borders::TOP) + .style(Style::default().fg(self.color_theme.divider_fg)) + .padding(Padding::horizontal(2)), + ); + paragraph.render(area, buf); + } + + fn update_state( + &self, + state: &mut CommitUserCommandState, + line_count: usize, + area_height: usize, + ) { + state.offset = state.offset.min(line_count.saturating_sub(area_height)); + } +}