From 76efddd7182ee45e0c108e19665e8984b6a113b6 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 21 Mar 2026 21:10:43 +0900 Subject: [PATCH] bash: shift-delete to delete history entries during CTRL-R Close #4715 --- CHANGELOG.md | 4 +++- shell/key-bindings.bash | 30 ++++++++++++++++++----- test/test_shell_integration.rb | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78395c35..07f90e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,9 @@ CHANGELOG ``` - Improved the cache structure, reducing memory footprint per entry by 86x. - With the reduced per-entry cost, the cache now has broader coverage. -- fish: Improved command history (CTRL-R) (#4703) (@bitraid) +- Shell integration improvements + - bash: Press `shift-delete` to delete history entries during CTRL-R search (#4715) + - fish: Improved command history (CTRL-R) (#4703) (@bitraid) - `GET /` HTTP endpoint now includes `positions` field in each match entry, providing the indices of matched characters for external highlighting (#4726) - Bug fixes - `--walker=follow` no longer follows symlinks whose target is an ancestor of the walker root, avoiding severe resource exhaustion when a symlink points outside the tree (e.g. Wine's `z:` → `/`) (#4710) diff --git a/shell/key-bindings.bash b/shell/key-bindings.bash index 971f6003..70726d94 100644 --- a/shell/key-bindings.bash +++ b/shell/key-bindings.bash @@ -77,17 +77,31 @@ __fzf_cd__() { ) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)" } +__fzf_history_delete() { + [[ -s $1 ]] || return + + local offsets + offsets=($(sort -rnu "$1")) + for offset in "${offsets[@]}"; do + builtin history -d "$offset" + done +} + if command -v perl > /dev/null; then __fzf_history__() { - local output script + local output script deletefile + deletefile=$(mktemp) script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++' output=$( set +o pipefail builtin fc -lnr -2147483648 | last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" | - FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \ + FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(echo {1} >> \"$deletefile\")+exclude' ${FZF_CTRL_R_OPTS-} +m --read0") \ FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE" - ) || return + ) + __fzf_history_delete "$deletefile" + command rm -f "$deletefile" + [[ -n $output ]] || return READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output") if [[ -z $READLINE_POINT ]]; then echo "$READLINE_LINE" @@ -97,7 +111,8 @@ if command -v perl > /dev/null; then } else # awk - fallback for POSIX systems __fzf_history__() { - local output script + local output script deletefile + deletefile=$(mktemp) [[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } } NR==1 { b = substr($0, 2); next } @@ -108,9 +123,12 @@ else # awk - fallback for POSIX systems set +o pipefail builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '$'\n' )* ; ::= [^\n]* ( $'\n' )* __fzf_exec_awk "$script" | # ( $'\t'$'\000' )* - FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \ + FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(echo {1} >> \"$deletefile\")+exclude' ${FZF_CTRL_R_OPTS-} +m --read0") \ FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE" - ) || return + ) + __fzf_history_delete "$deletefile" + command rm -f "$deletefile" + [[ -n $output ]] || return READLINE_LINE=${output#*$'\t'} if [[ -z $READLINE_POINT ]]; then echo "$READLINE_LINE" diff --git a/test/test_shell_integration.rb b/test/test_shell_integration.rb index e907a002..35e27d06 100644 --- a/test/test_shell_integration.rb +++ b/test/test_shell_integration.rb @@ -832,6 +832,50 @@ class TestBash < TestBase tmux.prepare end + def test_ctrl_r_delete + tmux.prepare + tmux.send_keys 'echo to-keep', :Enter + tmux.prepare + tmux.send_keys 'echo to-delete-1', :Enter + tmux.prepare + tmux.send_keys 'echo to-delete-2', :Enter + tmux.prepare + tmux.send_keys 'echo another-keeper', :Enter + tmux.prepare + + # Open Ctrl-R and delete two entries + tmux.send_keys 'C-r' + tmux.until { |lines| assert_operator lines.match_count, :>, 0 } + tmux.send_keys 'to-delete' + tmux.until { |lines| assert_equal 2, lines.match_count } + # Delete the first match + tmux.send_keys 'S-Delete' + tmux.until { |lines| assert_equal 1, lines.match_count } + # Delete the second match + tmux.send_keys 'S-Delete' + tmux.until { |lines| assert_equal 0, lines.match_count } + # Exit without selecting + tmux.send_keys :Escape + tmux.prepare + + # Verify deleted entries are gone from history + tmux.send_keys 'C-r' + tmux.until { |lines| assert_operator lines.match_count, :>, 0 } + tmux.send_keys 'to-delete' + tmux.until { |lines| assert_equal 0, lines.match_count } + tmux.send_keys :Escape + tmux.prepare + + # Verify kept entries are still there + tmux.send_keys 'C-r' + tmux.until { |lines| assert_operator lines.match_count, :>, 0 } + tmux.send_keys 'to-keep' + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.send_keys :Enter + tmux.until { |lines| assert_equal 'echo to-keep', lines[-1] } + tmux.send_keys 'C-c' + end + def test_dynamic_completion_loader tmux.paste 'touch /tmp/foo; _fzf_completion_loader=1' tmux.paste '_completion_loader() { complete -o default fake; }'