Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package fzf for openSUSE:Factory checked in at 2026-10-21 20:57:22 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/fzf (Old) and /work/SRC/openSUSE:Factory/.fzf.new.2252680 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "fzf" Wed Oct 21 20:57:22 2026 rev:87 rq:1386650 version:0.74.5 Changes: -------- --- /work/SRC/openSUSE:Factory/fzf/fzf.changes 2026-09-21 12:21:41.510461242 +0200 +++ /work/SRC/openSUSE:Factory/.fzf.new.2252680/fzf.changes 2026-10-21 20:59:02.672899660 +0200 @@ -1,0 +2,29 @@ +Tue Oct 20 05:31:56 UTC 2026 - Kyle Scheuing <[email protected]> + +- Update to version 0.74.5: + * Fixed --popup unzooming the window when opened over a zoomed pane on tmux + 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari) + - On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a + floating pane is created over a zoomed window + * Fixed --gap-line cutting a grapheme cluster when filling the last cells of + the line (#4920) + * Fixed an escape sequence fzf does not recognize being partly consumed, + which leaked the rest into the query (#4926) + * Fixed a key lost when it arrived in the same read as an escape sequence + * Fixed focus event not triggered after reload when the new item is at the + same position as the previous one (#4930) + - With --id-nth, the event is not triggered if the focus moves directly + from an item to another item with the same key + - A focus binding that runs reload now triggers itself again after each + reload, unless --id-nth is set and the key does not change + * Security fixes + - fzf-tmux no longer evaluates an attached layout size (e.g. -l50) via bash + arithmetic, which could run commands or inject tmux flags + (GHSA-w6x2-f5m4-xw82) + - zsh completion now creates its FIFO in a private mktemp -d directory + instead of a predictable /tmp path (GHSA-7rj6-7pqx-v7rm) + - Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K + (Researchers) of Innspark Solutions + + +------------------------------------------------------------------- Old: ---- fzf-0.74.4.tar.gz New: ---- fzf-0.74.5.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ fzf.spec ++++++ --- /var/tmp/diff_new_pack.n7DG4K/_old 2026-10-21 20:59:03.601938608 +0200 +++ /var/tmp/diff_new_pack.n7DG4K/_new 2026-10-21 20:59:03.603938692 +0200 @@ -19,7 +19,7 @@ %global _lto_cflags %{nil} Name: fzf -Version: 0.74.4 +Version: 0.74.5 Release: 0 Summary: A command-line fuzzy finder License: MIT ++++++ fzf-0.74.4.tar.gz -> fzf-0.74.5.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/.github/ISSUE_TEMPLATE/issue_template.yml new/fzf-0.74.5/.github/ISSUE_TEMPLATE/issue_template.yml --- old/fzf-0.74.4/.github/ISSUE_TEMPLATE/issue_template.yml 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/.github/ISSUE_TEMPLATE/issue_template.yml 2026-10-11 04:29:16.000000000 +0200 @@ -3,10 +3,6 @@ description: Report a problem or bug related to fzf to help us improve body: - - type: markdown - attributes: - value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED - - type: checkboxes attributes: label: Checklist @@ -32,7 +28,6 @@ - label: Linux - label: macOS - label: Windows - - label: Etc. - type: checkboxes attributes: @@ -41,9 +36,23 @@ - label: bash - label: zsh - label: fish + - label: nushell + - label: PowerShell - type: textarea attributes: label: Problem / Steps to reproduce validations: required: true + + - type: textarea + attributes: + label: How did you run into this? + description: | + What were you actually trying to do? Include the command line or + configuration from your real setup. + + If you did not hit this in actual use, say how you found it + (reading the code, automated analysis, etc.). + validations: + required: true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/CHANGELOG.md new/fzf-0.74.5/CHANGELOG.md --- old/fzf-0.74.4/CHANGELOG.md 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/CHANGELOG.md 2026-10-11 04:29:16.000000000 +0200 @@ -1,6 +1,21 @@ CHANGELOG ========= +0.74.5 +------ +- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari) + - On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window +- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920) +- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926) +- Fixed a key lost when it arrived in the same read as an escape sequence +- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930) + - With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key + - A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change +- Security fixes + - fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82) + - zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm) + - Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions + 0.74.4 ------ - Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/Makefile new/fzf-0.74.5/Makefile --- old/fzf-0.74.4/Makefile 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/Makefile 2026-10-11 04:29:16.000000000 +0200 @@ -94,8 +94,12 @@ github.com/junegunn/fzf/src/tui \ github.com/junegunn/fzf/src/util +# Run in a separate tmux server without user config, like CI (80x24) itest: - ruby test/runner.rb + @dir=$$(mktemp -d) && \ + env -u TMUX TMUX_TMPDIR=$$dir sh -c \ + 'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \ + s=$$?; rm -rf $$dir; exit $$s # Actively fuzz the matcher fast paths against the general algorithm. # Go fuzzes one target at a time, so iterate. Override duration with diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/RELEASE.md new/fzf-0.74.5/RELEASE.md --- old/fzf-0.74.4/RELEASE.md 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/RELEASE.md 2026-10-11 04:29:16.000000000 +0200 @@ -7,7 +7,11 @@ ## Steps -1. Update version in the following files and commit on `master`: +1. Sign in at https://developer.apple.com/account and accept any pending + agreements (e.g. an updated Apple Developer Program License Agreement). + Otherwise notarization fails. + +2. Update version in the following files and commit on `master`: - `CHANGELOG.md` - `main.go` - `install` @@ -15,10 +19,10 @@ - `man/man1/fzf.1` - `man/man1/fzf-tmux.1` -2. Verify file consistency, sign the tag, and push the tag. +3. Verify file consistency, sign the tag, and push the tag. ```sh - make tag VERSION=0.74.4 + make tag VERSION=0.74.5 ``` `make tag` runs `prerelease` first (checks that the version @@ -29,10 +33,10 @@ old version, so `/master/install` keeps resolving against existing binaries during the publish window. -3. The workflow fires on the tag push and pauses on the `release` +4. The workflow fires on the tag push and pauses on the `release` environment gate. Approve it in the Actions tab to release. -4. After the GitHub release is published, fast-forward `master`: +5. After the GitHub release is published, fast-forward `master`: ```sh git push origin master diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/bin/fzf-tmux new/fzf-0.74.5/bin/fzf-tmux --- old/fzf-0.74.4/bin/fzf-tmux 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/bin/fzf-tmux 2026-10-11 04:29:16.000000000 +0200 @@ -78,6 +78,7 @@ fi if [[ ${#arg} -gt 2 ]]; then size="${arg:2}" + [[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue else if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then size="$1" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/install new/fzf-0.74.5/install --- old/fzf-0.74.4/install 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/install 2026-10-11 04:29:16.000000000 +0200 @@ -2,7 +2,7 @@ set -u -version=0.74.4 +version=0.74.5 auto_completion= key_bindings= update_config=2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/install.ps1 new/fzf-0.74.5/install.ps1 --- old/fzf-0.74.4/install.ps1 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/install.ps1 2026-10-11 04:29:16.000000000 +0200 @@ -1,4 +1,4 @@ -$version="0.74.4" +$version="0.74.5" $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/man/man1/fzf-tmux.1 new/fzf-0.74.5/man/man1/fzf-tmux.1 --- old/fzf-0.74.4/man/man1/fzf-tmux.1 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/man/man1/fzf-tmux.1 2026-10-11 04:29:16.000000000 +0200 @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. .. -.TH fzf\-tmux 1 "Sep 2026" "fzf 0.74.4" "fzf\-tmux - open fzf in tmux split pane" +.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane" .SH NAME fzf\-tmux - open fzf in tmux split pane diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/man/man1/fzf.1 new/fzf-0.74.5/man/man1/fzf.1 --- old/fzf-0.74.4/man/man1/fzf.1 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/man/man1/fzf.1 2026-10-11 04:29:16.000000000 +0200 @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. .. -.TH fzf 1 "Sep 2026" "fzf 0.74.4" "fzf - a command-line fuzzy finder" +.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder" .SH NAME fzf - a command-line fuzzy finder @@ -1909,7 +1909,10 @@ \fIfocus\fR .RS Triggered when the focus changes due to a vertical cursor movement or a search -result update. +result update. After a reload, the focused item is considered new unless +\fB\-\-id\-nth\fR is set and the item has the same key as the previously +focused item. Because of this, binding \fBreload\fR to this event can cause an +infinite loop. e.g. \fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/shell/completion.zsh new/fzf-0.74.5/shell/completion.zsh --- old/fzf-0.74.4/shell/completion.zsh 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/shell/completion.zsh 2026-10-11 04:29:16.000000000 +0200 @@ -215,7 +215,7 @@ _fzf_feed_fifo() { command rm -f "$1" - mkfifo "$1" + command mkfifo "$1" || return cat <&0 > "$1" &| } @@ -242,8 +242,9 @@ rest=("$@") fi - local fifo lbuf matches post - fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$" + local fifo fifo_dir lbuf matches post + fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return + fifo="$fifo_dir/fifo" lbuf=${rest[0]} post="${funcstack[1]}_post" type $post > /dev/null 2>&1 || post=cat @@ -256,7 +257,7 @@ if [ -n "$matches" ]; then LBUFFER="$lbuf$matches" fi - command rm -f "$fifo" + command rm -rf "$fifo_dir" } # To use custom hostname lists, override __fzf_list_hosts. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/terminal.go new/fzf-0.74.5/src/terminal.go --- old/fzf-0.74.4/src/terminal.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/terminal.go 2026-10-11 04:29:16.000000000 +0200 @@ -467,7 +467,7 @@ termSize tui.TermSize lastAction actionType lastKey string - lastFocus int32 + lastFocus focusedItem areaLines int areaColumns int forcePreview bool @@ -492,6 +492,11 @@ item *Item } +type focusedItem struct { + major int // revision.major; changes on reload + item *Item +} + type byTimeOrder []selectedItem func (a byTimeOrder) Len() int { @@ -1186,7 +1191,7 @@ initFunc: func() error { return renderer.Init() }, executing: util.NewAtomicBool(false), lastAction: actStart, - lastFocus: minItem.Index(), + lastFocus: focusedItem{}, lastActivity: time.Now(), numLinesCache: make(map[int32]numLinesCacheValue), // The initial load counts as a search in progress ('start:wait'). @@ -6066,6 +6071,26 @@ return minItem.Index() } +func (t *Terminal) currentFocus() focusedItem { + return focusedItem{t.revision.major, t.currentItem()} +} + +// Records the current focus and returns whether it differs from the last one. +// Indexes restart after a reload, so items are compared by --id-nth key, if +// set. Without it, any focused item after a reload is a new one. +func (t *Terminal) updateFocus() bool { + curr := t.currentFocus() + last := t.lastFocus + t.lastFocus = curr + if curr.item == nil || last.item == nil { + return curr.item != last.item + } + if curr.major == last.major { + return curr.item.Index() != last.item.Index() + } + return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth) +} + func (t *Terminal) trackKeyFor(item *Item, nth []Range) string { tokens := Tokenize(item.AsString(t.ansi), t.delimiter) return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter) @@ -6631,8 +6656,7 @@ info = true } focusChanged := focusedIndex != currentIndex - if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus { - t.lastFocus = currentIndex + if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() { t.eventChan <- tui.Focus.AsEvent() if t.infoCommand != "" { info = true @@ -7017,9 +7041,11 @@ } } - if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents { - if newIndex := t.currentIndex(); newIndex != currentIndex { - t.lastFocus = newIndex + // Actions are ignored while blocked. The render loop will + // report the focus after the reload. + if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked { + if t.currentIndex() != currentIndex { + t.lastFocus = t.currentFocus() if t.infoCommand != "" { req(reqInfo) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tmux.go new/fzf-0.74.5/src/tmux.go --- old/fzf-0.74.4/src/tmux.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/tmux.go 2026-10-11 04:29:16.000000000 +0200 @@ -5,14 +5,19 @@ "os" "os/exec" "path/filepath" + "regexp" "strings" "github.com/junegunn/fzf/src/tui" ) +// The flag group of new-pane usage includes -A (tmux 3.8 or above) +var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`) + // Returns the size of the current window if the tmux server supports -// floating panes (tmux 3.7 or above) -func tmuxFloatingPaneInfo() (int, int, bool) { +// floating panes (tmux 3.7 or above), and whether a floating pane can stay +// above a zoomed pane +func tmuxFloatingPaneInfo() (int, int, bool, bool) { // TMUX_PANE is not set when fzf is not started from within a pane // (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating // pane there; a blocking run-shell suspends key processing for the @@ -25,7 +30,7 @@ // https://github.com/tmux/tmux/issues/5384 target := os.Getenv("TMUX_PANE") if target == "" { - return 0, 0, false + return 0, 0, false, false } // A single invocation for both checks. Cannot rely on the exit status; // tmux versions before 3.7 exit normally with empty output for an @@ -33,17 +38,17 @@ out, err := exec.Command("tmux", "display-message", "-p", "-t", target, "#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output() if err != nil || !strings.Contains(string(out), "new-pane") { - return 0, 0, false + return 0, 0, false, false } var width, height int if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil { - return 0, 0, false + return 0, 0, false, false } // The window is too small to fit a floating pane of the minimum size if width < 3 || height < 3 { - return 0, 0, false + return 0, 0, false, false } - return width, height, true + return width, height, tmuxOverZoomFlag.Match(out), true } // tmux ends a command at an argument ending in ';', so a trailing one is @@ -85,7 +90,7 @@ return max(3, min(dim, window)) } -func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) { +func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) { // Unlike display-popup, the size of a floating pane does not account for // the border around it, and the position is that of the content area. To // stay consistent with popups, treat the requested size as the total @@ -166,13 +171,15 @@ } paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s", setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal) - // Unzoom the window first; creating a floating pane over a zoomed - // window crashes the tmux server on 3.7b, and newer versions of - // tmux unzoom the window anyway. target := os.Getenv("TMUX_PANE") - newPane := fmt.Sprintf( - "tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s", - escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target), + newPane := "tmux new-pane -A" + if !overZoom { + // Unzoom the window first; creating a floating pane over a + // zoomed window crashes the tmux server on 3.7b + newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane", + escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target)) + } + newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s", escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y, escapeSingleQuote(sh), escapeSingleQuote(paneCmd)) @@ -222,10 +229,10 @@ // popup is used instead so that the fzf-drawn border is the only border // shown; the native border of a tmux floating pane cannot be removed. if nativeBorder(opts) { - if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok { + if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok { opts.Tmux.border = true argStr, dir := popupArgStr(args, opts) - return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts) + return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts) } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tui/light.go new/fzf-0.74.5/src/tui/light.go --- old/fzf-0.74.4/src/tui/light.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/tui/light.go 2026-10-11 04:29:16.000000000 +0200 @@ -348,9 +348,125 @@ func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f } func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e } -// incompleteEscape reports whether the buffer ends in an escape sequence that -// has not been terminated yet. The read loop keeps waiting in that case, so the -// parser is never handed a fragment to guess at. +// csiEnd returns the length of the CSI or SS3 sequence at the start of the +// buffer, 0 if it has no final byte yet, or -1 if it is malformed. +func csiEnd(buffer []byte) int { + digits := true + for i := 2; i < len(buffer); i++ { + b := buffer[i] + // rxvt ends keys with $ after at least one digit, as in \e[7$ for + // SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte. + if csiFinal(b) || b == '$' && digits && i > 2 { + return i + 1 + } + if !csiContinues(b) { + return -1 + } + digits = digits && b >= '0' && b <= '9' + } + return 0 +} + +// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the +// start of the buffer, or 0 while nothing has ended it. A terminator is +// included, an ESC that introduces another sequence is not. +func stringEnd(buffer []byte) int { + if len(buffer) < 2 { + return 0 + } + // Every one of them ends with ST. BEL ends an OSC as well, because xterm has + // always allowed it, but stopping at a BEL inside a DCS or APC payload would + // frame only its first half and leave the rest to be typed into the query. + bel := buffer[1] == ']' + for i := 2; i < len(buffer); i++ { + switch buffer[i] { + case '\a': + if bel { + return i + 1 + } + case Esc.Byte(): + if i+1 == len(buffer) { + return 0 // ST may still be arriving + } + if buffer[i+1] == '\\' { + return i + 2 + } + // Any other ESC ends the string and introduces a sequence of its + // own, so frame only what precedes it and leave the ESC to be + // parsed again. Scanning past it would swallow that sequence too. + return i + } + } + return 0 +} + +// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home. +// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both. +func rxvtKeyEnd(b byte) bool { + return b == '~' || b == '$' || b == '^' || b == '@' +} + +// csiIntroducer reports whether the byte after ESC starts a sequence that is +// framed by a final byte. The parser routes CSI and SS3 through the same +// parameterized subcases, so both are framed by csiEnd. +func csiIntroducer(b byte) bool { + return b == '[' || b == 'O' +} + +// stringIntroducer reports whether the byte after ESC starts a string sequence. +// Only the three that terminals reply with: OSC for colors, title and clipboard, +// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing +// sends SOS or PM. +func stringIntroducer(b byte) bool { + switch b { + case 'P', ']', '_': + return true + } + return false +} + +// stringReply reports whether a framed string sequence starts the way a +// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed +// text after one, ended by CTRL-G or another key, frames like a reply when it +// all arrives in one read. +func stringReply(buffer []byte) bool { + payload := buffer[2:] + switch buffer[1] { + case ']': // Ps ; ... + i := 0 + for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' { + i++ + } + return i > 0 && i < len(payload) && payload[i] == ';' + case 'P': // >| or !| for the version, [01]$r or [01]+r for settings + if len(payload) < 2 { + return false + } + if payload[1] == '|' { + return payload[0] == '>' || payload[0] == '!' + } + return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') && + (payload[1] == '$' || payload[1] == '+') && payload[2] == 'r' + case '_': // G, then key=value + return len(payload) >= 3 && payload[0] == 'G' && + (payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') && + payload[2] == '=' + } + return false +} + +// stillArriving reports whether the CSI or SS3 sequence at the start of the +// buffer may still be completed by more input. It must be the only one in the buffer: +// whatever follows it, another sequence or a byte that ended it, means it is not +// waiting for anything. +func stillArriving(buffer []byte) bool { + return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer) +} + +// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that +// has not been terminated yet. The read loop keeps waiting while it does, so a +// fragment reaches the parser only once that wait has run out. A string sequence +// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_. func incompleteEscape(buffer []byte) bool { // Only the tail can hold a sequence still arriving. This runs once per byte // read, so scanning all of a large paste would make the read quadratic. @@ -362,21 +478,10 @@ if start < 0 || len(tail)-start < 2 { return false } - switch tail[start+1] { - case '[': - for _, b := range tail[start+2:] { - if csiFinal(b) { - return false - } - if !csiContinues(b) { - return false // malformed, do not wait for a terminator - } - } - return true - case 'O': - return len(tail)-start < 3 - } - return false + // SS3 is judged like CSI: declaring it finished after one byte made the + // parser give up on a split \eO1;5A. A malformed sequence is not waited for, + // as nothing can complete it. + return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0 } func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) { @@ -483,17 +588,27 @@ return Event{CtrlSlash, 0, nil} case Esc.Byte(): ev := r.escSequence(&sz) - // Second chance - if ev.Type == Invalid { - r.buffer, result, err = r.getBytes(true) + // Second chance, but only for an unfinished sequence. Re-reading + // otherwise blocks until the next keystroke, holding back whatever + // follows in the buffer. + if ev.Type == Invalid && stillArriving(r.buffer) { + waited := len(r.buffer) + var buffer []byte + buffer, result, err = r.getBytes(true) if err != nil { return Event{Fatal, 0, nil} } + r.buffer = buffer if result == getCharCancelled { return Event{Invalid, 0, nil} } ev = r.escSequence(&sz) + // This read has no time limit, so what it returned may be the next + // keystroke rather than the rest of the sequence. Never drop it. + if ev.Type == Invalid && sz > waited { + sz = waited + } } return ev } @@ -525,7 +640,41 @@ r.mutex.Unlock() } +// escSequence parses an escape sequence, then checks a CSI or SS3 result +// against its frame. parseEscSequence can stop after a few bytes, and consuming +// only those would leave the rest to be typed into the query. Complete +// sequences it does not recognize are dropped there, unless they could be an +// ALT key followed by typed text. func (r *LightRenderer) escSequence(sz *int) Event { + ev := r.parseEscSequence(sz) + if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) { + return ev + } + // A frame missing its final byte may still be arriving + end := csiEnd(r.buffer) + if end <= 0 { + return ev + } + // The parser can also set the size before checking the bytes and then give + // up, counting typed input past the frame, as in \e[3;1u followed by ~ + if ev.Type == Invalid && *sz > end { + *sz = end + } + // Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends + // keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE + if end <= 4 { + return ev + } + // A key matched on a prefix, such as Home for \e[70;5u, or a sequence given + // up on partway + if end > *sz { + *sz = end + return Event{Invalid, 0, nil} + } + return ev +} + +func (r *LightRenderer) parseEscSequence(sz *int) Event { if len(r.buffer) < 2 { return Event{Esc, 0, nil} } @@ -606,6 +755,11 @@ if len(r.buffer) < 4 { return Event{Invalid, 0, nil} } + // A byte that cannot continue the sequence, such as Enter after + // ALT-[ and a digit, is a key of its own + if csiEnd(r.buffer) < 0 { + break + } *sz = 4 switch r.buffer[2] { case '2': @@ -638,7 +792,7 @@ if r.buffer[3] == '~' { return Event{Delete, 0, nil} } - if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' { + if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' { *sz = 7 switch r.buffer[5] { case '0': @@ -657,7 +811,7 @@ return Event{CtrlAltShiftDelete, 0, nil} } } - if len(r.buffer) == 6 && r.buffer[5] == '~' { + if len(r.buffer) > 5 && r.buffer[5] == '~' { *sz = 6 switch r.buffer[4] { case '2': @@ -680,12 +834,14 @@ } return Event{Invalid, 0, nil} case '4': - return Event{End, 0, nil} + if rxvtKeyEnd(r.buffer[3]) { + return Event{End, 0, nil} + } case '5': if r.buffer[3] == '~' { return Event{PageUp, 0, nil} } - if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' { + if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' { *sz = 7 switch r.buffer[5] { case '0': @@ -704,7 +860,7 @@ return Event{CtrlAltShiftPageUp, 0, nil} } } - if len(r.buffer) == 6 && r.buffer[5] == '~' { + if len(r.buffer) > 5 && r.buffer[5] == '~' { *sz = 6 switch r.buffer[4] { case '2': @@ -730,7 +886,7 @@ if r.buffer[3] == '~' { return Event{PageDown, 0, nil} } - if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' { + if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' { *sz = 7 switch r.buffer[5] { case '0': @@ -749,7 +905,7 @@ return Event{CtrlAltShiftPageDown, 0, nil} } } - if len(r.buffer) == 6 && r.buffer[5] == '~' { + if len(r.buffer) > 5 && r.buffer[5] == '~' { *sz = 6 switch r.buffer[4] { case '2': @@ -772,15 +928,19 @@ } return Event{Invalid, 0, nil} case '7': - return Event{Home, 0, nil} + if rxvtKeyEnd(r.buffer[3]) { + return Event{Home, 0, nil} + } case '8': - return Event{End, 0, nil} + if rxvtKeyEnd(r.buffer[3]) { + return Event{End, 0, nil} + } case '1': switch r.buffer[3] { case '~': return Event{Home, 0, nil} case '1', '2', '3', '4', '5', '7', '8', '9': - if len(r.buffer) == 5 && r.buffer[4] == '~' { + if len(r.buffer) > 4 && r.buffer[4] == '~' { *sz = 5 switch r.buffer[3] { case '1': @@ -987,6 +1147,24 @@ } // r.buffer[2] } // r.buffer[2] } // r.buffer[1] + // Nothing matched. A framed sequence is dropped whole: reading its + // introducer as an ALT-key below would type the rest into the query. + // Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-], + // ALT-P and ALT-_ arrive. + if csiIntroducer(r.buffer[1]) { + // ALT-[ or ALT-O and one or two typed characters can look like a short + // sequence, so ask for at least two bytes before the final byte + if end := csiEnd(r.buffer); end > 4 { + *sz = end + return Event{Invalid, 0, nil} + } + } else if stringIntroducer(r.buffer[1]) { + if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) { + *sz = end + return Event{Invalid, 0, nil} + } + } + rest := bytes.NewBuffer(r.buffer[1:]) c, size, err := rest.ReadRune() if err == nil { @@ -1003,11 +1181,13 @@ return Event{Invalid, 0, nil} } - rest := r.buffer[*sz:] - end := bytes.IndexAny(rest, "mM") - if end == -1 { + // Not past the frame, where M or m can be a key typed later + frame := csiEnd(r.buffer) + if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' { return Event{Invalid, 0, nil} } + rest := r.buffer[*sz:] + end := frame - 1 - *sz elems := strings.SplitN(string(rest[:end]), ";", 3) if len(elems) != 3 { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tui/light_csi_test.go new/fzf-0.74.5/src/tui/light_csi_test.go --- old/fzf-0.74.4/src/tui/light_csi_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/fzf-0.74.5/src/tui/light_csi_test.go 2026-10-11 04:29:16.000000000 +0200 @@ -0,0 +1,274 @@ +package tui + +import "testing" + +// An unrecognized escape sequence must be consumed whole, CSI and string +// sequences alike. Consuming only part of one leaves the rest to be read as +// input and typed into the query. +func TestUnknownEscapeSequence(t *testing.T) { + for _, c := range []struct { + sequence string + event EventType + size int + }{ + // Key encodings fzf does not implement + {"\x1b[97;5u", Invalid, 7}, + {"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches + {"\x1b[42;5u", Invalid, 7}, // nor End + {"\x1b[4;5~", Invalid, 6}, + {"\x1b[127;5u", Invalid, 8}, + {"\x1b[27;5;127~", Invalid, 11}, + {"\x1b[57441;1u", Invalid, 10}, + {"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped + + // Replies to queries fzf did not send, or sent and stopped waiting for + {"\x1b[?1;2c", Invalid, 7}, + {"\x1b[>0;95;0c", Invalid, 10}, + + // Mouse report arriving while mouse input is off + {"\x1b[<0;1;1M", Invalid, 9}, + + // String sequences + {"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply + {"\x1b]0;a title\a", Invalid, 12}, // BEL terminated + {"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply + {"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply + {"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS + {"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x" + + // SS3 is framed like CSI, since the parser routes both the same way + {"\x1bO9;5u", Invalid, 6}, + {"\x1bO1;5X", Invalid, 6}, // unknown final byte + {"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged + {"\x1bOP", F1, 3}, + + // Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive + {"\x1b[a", Alt, 2}, + {"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI + {"\x1b[1m", Alt, 2}, + {"\x1b[ x", Alt, 2}, + {"\x1bOx", Alt, 2}, + {"\x1bO9A", Alt, 2}, + + // rxvt keys fzf does not know: dropped, not typed + {"\x1b[3^", Invalid, 4}, // CTRL-DELETE + {"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP + {"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE + {"\x1b[11^", Invalid, 5}, + + // rxvt ends keys with $ after digits, so what follows is typed + {"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x + {"\x1b[7$1x", Home, 4}, + {"\x1b[3$x", Invalid, 4}, + {"\x1b[3$1x", Invalid, 4}, + {"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x + {"\x1b[24$1x", Invalid, 5}, + + // A parse that gives up past the frame consumes only the frame, so the + // typed ~ after these stays in the buffer + {"\x1b[3;1u~", Invalid, 6}, + {"\x1b[5;1u~", Invalid, 6}, + {"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT + {"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT + {"\x1b[2A~", Invalid, 4}, + + // Elsewhere $ and other intermediate bytes do not end a sequence + {"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches + {"\x1b[4;2$y", Invalid, 7}, + {"\x1b[2 q", Invalid, 5}, + {"\x1b]abc", Alt, 2}, + {"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived + {"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key + {"\x1b]\x1b[A", Alt, 2}, + {"\x1bP\x1bP", Alt, 2}, + {"\x1b_\x1b_", Alt, 2}, + {"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC + {"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort + + // Typed text after the introducer is not a reply, even when a + // terminator follows in the same read + {"\x1b]a\a", Alt, 2}, + {"\x1b]1\a", Alt, 2}, + {"\x1b]a\x1b[A", Alt, 2}, + {"\x1bPx\x1b[A", Alt, 2}, + {"\x1b_ab\x1bb", Alt, 2}, + + // SOS and PM are not framed + {"\x1bXsos\x1b\\", Alt, 2}, + {"\x1b^status\x1b\\", Alt, 2}, + + // Left alone: no final byte yet, so the sequence may still be arriving + {"\x1b[", Invalid, 2}, + + // Recognized sequences keep their existing parsing + {"\x1b[1;5A", CtrlUp, 6}, + {"\x1b[3;5~", CtrlDelete, 6}, + {"\x1b[2~", Insert, 4}, + {"\x1b[200~", BracketedPasteBegin, 6}, + {"\x1b[Z", ShiftTab, 3}, + {"\x1bOA", Up, 3}, + {"\x1b[12;34R", Invalid, 8}, + {"\x1b[?2004;2$y", Invalid, 11}, + } { + r := &LightRenderer{buffer: []byte(c.sequence)} + sz := 1 + event := r.escSequence(&sz) + if event.Type != c.event { + t.Errorf("escSequence(%q) = %s, want %s", + c.sequence, event.Type.String(), c.event.String()) + } + if sz != c.size { + t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size) + } + } +} + +func TestStringEnd(t *testing.T) { + for _, c := range []struct { + buffer string + want int + }{ + // Terminated + {"\x1b]0;t\a", 6}, + {"\x1b]0;t\x1b\\", 7}, + {"\x1b_G\x1b\\", 5}, + {"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it + + // BEL terminates an OSC only + {"\x1bP\a", 0}, + {"\x1b_Gi=1\a", 0}, + + // An ESC ends the string and introduces a sequence of its own + {"\x1b]foo\x1bX\x1b\\", 5}, + {"\x1b]foo\x1bP", 5}, + {"\x1b]\x1b]", 2}, + + // Nothing has ended it yet + {"\x1b]0;t", 0}, + {"\x1b]0;t\x1b", 0}, // ST half arrived + {"\x1b]foo\x1b", 0}, + {"\x1b]", 0}, + {"\x1b", 0}, // shorter than an introducer + + } { + if got := stringEnd([]byte(c.buffer)); got != c.want { + t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want) + } + } +} + +// A dropped sequence followed by ALT-[ is not waiting for anything, so the +// bytes in between must not be held back until the next keystroke +func TestStillArriving(t *testing.T) { + for _, c := range []struct { + buffer string + want bool + }{ + {"\x1b[97;5u\a\x1b[", false}, + {"\x1b[97;5u\x1b[", false}, + {"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one + {"\x1b[1;\a", false}, // the BEL has already ended it + {"\x1b[1;", true}, + {"\x1b[", true}, + {"\x1bO1", true}, + {"\x1b[<0;1", true}, + } { + if got := stillArriving([]byte(c.buffer)); got != c.want { + t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want) + } + } +} + +func TestStringReply(t *testing.T) { + for _, c := range []struct { + buffer string + want bool + }{ + {"\x1b]11;rgb:1/2/3\a", true}, + {"\x1b]0;title\a", true}, + {"\x1b]52;c;YWJj\x1b\\", true}, + {"\x1bP>|kitty(0.48.2)\x1b\\", true}, + {"\x1bP!|0\x1b\\", true}, + {"\x1bP1$r0m\x1b\\", true}, + {"\x1bP0+r\x1b\\", true}, + {"\x1b_Gi=1;OK\x1b\\", true}, + + // What typed text after ALT-], ALT-P or ALT-_ looks like + {"\x1b]\a", false}, + {"\x1b]a\a", false}, + {"\x1b]1\a", false}, + {"\x1b];\a", false}, + {"\x1bPx\x1b\\", false}, + {"\x1bP1\x1b\\", false}, + {"\x1b_ab\x1b\\", false}, + {"\x1b_G\x1b\\", false}, + } { + if got := stringReply([]byte(c.buffer)); got != c.want { + t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want) + } + } +} + +func TestCsiEnd(t *testing.T) { + for _, c := range []struct { + buffer string + want int + }{ + {"\x1b[97;5u", 7}, + {"\x1b[A", 3}, + {"\x1b[<0;1;1M", 9}, + {"\x1b[?2004;2$y", 11}, + {"\x1b[7$x", 4}, // rxvt ends keys with $ after digits + {"\x1b[23$", 5}, + {"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere + {"\x1b[97;5", 0}, // no final byte + {"\x1b[", 0}, // no final byte + {"\x1b[1\x01A", -1}, // malformed, do not frame it + {"\x1b[\a", -1}, + } { + if got := csiEnd([]byte(c.buffer)); got != c.want { + t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want) + } + } +} + +// A key that follows a sequence in the same read must not be consumed with it +func TestKeyAfterSequence(t *testing.T) { + for _, c := range []struct { + sequence string + mouse bool + event EventType + size int + }{ + // Recognized when more input follows, as from a paste or tmux send-keys + {"\x1b[15~x", false, F5, 5}, + {"\x1b[3;5~x", false, CtrlDelete, 6}, + {"\x1b[5;13~x", false, CtrlAltPageUp, 7}, + {"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6}, + + // ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence + {"\x1b[2\r", false, Alt, 2}, + {"\x1b[12\x03", false, Alt, 2}, + {"\x1bO2\a", false, Alt, 2}, + {"\x1b[4x", false, Alt, 2}, // not End + {"\x1b[7\r", false, Alt, 2}, // not Home + {"\x1b[7~x", false, Home, 4}, + {"\x1b[8^x", false, End, 4}, // rxvt CTRL-END + + // A mouse report ends at its own final byte + {"\x1b[<0;5;3Mx", true, Mouse, 9}, + {"\x1b[<0;1;\x1b[AM", true, Invalid, 3}, + {"\x1b[<0;1;1xM", true, Invalid, 9}, + } { + r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse} + sz := 1 + event := r.escSequence(&sz) + if event.Type != c.event { + t.Errorf("escSequence(%q) = %s, want %s", + c.sequence, event.Type.String(), c.event.String()) + } + if sz != c.size { + t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tui/light_escape_test.go new/fzf-0.74.5/src/tui/light_escape_test.go --- old/fzf-0.74.4/src/tui/light_escape_test.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/tui/light_escape_test.go 2026-10-11 04:29:16.000000000 +0200 @@ -51,3 +51,46 @@ } } } + +// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or +// ALT-_, and holding the read open on those both delayed the key and let typed +// bytes accumulate into something that looked like a sequence. +func TestIncompleteStringEscape(t *testing.T) { + for _, buffer := range []string{ + "\x1b]11;rgb:", + "\x1bP>|kitty", + "\x1b_Gi=1", + "\x1b]0;t\x1b", + "\x1b]0;title\a", + "ab\x1b]11;rgb:", + } { + if incompleteEscape([]byte(buffer)) { + t.Errorf("incompleteEscape(%q) = true, want false", buffer) + } + } +} + +// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to +// be judged the same way here. Declaring it finished after one byte made the +// parser give up on a split \eO1;5A and type its tail into the query. +func TestIncompleteSS3(t *testing.T) { + for _, c := range []struct { + buffer string + want bool + }{ + {"\x1bO", true}, + {"\x1bO1", true}, + {"\x1bO1;", true}, + {"\x1bO1;5", true}, + {"\x1bOA", false}, + {"\x1bOP", false}, + {"\x1bO1;5A", false}, + {"\x1b[23$", false}, // rxvt SHIFT-F11 is complete + {"\x1b[7$", false}, + {"\x1b[4;2$", true}, // $ after a ; is intermediate + } { + if got := incompleteEscape([]byte(c.buffer)); got != c.want { + t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tui/light_query_test.go new/fzf-0.74.5/src/tui/light_query_test.go --- old/fzf-0.74.4/src/tui/light_query_test.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/tui/light_query_test.go 2026-10-11 04:29:16.000000000 +0200 @@ -6,6 +6,9 @@ "os" "strings" "testing" + "time" + + "golang.org/x/term" ) // Drives queryStartup against a terminal simulated by pipes, with the replies @@ -126,3 +129,54 @@ }) } } + +// The second-chance read has no time limit, so a key typed long after an +// unfinished sequence must not be dropped with it. +func TestLateKeyAfterUnfinishedSequence(t *testing.T) { + inR, inW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { inR.Close(); inW.Close() }) + inW.WriteString("\x1b[12") + go func() { + time.Sleep(2 * defaultEscDelay * time.Millisecond) + inW.WriteString("x") + time.Sleep(2 * defaultEscDelay * time.Millisecond) + inW.WriteString("y") // ends the wait if x was dropped + }() + + r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay} + for range 3 { + if ev := r.GetChar(false); ev.Type == Rune { + if ev.Char != 'x' { + t.Errorf("got %q, want 'x'", ev.Char) + } + return + } + } + t.Error("the late key was dropped") +} + +// A read error during the second-chance read must end fzf cleanly, not panic. +func TestSecondChanceReadError(t *testing.T) { + inR, inW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { inR.Close() }) + inW.WriteString("\x1b[12") + go func() { + time.Sleep(2 * defaultEscDelay * time.Millisecond) + inW.Close() + }() + + out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}} + if ev := r.GetChar(false); ev.Type != Fatal { + t.Errorf("got %s, want Fatal", ev.Type.String()) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/tui/light_test.go new/fzf-0.74.5/src/tui/light_test.go --- old/fzf-0.74.4/src/tui/light_test.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/tui/light_test.go 2026-10-11 04:29:16.000000000 +0200 @@ -66,7 +66,6 @@ assertEscSequence("\x1b[1;1R", "!Invalid") assertEscSequence("\x1b[", "!Invalid") assertEscSequence("\x1b[1", "!Invalid") - assertEscSequence("\x1b[3;3~1", "!Invalid") assertEscSequence("\x1b[13", "!Invalid") assertEscSequence("\x1b[1;3", "!Invalid") assertEscSequence("\x1b[1;10", "!Invalid") @@ -200,6 +199,7 @@ assertEscSequence("\x1b[1;3H", "alt-home") assertEscSequence("\x1b[1;3F", "alt-end") assertEscSequence("\x1b[3;3~", "alt-delete") + assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1 assertEscSequence("\x1b[5;3~", "alt-page-up") assertEscSequence("\x1b[6;3~", "alt-page-down") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/util/util.go new/fzf-0.74.5/src/util/util.go --- old/fzf-0.74.4/src/util/util.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/util/util.go 2026-10-11 04:29:16.000000000 +0200 @@ -105,16 +105,18 @@ rest := limit % length output := strings.Repeat(str, times) if rest > 0 { - for _, r := range str { - rest -= uniseg.StringWidth(string(r)) - if rest < 0 { - break - } - output += string(r) - if rest == 0 { + // Iterate over grapheme clusters so that we don't cut a cluster in half + end := 0 + graphemes := uniseg.NewGraphemes(str) + for rest > 0 && graphemes.Next() { + width := graphemes.Width() + if width > rest { break } + rest -= width + _, end = graphemes.Positions() } + output += str[:end] } return output } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/src/util/util_test.go new/fzf-0.74.5/src/util/util_test.go --- old/fzf-0.74.4/src/util/util_test.go 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/src/util/util_test.go 2026-10-11 04:29:16.000000000 +0200 @@ -109,6 +109,27 @@ if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] { t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2]) } + + // Should not cut a grapheme cluster in half + for _, test := range []struct { + str string + limit int + expected string + }{ + {"a\u0301b", 1, "a\u0301"}, + {"a\u0301b", 3, "a\u0301ba\u0301"}, + {"a\u0301b", 4, "a\u0301ba\u0301b"}, + {"a\u4e00", 2, "a"}, + {"a\u4e00", 4, "a\u4e00a"}, + {"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"}, + {"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"}, + {"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"}, + } { + actual := RepeatToFill(test.str, StringWidth(test.str), test.limit) + if actual != test.expected { + t.Errorf("Expected: %q, actual: %q", test.expected, actual) + } + } } func TestStringWidth(t *testing.T) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.4/test/test_core.rb new/fzf-0.74.5/test/test_core.rb --- old/fzf-0.74.4/test/test_core.rb 2026-09-12 02:21:00.000000000 +0200 +++ new/fzf-0.74.5/test/test_core.rb 2026-10-11 04:29:16.000000000 +0200 @@ -1532,6 +1532,61 @@ tmux.until { |lines| refute_includes(lines[-1], '[[1]]') } end + def assert_focus_log(expected) + wait do + assert_path_exists tempname + assert_equal expected, File.readlines(tempname, chomp: true) + end + end + + def test_focus_event_after_reload + # Without --id-nth, the item at the same index after reload is a new item + tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> a' } + assert_focus_log(%w[a]) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> c' } + assert_focus_log(%w[a c]) + end + + def test_focus_event_after_reload_id_nth_same_key + # Same --id-nth key after reload: no focus event + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> 1 aa' } + tmux.send_keys :Up + tmux.until { |lines| assert_includes lines, '> 2 bb' } + assert_focus_log(['1 a', '2 bb']) + end + + def test_focus_event_after_reload_id_nth_key_not_found + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> 3 c' } + assert_focus_log(['1 a', '3 c']) + end + + def test_focus_event_after_async_reload_id_nth + # No focus event for the items shown before the tracked item arrives. + # 'result' binding makes fzf redraw the list while blocked. + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines[-2], '+T*' } + tmux.until do |lines| + assert_includes lines, '> 1 aa' + refute_includes lines[-2], '+T*' + end + tmux.send_keys :Up + tmux.until { |lines| assert_includes lines, '> 2 bb' } + assert_focus_log(['1 a', '2 bb']) + end + def test_result_event tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter tmux.until { |lines| assert_equal 11, lines.match_count } ++++++ vendor.tar.zst ++++++
