mirror of
https://github.com/junegunn/fzf.git
synced 2026-08-12 19:07:00 +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)
|
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)
|
- 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
|
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
|
||||||
```sh
|
```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).
|
section (default: \fB10+\fR).
|
||||||
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
|
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
|
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf]]"
|
||||||
Start fzf in a tmux popup or in a Zellij floating pane (default
|
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
|
||||||
\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
|
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.
|
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.
|
e.g.
|
||||||
\fB# Popup in the center with 70% width and height
|
\fB# Popup in the center with 70% width and height
|
||||||
fzf \-\-popup 70%
|
fzf \-\-popup 70%
|
||||||
|
|||||||
+19
-7
@@ -75,9 +75,9 @@ Usage: fzf [options]
|
|||||||
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
|
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
|
||||||
Add '+' to automatically increase the value
|
Add '+' to automatically increase the value
|
||||||
according to the other layout options (default: 10+).
|
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[%]]
|
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||||
[,border-native] (default: center,50%)
|
[,border-native|border-fzf] (default: center,50%)
|
||||||
--tmux[=OPTS] Alias for --popup
|
--tmux[=OPTS] Alias for --popup
|
||||||
|
|
||||||
LAYOUT
|
LAYOUT
|
||||||
@@ -335,12 +335,20 @@ const (
|
|||||||
posNext // adjacent to the input section, on the list side
|
posNext // adjacent to the input section, on the list side
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type tmuxBorder int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tmuxBorderAuto tmuxBorder = iota
|
||||||
|
tmuxBorderNative
|
||||||
|
tmuxBorderFzf
|
||||||
|
)
|
||||||
|
|
||||||
type tmuxOptions struct {
|
type tmuxOptions struct {
|
||||||
width sizeSpec
|
width sizeSpec
|
||||||
height sizeSpec
|
height sizeSpec
|
||||||
position windowPosition
|
position windowPosition
|
||||||
index int
|
index int
|
||||||
border bool
|
border tmuxBorder
|
||||||
}
|
}
|
||||||
|
|
||||||
type layoutType int
|
type layoutType int
|
||||||
@@ -427,15 +435,19 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
|
|||||||
var err error
|
var err error
|
||||||
opts := defaultTmuxOptions(index)
|
opts := defaultTmuxOptions(index)
|
||||||
tokens := splitRegexp.Split(arg, -1)
|
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 {
|
if len(tokens) == 0 || len(tokens) > 4 {
|
||||||
return nil, errorToReturn
|
return nil, errorToReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, token := range tokens {
|
for i, token := range tokens {
|
||||||
if token == "border-native" {
|
if token == "border-native" || token == "border-fzf" {
|
||||||
tokens = append(tokens[:i], tokens[i+1:]...) // cut the 'border-native' option
|
tokens = append(tokens[:i], tokens[i+1:]...) // cut the border option
|
||||||
opts.border = true
|
if token == "border-native" {
|
||||||
|
opts.border = tmuxBorderNative
|
||||||
|
} else {
|
||||||
|
opts.border = tmuxBorderFzf
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -26,14 +26,14 @@ func escapeSingleQuote(str string) string {
|
|||||||
func popupArgStr(args []string, opts *Options) (string, string) {
|
func popupArgStr(args []string, opts *Options) (string, string) {
|
||||||
fzf, rest := args[0], args[1:]
|
fzf, rest := args[0], args[1:]
|
||||||
args = []string{"--bind=ctrl-z:ignore"}
|
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 {
|
if tui.DefaultBorderShape == tui.BorderRounded {
|
||||||
rest = append(rest, "--border=rounded")
|
rest = append(rest, "--border=rounded")
|
||||||
} else {
|
} else {
|
||||||
rest = append(rest, "--border=sharp")
|
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")
|
args = append(args, "--margin=0,1")
|
||||||
}
|
}
|
||||||
argStr := escapeSingleQuote(fzf)
|
argStr := escapeSingleQuote(fzf)
|
||||||
|
|||||||
+136
-1
@@ -1,10 +1,145 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"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) {
|
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)
|
argStr, dir := popupArgStr(args, opts)
|
||||||
|
|
||||||
// Set tmux options for popup placement
|
// 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
|
// W Both The window position on the status line
|
||||||
// S -y The line above or below the status line
|
// S -y The line above or below the status line
|
||||||
tmuxArgs := []string{"display-popup", "-E", "-d", dir}
|
tmuxArgs := []string{"display-popup", "-E", "-d", dir}
|
||||||
if !opts.Tmux.border {
|
if opts.Tmux.border != tmuxBorderNative {
|
||||||
tmuxArgs = append(tmuxArgs, "-B")
|
tmuxArgs = append(tmuxArgs, "-B")
|
||||||
}
|
}
|
||||||
switch opts.Tmux.position {
|
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",
|
"run", "--floating", "--close-on-exit", "--block-until-exit",
|
||||||
"--cwd", dir,
|
"--cwd", dir,
|
||||||
}
|
}
|
||||||
if !opts.Tmux.border {
|
if opts.Tmux.border != tmuxBorderNative {
|
||||||
zellijArgs = append(zellijArgs, "--borderless", "true")
|
zellijArgs = append(zellijArgs, "--borderless", "true")
|
||||||
}
|
}
|
||||||
switch opts.Tmux.position {
|
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