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));
+ }
+}
]