From 1e31e5dfbe1912e0532e9055c68b5c689e096e87 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 5 Jul 2026 14:15:11 +0900 Subject: [PATCH] Use floating pane instead of popup on tmux 3.7 or above (#4850) (#4852) Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it. - Floating pane always has a native border, so 'border-native' is implied; give new 'border-fzf' option to fall back to a popup where fzf draws its own border - Popup is also used on tmux versions below 3.7, or when the window is too small to fit a floating pane - new-pane does not block until the command finishes and does not propagate the exit status; block on a wait-for channel signaled by the pane and pass the exit status through a temporary file - Watchdog process signals the channel when the pane is closed abnormally (e.g. kill-pane) - Kill the pane when the proxy process is interrupted, like a popup dying with its client - Unzoom the window before creating the floating pane; doing so over a zoomed window crashes the tmux server on 3.7b, and newer versions of tmux unzoom the window anyway - Floating pane size excludes the border and the position is that of the content area; treat the requested size as the total footprint including the border for consistency with popups - Close the pane on exit even when remain-on-exit is on - Pre-create the exit status file with O_EXCL to prevent tampering on a shared TMPDIR --- CHANGELOG.md | 7 +++ man/man1/fzf.1 | 14 ++++- src/options.go | 26 ++++++--- src/proxy.go | 4 +- src/tmux.go | 137 +++++++++++++++++++++++++++++++++++++++++++++- src/zellij.go | 2 +- test/test_tmux.rb | 64 ++++++++++++++++++++++ 7 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 test/test_tmux.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e6e665..5db41b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ CHANGELOG 0.74.0 (WIP) ------------ +- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850) + - Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it + - A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied + - Give `border-fzf` to fall back to a popup where fzf draws its own border + ```sh + fzf --popup center,80%,border-fzf + ``` - Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835) - Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading ```sh diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index a8556b4d..226c8629 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -417,11 +417,19 @@ layout options so that the specified number of items are visible in the list section (default: \fB10+\fR). Ignored when \fB\-\-height\fR is not specified or set as an absolute value. .TP -.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]" -Start fzf in a tmux popup or in a Zellij floating pane (default -\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you +.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf]]" +Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR). +Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option. +On tmux 3.7 or above, the floating pane is not modal; you can switch to +other panes and windows while fzf is running, move and resize the pane with +the mouse, zoom it to fullscreen, and use copy\-mode in it. The pane always +has a native border, which is what makes it movable and resizable, so +\fBborder\-native\fR is implied. On tmux versions below 3.7, or when +\fBborder\-fzf\fR is given, a modal tmux popup is used instead and fzf draws +its own border. + e.g. \fB# Popup in the center with 70% width and height fzf \-\-popup 70% diff --git a/src/options.go b/src/options.go index 550c84cf..955d05b2 100644 --- a/src/options.go +++ b/src/options.go @@ -75,9 +75,9 @@ Usage: fzf [options] --min-height=HEIGHT[+] Minimum height when --height is given as a percentage. Add '+' to automatically increase the value according to the other layout options (default: 10+). - --popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+) + --popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+) [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] - [,border-native] (default: center,50%) + [,border-native|border-fzf] (default: center,50%) --tmux[=OPTS] Alias for --popup LAYOUT @@ -335,12 +335,20 @@ const ( posNext // adjacent to the input section, on the list side ) +type tmuxBorder int + +const ( + tmuxBorderAuto tmuxBorder = iota + tmuxBorderNative + tmuxBorderFzf +) + type tmuxOptions struct { width sizeSpec height sizeSpec position windowPosition index int - border bool + border tmuxBorder } type layoutType int @@ -427,15 +435,19 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) { var err error opts := defaultTmuxOptions(index) tokens := splitRegexp.Split(arg, -1) - errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])") + errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf])") if len(tokens) == 0 || len(tokens) > 4 { return nil, errorToReturn } for i, token := range tokens { - if token == "border-native" { - tokens = append(tokens[:i], tokens[i+1:]...) // cut the 'border-native' option - opts.border = true + if token == "border-native" || token == "border-fzf" { + tokens = append(tokens[:i], tokens[i+1:]...) // cut the border option + if token == "border-native" { + opts.border = tmuxBorderNative + } else { + opts.border = tmuxBorderFzf + } break } } diff --git a/src/proxy.go b/src/proxy.go index c02aedcf..cbf2ac47 100644 --- a/src/proxy.go +++ b/src/proxy.go @@ -26,14 +26,14 @@ func escapeSingleQuote(str string) string { func popupArgStr(args []string, opts *Options) (string, string) { fzf, rest := args[0], args[1:] args = []string{"--bind=ctrl-z:ignore"} - if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) { + if opts.Tmux.border != tmuxBorderNative && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) { if tui.DefaultBorderShape == tui.BorderRounded { rest = append(rest, "--border=rounded") } else { rest = append(rest, "--border=sharp") } } - if opts.Tmux.border && opts.Margin == defaultMargin() { + if opts.Tmux.border == tmuxBorderNative && opts.Margin == defaultMargin() { args = append(args, "--margin=0,1") } argStr := escapeSingleQuote(fzf) diff --git a/src/tmux.go b/src/tmux.go index 60d81dc4..c4834532 100644 --- a/src/tmux.go +++ b/src/tmux.go @@ -1,10 +1,145 @@ package fzf import ( + "fmt" + "os" "os/exec" + "path/filepath" + "strings" ) +// Returns the size of the current window if the tmux server supports +// floating panes (tmux 3.7 or above) +func tmuxFloatingPaneInfo() (int, int, bool) { + target := os.Getenv("TMUX_PANE") + if target == "" { + return 0, 0, 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 + // unknown command name, so check the output instead. + 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 + } + var width, height int + if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil { + return 0, 0, 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 width, height, true +} + +// Convert sizeSpec to the number of cells, clamped between the minimum +// footprint of 3, including the border, and the window size +func tmuxDim(spec sizeSpec, window int) int { + dim := int(spec.size) + if spec.percent { + dim = window * dim / 100 + } + return max(3, min(dim, window)) +} + +func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, 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 + // footprint including the border. + width := tmuxDim(opts.Tmux.width, windowWidth) + height := tmuxDim(opts.Tmux.height, windowHeight) + x := (windowWidth-width)/2 + 1 + y := (windowHeight-height)/2 + 1 + switch opts.Tmux.position { + case posUp: + y = 1 + case posDown: + y = windowHeight - height + 1 + case posLeft: + x = 1 + case posRight: + x = windowWidth - width + 1 + } + + return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) { + sh, err := sh(needBash) + if err != nil { + return nil, err + } + // Unlike display-popup, new-pane does not block until the command + // finishes, and it does not propagate the exit status. So we block on + // a wait-for channel that the pane signals on completion, and pass + // the exit status through a temporary file. A watchdog process + // signals the same channel if the pane is closed abnormally + // (e.g. kill-pane), in which case the file is not written. + // + // has-session is the liveness check because it fails when the target + // pane is gone, while display-message succeeds even for a dead pane. + signal := escapeSingleQuote("fzf-" + filepath.Base(temp)) + + // Pre-create the exit status file so that another user on a shared + // TMPDIR cannot plant a file or a symbolic link at the predictable + // path while the pane is running + codeFile := temp + ".code" + f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + f.Close() + code := escapeSingleQuote(codeFile) + paneCmd := fmt.Sprintf("%s %s; echo $? > %s; tmux wait-for -S %s", + 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. The pane should always close on + // exit like a popup, even when remain-on-exit is on. set-option + // targets the new pane as new-pane makes it the current pane. + 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 ';' set-option -p remain-on-exit off", + escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target), + escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y, + escapeSingleQuote(sh), escapeSingleQuote(paneCmd)) + // The pane is killed when the proxy process is interrupted or hung up, + // like a popup dying with its client. wait-for runs in the background + // and is awaited with the interruptible wait builtin so that the trap + // can fire while blocked. The trap is installed before creating the + // pane; a signal received during creation is deferred until the + // command substitution completes, and the pane is killed right after. + // An interrupted wait does not reap the waiter, so it is killed + // along with the watchdog. + script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP +id=$(%s) || { status=$?; rm -f %s; exit "$status"; } +{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } & +watchdog=$! +tmux wait-for %s & +waiter=$! +wait "$waiter" +kill "$watchdog" "$waiter" 2> /dev/null +wait 2> /dev/null +if [ -s %s ]; then code=$(cat %s); else code=130; fi +rm -f %s +exit "$code"`, newPane, code, signal, signal, code, code, code) + return exec.Command(sh, "-c", script), nil + }, opts, true) +} + func runTmux(args []string, opts *Options) (int, error) { + // On tmux 3.7 or above, fzf runs in a floating pane instead of a popup. + // A floating pane always has a native border, so 'border-native' is + // implied. Give 'border-fzf' to fall back to a popup where fzf draws + // its own border. + if opts.Tmux.border != tmuxBorderFzf { + if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok { + opts.Tmux.border = tmuxBorderNative + argStr, dir := popupArgStr(args, opts) + return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts) + } + } + argStr, dir := popupArgStr(args, opts) // Set tmux options for popup placement @@ -15,7 +150,7 @@ func runTmux(args []string, opts *Options) (int, error) { // W Both The window position on the status line // S -y The line above or below the status line tmuxArgs := []string{"display-popup", "-E", "-d", dir} - if !opts.Tmux.border { + if opts.Tmux.border != tmuxBorderNative { tmuxArgs = append(tmuxArgs, "-B") } switch opts.Tmux.position { diff --git a/src/zellij.go b/src/zellij.go index dce9dd40..0fb6532d 100644 --- a/src/zellij.go +++ b/src/zellij.go @@ -11,7 +11,7 @@ func runZellij(args []string, opts *Options) (int, error) { "run", "--floating", "--close-on-exit", "--block-until-exit", "--cwd", dir, } - if !opts.Tmux.border { + if opts.Tmux.border != tmuxBorderNative { zellijArgs = append(zellijArgs, "--borderless", "true") } switch opts.Tmux.position { diff --git a/test/test_tmux.rb b/test/test_tmux.rb new file mode 100644 index 00000000..5e0888c6 --- /dev/null +++ b/test/test_tmux.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'shellwords' +require 'tmpdir' +require_relative 'lib/common' + +# Tests for running fzf in a tmux floating pane (--popup on tmux 3.7 or above) +class TestTmux < TestInteractive + def setup + super + # Cannot rely on the exit status; tmux versions before 3.7 exit + # normally with empty output for an unknown command name + supported = IO.popen(%w[tmux list-commands new-pane], err: File::NULL, &:read).include?('new-pane') + skip('floating panes not supported') unless supported + end + + def test_floating_pane + tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter + tmux.until { |lines| assert_equal 100, lines.item_count } + tmux.send_keys '99' + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.send_keys :Enter + assert_equal '99', fzf_output + end + + def test_floating_pane_killed + tmux.send_keys "seq 100 | #{FZF} --popup bottom,50% --margin 0; echo code:$?", :Enter + tmux.until { |lines| assert_equal 100, lines.item_count } + pane = floating_pane + refute_nil pane + assert system('tmux', 'kill-pane', '-t', pane) + tmux.until { |lines| assert lines.any_include?('code:130') } + end + + def test_border_fzf_falls_back_to_popup + # display-popup requires an attached client, which the test environment + # may not have; intercept it with a tmux shim on PATH + dir = Dir.mktmpdir + real = `command -v tmux`.chomp + shim = File.join(dir, 'tmux') + File.write(shim, <<~SH) + #!/bin/sh + if [ "$1" = display-popup ]; then + echo popup-used >&2 + exit 0 + fi + exec #{real.shellescape} "$@" + SH + FileUtils.chmod(0o755, shim) + tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center,border-fzf", :Enter + tmux.until { |lines| assert lines.any_include?('popup-used') } + refute floating_pane + ensure + FileUtils.remove_entry(dir) if dir + end + + private + + def floating_pane + format = "\#{pane_id} \#{pane_floating_flag}" + lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) } + lines.filter_map { |line| line.split.first if line.end_with?(' 1') }.first + end +end