Skip to content

Commit cfc37ca

Browse files
authored
feat(zsh): Handle multi-line history selection (#4595)
1 parent af2a81d commit cfc37ca

File tree

2 files changed

+111
-6
lines changed

2 files changed

+111
-6
lines changed

shell/key-bindings.zsh

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,25 +128,52 @@ fi
128128

129129
# CTRL-R - Paste the selected command from history into the command line
130130
fzf-history-widget() {
131-
local selected
132-
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null
131+
local selected extracted_with_perl=0
132+
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_ksharrays extendedglob 2> /dev/null
133133
# Ensure the module is loaded if not already, and the required features, such
134134
# as the associative 'history' array, which maps event numbers to full history
135135
# lines, are set. Also, make sure Perl is installed for multi-line output.
136136
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
137137
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
138138
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
139-
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") \
139+
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") \
140140
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
141+
extracted_with_perl=1
141142
else
142143
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
143-
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") \
144+
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}") \
144145
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
145146
fi
146147
local ret=$?
148+
local -a cmds
149+
# Avoid leaking auto assigned values when using backreferences '(#b)'
150+
local -a mbegin mend match
147151
if [ -n "$selected" ]; then
148-
if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then
149-
zle vi-fetch-history -n $MATCH
152+
# Heuristic to check if the selected value is from history or a custom query
153+
if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) ||
154+
((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then
155+
# Split at newlines
156+
for line in ${(ps:\n:)selected}; do
157+
if (( extracted_with_perl )); then
158+
if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then
159+
(( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}")
160+
fi
161+
elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then
162+
# Avoid $history array: lags behind 'fc' on foreign commands (*)
163+
# https://zsh.org/mla/users/2024/msg00692.html
164+
# Push BUFFER onto stack; fetch and save history entry from BUFFER; restore
165+
zle .push-line
166+
zle vi-fetch-history -n ${match[1]}
167+
(( ${#BUFFER} )) && cmds+=("${BUFFER}")
168+
BUFFER=""
169+
zle .get-line
170+
fi
171+
done
172+
if (( ${#cmds[@]} )); then
173+
# Join by newline after stripping trailing newlines from each command
174+
BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"
175+
CURSOR=${#BUFFER}
176+
fi
150177
else # selected is a custom query, not from history
151178
LBUFFER="$selected"
152179
fi

test/test_shell_integration.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,84 @@ def test_complete_quoted_command
462462
tmux.send_keys 'C-c'
463463
end
464464
end
465+
466+
# Helper function to run test with Perl and again with Awk
467+
def self.test_perl_and_awk(name, &block)
468+
define_method("test_#{name}") do
469+
instance_eval(&block)
470+
end
471+
472+
define_method("test_#{name}_awk") do
473+
tmux.send_keys "unset 'commands[perl]'", :Enter
474+
tmux.prepare
475+
# Verify perl is actually unset (0 = not found)
476+
tmux.send_keys 'echo ${+commands[perl]}', :Enter
477+
tmux.until { |lines| assert_equal '0', lines[-1] }
478+
tmux.prepare
479+
instance_eval(&block)
480+
end
481+
end
482+
483+
def prepare_ctrl_r_test
484+
tmux.send_keys ':', :Enter
485+
tmux.send_keys 'echo match-collision', :Enter
486+
tmux.prepare
487+
tmux.send_keys 'echo "line 1', :Enter, '2 line 2"', :Enter
488+
tmux.prepare
489+
tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter
490+
tmux.prepare
491+
tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter
492+
tmux.prepare
493+
tmux.send_keys 'echo "trailing_space "', :Enter
494+
tmux.prepare
495+
tmux.send_keys 'cat <<EOF | wc -c', :Enter, 'qux thud', :Enter, 'EOF', :Enter
496+
tmux.prepare
497+
tmux.send_keys 'C-l', 'C-r'
498+
end
499+
500+
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
501+
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
502+
prepare_ctrl_r_test
503+
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
504+
tmux.send_keys '1 foobar'
505+
tmux.until { |lines| assert_equal 0, lines.match_count }
506+
tmux.send_keys :Enter
507+
tmux.until { |lines| assert_equal '1 foobar', lines[-1] }
508+
end
509+
510+
test_perl_and_awk 'ctrl_r_multiline_index_collision' do
511+
# Leading number in multi-line history content is not confused with index
512+
prepare_ctrl_r_test
513+
tmux.send_keys "'line 1"
514+
tmux.until { |lines| assert_equal 1, lines.match_count }
515+
tmux.send_keys :Enter
516+
tmux.until do |lines|
517+
assert_equal ['echo "line 1', '2 line 2"'], lines[-2..]
518+
end
519+
end
520+
521+
test_perl_and_awk 'ctrl_r_multi_selection' do
522+
prepare_ctrl_r_test
523+
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
524+
tmux.send_keys :BTab, :BTab, :BTab
525+
tmux.until { |lines| assert_includes lines[-2], '(3)' }
526+
tmux.send_keys :Enter
527+
tmux.until do |lines|
528+
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF', 'echo "trailing_space "', 'echo "bar', 'foo"'], lines[-6..]
529+
end
530+
end
531+
532+
test_perl_and_awk 'ctrl_r_no_multi_selection' do
533+
set_var('FZF_CTRL_R_OPTS', '--no-multi')
534+
prepare_ctrl_r_test
535+
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
536+
tmux.send_keys :BTab, :BTab, :BTab
537+
tmux.until { |lines| refute_includes lines[-2], '(3)' }
538+
tmux.send_keys :Enter
539+
tmux.until do |lines|
540+
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF'], lines[-3..]
541+
end
542+
end
465543
end
466544

467545
class TestFish < TestBase

0 commit comments

Comments
 (0)