mirror of
https://github.com/junegunn/fzf.git
synced 2026-07-09 18:19:13 +08:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-3
@@ -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%
|
||||
|
||||
+19
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+136
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user