From cfc37caabcf97f43595b5a06d60d6c7d36ba43e9 Mon Sep 17 00:00:00 2001 From: LangLangBart <92653266+LangLangBart@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:32:55 +0100 Subject: [PATCH] feat(zsh): Handle multi-line history selection (#4595) --- shell/key-bindings.zsh | 39 ++++++++++++++--- test/test_shell_integration.rb | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/shell/key-bindings.zsh b/shell/key-bindings.zsh index 565529fd..6b68eed7 100644 --- a/shell/key-bindings.zsh +++ b/shell/key-bindings.zsh @@ -128,25 +128,52 @@ fi # CTRL-R - Paste the selected command from history into the command line fzf-history-widget() { - local selected - setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null + local selected extracted_with_perl=0 + setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_ksharrays extendedglob 2> /dev/null # Ensure the module is loaded if not already, and the required features, such # as the associative 'history' array, which maps event numbers to full history # lines, are set. Also, make sure Perl is installed for multi-line output. if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then selected="$(printf '%s\t%s\000' "${(kv)history[@]}" | perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' | - 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-} --query=${(qqq)LBUFFER} +m --read0") \ + FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \ FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" + extracted_with_perl=1 else selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | - 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-} --query=${(qqq)LBUFFER} +m") \ + FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \ FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" fi local ret=$? + local -a cmds + # Avoid leaking auto assigned values when using backreferences '(#b)' + local -a mbegin mend match if [ -n "$selected" ]; then - if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then - zle vi-fetch-history -n $MATCH + # Heuristic to check if the selected value is from history or a custom query + if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) || + ((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then + # Split at newlines + for line in ${(ps:\n:)selected}; do + if (( extracted_with_perl )); then + if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then + (( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}") + fi + elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then + # Avoid $history array: lags behind 'fc' on foreign commands (*) + # https://zsh.org/mla/users/2024/msg00692.html + # Push BUFFER onto stack; fetch and save history entry from BUFFER; restore + zle .push-line + zle vi-fetch-history -n ${match[1]} + (( ${#BUFFER} )) && cmds+=("${BUFFER}") + BUFFER="" + zle .get-line + fi + done + if (( ${#cmds[@]} )); then + # Join by newline after stripping trailing newlines from each command + BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}" + CURSOR=${#BUFFER} + fi else # selected is a custom query, not from history LBUFFER="$selected" fi diff --git a/test/test_shell_integration.rb b/test/test_shell_integration.rb index 239dc258..e98b7017 100644 --- a/test/test_shell_integration.rb +++ b/test/test_shell_integration.rb @@ -462,6 +462,84 @@ class TestZsh < TestBase tmux.send_keys 'C-c' end end + + # Helper function to run test with Perl and again with Awk + def self.test_perl_and_awk(name, &block) + define_method("test_#{name}") do + instance_eval(&block) + end + + define_method("test_#{name}_awk") do + tmux.send_keys "unset 'commands[perl]'", :Enter + tmux.prepare + # Verify perl is actually unset (0 = not found) + tmux.send_keys 'echo ${+commands[perl]}', :Enter + tmux.until { |lines| assert_equal '0', lines[-1] } + tmux.prepare + instance_eval(&block) + end + end + + def prepare_ctrl_r_test + tmux.send_keys ':', :Enter + tmux.send_keys 'echo match-collision', :Enter + tmux.prepare + tmux.send_keys 'echo "line 1', :Enter, '2 line 2"', :Enter + tmux.prepare + tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter + tmux.prepare + tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter + tmux.prepare + tmux.send_keys 'echo "trailing_space "', :Enter + tmux.prepare + tmux.send_keys 'cat <, 0 } + tmux.send_keys '1 foobar' + tmux.until { |lines| assert_equal 0, lines.match_count } + tmux.send_keys :Enter + tmux.until { |lines| assert_equal '1 foobar', lines[-1] } + end + + test_perl_and_awk 'ctrl_r_multiline_index_collision' do + # Leading number in multi-line history content is not confused with index + prepare_ctrl_r_test + tmux.send_keys "'line 1" + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.send_keys :Enter + tmux.until do |lines| + assert_equal ['echo "line 1', '2 line 2"'], lines[-2..] + end + end + + test_perl_and_awk 'ctrl_r_multi_selection' do + prepare_ctrl_r_test + tmux.until { |lines| assert_operator lines.match_count, :>, 0 } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| assert_includes lines[-2], '(3)' } + tmux.send_keys :Enter + tmux.until do |lines| + assert_equal ['cat <, 0 } + tmux.send_keys :BTab, :BTab, :BTab + tmux.until { |lines| refute_includes lines[-2], '(3)' } + tmux.send_keys :Enter + tmux.until do |lines| + assert_equal ['cat <