From 57421d142510b6994f2ac38820b5c9f39d01ae4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20W=C3=B6hler?= Date: Sat, 4 May 2024 10:39:10 +0200 Subject: [PATCH 1/5] Added "accept-to-edit" for (Back)Space / Home / Left The above keys edit the selected command (as with hitting Tab), but like if using them in the shell. They only trigger when the search input is currently empty, and must be enabled individually with the respective boolean config options: * exit_with_backspace: right-trim command and remove last character * exit_with_space: right-trim and append a space character * exit_with_home: start editing at position 0 * exit_with_cursor_left: right-trim and start editing at last character * exit_positions_cursor: enable cursor positioning for Home and Left keys (bash-only) Updated bash init to support cursor positioning. For other shells, Home and Left keys will behave like Tab. Resolves #1906 --- crates/atuin-client/src/settings.rs | 11 +++ .../atuin/src/command/client/search/cursor.rs | 5 + .../src/command/client/search/interactive.rs | 96 +++++++++++++++++-- crates/atuin/src/shell/atuin.bash | 12 +++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 675fb307b2e..4cb3ebe3db9 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -413,6 +413,12 @@ pub struct Settings { pub enter_accept: bool, pub smart_sort: bool, + pub exit_with_backspace: bool, + pub exit_with_space: bool, + pub exit_with_home: bool, + pub exit_with_cursor_left: bool, + pub exit_positions_cursor: bool, + #[serde(default)] pub stats: Stats, @@ -674,6 +680,11 @@ impl Settings { .set_default("keymap_mode_shell", "auto")? .set_default("keymap_cursor", HashMap::::new())? .set_default("smart_sort", false)? + .set_default("exit_with_backspace", false)? + .set_default("exit_with_space", false)? + .set_default("exit_with_home", false)? + .set_default("exit_with_cursor_left", false)? + .set_default("exit_positions_cursor", false)? .set_default("store_failed", true)? .set_default( "prefers_reduced_motion", diff --git a/crates/atuin/src/command/client/search/cursor.rs b/crates/atuin/src/command/client/search/cursor.rs index 2bce4f3794e..06795388890 100644 --- a/crates/atuin/src/command/client/search/cursor.rs +++ b/crates/atuin/src/command/client/search/cursor.rs @@ -101,6 +101,11 @@ impl Cursor { self.source } + /// Checks if there's currently no input + pub fn is_empty(&self) -> bool { + self.source.is_empty() + } + /// Returns the string before the cursor pub fn substring(&self) -> &str { &self.source[..self.index] diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 9b41950e2a4..1e7ab405501 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -49,7 +49,7 @@ use ratatui::{ const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; pub enum InputAction { - Accept(usize), + Accept(usize, InputAcceptKind), Copy(usize), Delete(usize), ReturnOriginal, @@ -57,6 +57,12 @@ pub enum InputAction { Continue, Redraw, } +pub enum InputAcceptKind { + Default, + Backspace, + Space, + Offset(i64), +} #[allow(clippy::struct_field_names)] pub struct State { @@ -214,7 +220,10 @@ impl State { KeyCode::Char('c' | 'g') if ctrl => Some(InputAction::ReturnOriginal), KeyCode::Esc if esc_allow_exit => Some(Self::handle_key_exit(settings)), KeyCode::Char('[') if ctrl && esc_allow_exit => Some(Self::handle_key_exit(settings)), - KeyCode::Tab => Some(InputAction::Accept(self.results_state.selected())), + KeyCode::Tab => Some(InputAction::Accept( + self.results_state.selected(), + InputAcceptKind::Default, + )), KeyCode::Char('o') if ctrl => { self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); @@ -273,7 +282,7 @@ impl State { if settings.enter_accept { self.accept = true; } - InputAction::Accept(self.results_state.selected()) + InputAction::Accept(self.results_state.selected(), InputAcceptKind::Default) } #[allow(clippy::too_many_lines)] @@ -374,7 +383,10 @@ impl State { } KeyCode::Char(c @ '1'..='9') if modfr => { return c.to_digit(10).map_or(InputAction::Continue, |c| { - InputAction::Accept(self.results_state.selected() + c as usize) + InputAction::Accept( + self.results_state.selected() + c as usize, + InputAcceptKind::Default, + ) }) } KeyCode::Left if ctrl => self @@ -386,6 +398,12 @@ impl State { .input .prev_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Left => { + if settings.exit_with_cursor_left && self.search.input.is_empty() { + return InputAction::Accept( + self.results_state.selected(), + InputAcceptKind::Offset(-1), + ); + } self.search.input.left(); } KeyCode::Char('b') if ctrl => { @@ -401,7 +419,15 @@ impl State { .next_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Right => self.search.input.right(), KeyCode::Char('f') if ctrl => self.search.input.right(), - KeyCode::Home => self.search.input.start(), + KeyCode::Home => { + if settings.exit_with_home && self.search.input.is_empty() { + return InputAction::Accept( + self.results_state.selected(), + InputAcceptKind::Offset(0), + ); + } + self.search.input.start(); + } KeyCode::Char('e') if ctrl => self.search.input.end(), KeyCode::End => self.search.input.end(), KeyCode::Backspace if ctrl => self @@ -409,6 +435,12 @@ impl State { .input .remove_prev_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Backspace => { + if settings.exit_with_backspace && self.search.input.is_empty() { + return InputAction::Accept( + self.results_state.selected(), + InputAcceptKind::Backspace, + ); + } self.search.input.back(); } KeyCode::Char('h' | '?') if ctrl => { @@ -494,6 +526,15 @@ impl State { KeyCode::Char('l') if ctrl => { return InputAction::Redraw; } + KeyCode::Char(' ') => { + if settings.exit_with_space && self.search.input.is_empty() { + return InputAction::Accept( + self.results_state.selected(), + InputAcceptKind::Space, + ); + } + self.search.input.insert(' '); + } KeyCode::Char(c) => { self.search.input.insert(c); } @@ -1134,12 +1175,53 @@ pub async fn history( } match result { - InputAction::Accept(index) if index < results.len() => { + InputAction::Accept(index, kind) if index < results.len() => { let mut command = results.swap_remove(index).command; if accept && (utils::is_zsh() || utils::is_fish() || utils::is_bash() || utils::is_xonsh()) { command = String::from("__atuin_accept__:") + &command; + } else { + match kind { + InputAcceptKind::Default => {} + InputAcceptKind::Backspace => { + // trim the end of the selected command *and* remove the + // last character, as tab-completion might have added + // trailing whitespace (which can't be seen in the UI) + command = command.trim_end().to_string(); + command.pop(); + } + InputAcceptKind::Space => { + // trim the end and add one space character + command = command.trim_end().to_string() + " "; + } + InputAcceptKind::Offset(offset) => { + if settings.exit_positions_cursor { + // for negative offsets (move left), remove trailing whitespace + if offset < 0 { + command = command.trim_end().to_string(); + } + #[allow(clippy::cast_possible_wrap)] + let length = command.len() as i64; + let position = if offset >= 0 { + // start editing at specified position, + // counting from the start + length.min(offset) + } else { + // start editing at specified position, + // counting from the (trimmed) end + 0.max(length - offset.abs()) + }; + // Bash's READLINE_POINT is required or positioning the cursor; we still allow + // to go back to the shell even when not using bash (if user's configure the + // respective options), but the actual positioning is only possible with bash + // (and only implemented in the bash shell integration) + if utils::is_bash() { + command = format!("__atuin_edit_at__:{position}:{command}"); + } + } + } + } } // index is in bounds so we return that entry @@ -1151,7 +1233,7 @@ pub async fn history( set_clipboard(cmd); Ok(String::new()) } - InputAction::ReturnQuery | InputAction::Accept(_) => { + InputAction::ReturnQuery | InputAction::Accept(_, _) => { // Either: // * index == RETURN_QUERY, in which case we should return the input // * out of bounds -> usually implies no selected entry so we return the input diff --git a/crates/atuin/src/shell/atuin.bash b/crates/atuin/src/shell/atuin.bash index 8eda0a6f3b8..7f8cf8c4baa 100644 --- a/crates/atuin/src/shell/atuin.bash +++ b/crates/atuin/src/shell/atuin.bash @@ -252,6 +252,7 @@ __atuin_history() { [[ $__atuin_output ]] || return 0 if [[ $__atuin_output == __atuin_accept__:* ]]; then + # Execute selected command __atuin_output=${__atuin_output#__atuin_accept__:} if [[ ${BLE_ATTACHED-} ]]; then @@ -263,6 +264,17 @@ __atuin_history() { READLINE_LINE="" READLINE_POINT=${#READLINE_LINE} + elif [[ $__atuin_output == __atuin_edit_at__:* ]]; then + # Edit selected command at specified cursor position + __atuin_output=${__atuin_output#__atuin_edit_at__:} + local __atuin_edit_at_rx='^([0-9]+):(.*)$' + if [[ $__atuin_output =~ $__atuin_edit_at_rx ]]; then + READLINE_LINE="${BASH_REMATCH[2]}" + READLINE_POINT=${BASH_REMATCH[1]} + else + READLINE_LINE=$__atuin_output + READLINE_POINT=${#READLINE_LINE} + fi else READLINE_LINE=$__atuin_output READLINE_POINT=${#READLINE_LINE} From 79e26f6b3ef3cf04d38d250d2909150176f61549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20W=C3=B6hler?= Date: Mon, 13 May 2024 20:59:13 +0200 Subject: [PATCH 2/5] fixed typo Co-authored-by: Koichi Murase --- crates/atuin/src/command/client/search/interactive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 1e7ab405501..832f22508c2 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -1212,7 +1212,7 @@ pub async fn history( // counting from the (trimmed) end 0.max(length - offset.abs()) }; - // Bash's READLINE_POINT is required or positioning the cursor; we still allow + // Bash's READLINE_POINT is required for positioning the cursor; we still allow // to go back to the shell even when not using bash (if user's configure the // respective options), but the actual positioning is only possible with bash // (and only implemented in the bash shell integration) From 50c1d76a7bb3c1d437108549d9a0f982ac2bc44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20W=C3=B6hler?= Date: Mon, 13 May 2024 21:01:14 +0200 Subject: [PATCH 3/5] simplified "edit at position" handling for bash Co-authored-by: Koichi Murase --- crates/atuin/src/shell/atuin.bash | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/atuin/src/shell/atuin.bash b/crates/atuin/src/shell/atuin.bash index 7f8cf8c4baa..c01a74bda36 100644 --- a/crates/atuin/src/shell/atuin.bash +++ b/crates/atuin/src/shell/atuin.bash @@ -264,17 +264,10 @@ __atuin_history() { READLINE_LINE="" READLINE_POINT=${#READLINE_LINE} - elif [[ $__atuin_output == __atuin_edit_at__:* ]]; then + elif [[ $__atuin_output == $__atuin_edit_at_rx ]]; then # Edit selected command at specified cursor position - __atuin_output=${__atuin_output#__atuin_edit_at__:} - local __atuin_edit_at_rx='^([0-9]+):(.*)$' - if [[ $__atuin_output =~ $__atuin_edit_at_rx ]]; then - READLINE_LINE="${BASH_REMATCH[2]}" - READLINE_POINT=${BASH_REMATCH[1]} - else - READLINE_LINE=$__atuin_output - READLINE_POINT=${#READLINE_LINE} - fi + READLINE_LINE="${BASH_REMATCH[2]}" + READLINE_POINT=${BASH_REMATCH[1]} else READLINE_LINE=$__atuin_output READLINE_POINT=${#READLINE_LINE} From 9617f1bab2d87f7562b8ad15f61836efc46a138e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20W=C3=B6hler?= Date: Mon, 13 May 2024 22:17:43 +0200 Subject: [PATCH 4/5] enabled "edit at position" for both bash and zsh --- crates/atuin/src/command/client/search/interactive.rs | 11 ++++++----- crates/atuin/src/shell/atuin.bash | 3 ++- crates/atuin/src/shell/atuin.zsh | 6 ++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 832f22508c2..8140c407ab0 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -1212,11 +1212,12 @@ pub async fn history( // counting from the (trimmed) end 0.max(length - offset.abs()) }; - // Bash's READLINE_POINT is required for positioning the cursor; we still allow - // to go back to the shell even when not using bash (if user's configure the - // respective options), but the actual positioning is only possible with bash - // (and only implemented in the bash shell integration) - if utils::is_bash() { + // One of bash's READLINE_POINT or zsh's LBUFFER/RBUFFER is required + // for positioning the cursor; we still allow to go back to the shell + // even when not using these shells (if users configure the respective + // options), but the actual positioning is only possible with bash/zsh + // (and is only implemented in the bash and zsh shell integrations) + if utils::is_bash() || utils::is_zsh() { command = format!("__atuin_edit_at__:{position}:{command}"); } } diff --git a/crates/atuin/src/shell/atuin.bash b/crates/atuin/src/shell/atuin.bash index c01a74bda36..26d6c8ddae9 100644 --- a/crates/atuin/src/shell/atuin.bash +++ b/crates/atuin/src/shell/atuin.bash @@ -251,6 +251,7 @@ __atuin_history() { # We do nothing when the search is canceled. [[ $__atuin_output ]] || return 0 + local __atuin_edit_at_rx='^__atuin_edit_at__:([0-9]+):(.*)$' if [[ $__atuin_output == __atuin_accept__:* ]]; then # Execute selected command __atuin_output=${__atuin_output#__atuin_accept__:} @@ -264,7 +265,7 @@ __atuin_history() { READLINE_LINE="" READLINE_POINT=${#READLINE_LINE} - elif [[ $__atuin_output == $__atuin_edit_at_rx ]]; then + elif [[ $__atuin_output =~ $__atuin_edit_at_rx ]]; then # Edit selected command at specified cursor position READLINE_LINE="${BASH_REMATCH[2]}" READLINE_POINT=${BASH_REMATCH[1]} diff --git a/crates/atuin/src/shell/atuin.zsh b/crates/atuin/src/shell/atuin.zsh index d580f7044ca..81aacded725 100644 --- a/crates/atuin/src/shell/atuin.zsh +++ b/crates/atuin/src/shell/atuin.zsh @@ -68,6 +68,12 @@ _atuin_search() { then LBUFFER=${LBUFFER#__atuin_accept__:} zle accept-line + elif [[ $LBUFFER =~ ^__atuin_edit_at__:([0-9]+):(.*)$ ]] + then + local POS=${match[1]} + local LINE=${match[2]} + LBUFFER="${LINE[0,${POS}]}" + RBUFFER="${LINE[$((POS+1)),${#LINE}]}" fi fi } From 3a7b1361f09a3c1d9b5bcdcc4906d2a9100c706b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20W=C3=B6hler?= Date: Wed, 15 May 2024 07:36:19 +0200 Subject: [PATCH 5/5] address shellcheck false positive --- crates/atuin/src/shell/atuin.zsh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/atuin/src/shell/atuin.zsh b/crates/atuin/src/shell/atuin.zsh index 81aacded725..1f1cfe88388 100644 --- a/crates/atuin/src/shell/atuin.zsh +++ b/crates/atuin/src/shell/atuin.zsh @@ -70,7 +70,9 @@ _atuin_search() { zle accept-line elif [[ $LBUFFER =~ ^__atuin_edit_at__:([0-9]+):(.*)$ ]] then + # shellcheck disable=SC2154 # $match array contains the regexp groups local POS=${match[1]} + # shellcheck disable=SC2154 # $match array contains the regexp groups local LINE=${match[2]} LBUFFER="${LINE[0,${POS}]}" RBUFFER="${LINE[$((POS+1)),${#LINE}]}"