diff --git a/bin/fzf-tmux b/bin/fzf-tmux index e66dcda..7136973 100755 --- a/bin/fzf-tmux +++ b/bin/fzf-tmux @@ -132,8 +132,10 @@ if [[ -z "$TMUX" ]]; then exit $? fi -# --height option is not allowed. CTRL-Z is also disabled. -args=("${args[@]}" "--no-height" "--bind=ctrl-z:ignore") +# * --height option is not allowed +# * CTRL-Z is also disabled +# * fzf-tmux script is not compatible with --tmux option in fzf 0.53.0 or later +args=("${args[@]}" "--no-height" "--bind=ctrl-z:ignore" "--no-tmux") # Handle zoomed tmux pane without popup options by moving it to a temp window if [[ ! "$opt" =~ "-E" ]] && tmux list-panes -F '#F' | grep -q Z; then diff --git a/main.go b/main.go index 7c76bef..1e05345 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,7 @@ func printScript(label string, content []byte) { } func exit(code int, err error) { - if err != nil { + if code == fzf.ExitError { fmt.Fprintln(os.Stderr, err.Error()) } os.Exit(code) diff --git a/man/man1/fzf-tmux.1 b/man/man1/fzf-tmux.1 index 11161e4..3ab36d4 100644 --- a/man/man1/fzf-tmux.1 +++ b/man/man1/fzf-tmux.1 @@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. .. -.TH fzf-tmux 1 "May 2024" "fzf 0.52.1" "fzf-tmux - open fzf in tmux split pane" +.TH fzf-tmux 1 "May 2024" "fzf 0.53.0" "fzf-tmux - open fzf in tmux split pane" .SH NAME fzf-tmux - open fzf in tmux split pane diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 0f99739..09d7e89 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. .. -.TH fzf 1 "May 2024" "fzf 0.52.1" "fzf - a command-line fuzzy finder" +.TH fzf 1 "May 2024" "fzf 0.53.0" "fzf - a command-line fuzzy finder" .SH NAME fzf - a command-line fuzzy finder @@ -215,6 +215,24 @@ compatible with a negative height value. .BI "--min-height=" "HEIGHT" Minimum height when \fB--height\fR is given in percent (default: 10). Ignored when \fB--height\fR is not specified. +.TP +.BI "--tmux=" "[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]" +Start fzf in a tmux popup. Requires tmux 3.3 or later. This option is ignored +if you are not running fzf inside tmux. + +e.g. + \fB# Popup in the center with 80% width height + fzf --tmux 80% + + # Popup on the left with 40% width and 100% height + fzf --tmux right,40% + + # Popup on the bottom with 100% width and 30% height + fzf --tmux bottom,30% + + # Popup on the top with 80% width and 40% height + fzf --tmux top,80%,40%\fR + .TP .BI "--layout=" "LAYOUT" Choose the layout (default: default) diff --git a/plugin/fzf.vim b/plugin/fzf.vim index fc7b196..51ed138 100644 --- a/plugin/fzf.vim +++ b/plugin/fzf.vim @@ -537,10 +537,10 @@ try let use_term = 0 endif if use_term - let optstr .= ' --no-height' + let optstr .= ' --no-height --no-tmux' elseif use_height let height = s:calc_size(&lines, dict.down, dict) - let optstr .= ' --height='.height + let optstr .= ' --no-tmux --height='.height endif " Respect --border option given in $FZF_DEFAULT_OPTS and 'options' let optstr = join([s:border_opt(get(dict, 'window', 0)), s:extract_option($FZF_DEFAULT_OPTS, 'border'), optstr]) diff --git a/src/core.go b/src/core.go index 2a07b82..17b7d1d 100644 --- a/src/core.go +++ b/src/core.go @@ -2,6 +2,7 @@ package fzf import ( + "os" "sync" "time" @@ -19,6 +20,10 @@ Matcher -> EvtHeader -> Terminal (update header) // Run starts fzf func Run(opts *Options) (int, error) { + if opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 { + return runTmux(os.Args[1:], opts) + } + if err := postProcessOptions(opts); err != nil { return ExitError, err } diff --git a/src/functions.go b/src/functions.go index f16371a..8d29db7 100644 --- a/src/functions.go +++ b/src/functions.go @@ -7,7 +7,7 @@ import ( ) func writeTemporaryFile(data []string, printSep string) string { - f, err := os.CreateTemp("", "fzf-preview-*") + f, err := os.CreateTemp("", "fzf-temp-*") if err != nil { // Unable to create temporary file // FIXME: Should we terminate the program? diff --git a/src/options.go b/src/options.go index 0265693..1024038 100644 --- a/src/options.go +++ b/src/options.go @@ -63,6 +63,8 @@ const Usage = `usage: fzf [options] according to the input size. --min-height=HEIGHT Minimum height when --height is given in percent (default: 10) + --tmux=OPTS Start fzf in a tmux popup (requires tmux 3.3+) + [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] --layout=LAYOUT Choose layout: [default|reverse|reverse-list] --border[=STYLE] Draw border around the finder [rounded|sharp|bold|block|thinblock|double|horizontal|vertical| @@ -180,6 +182,13 @@ type sizeSpec struct { percent bool } +func (s sizeSpec) String() string { + if s.percent { + return fmt.Sprintf("%d%%", int(s.size)) + } + return fmt.Sprintf("%d", int(s.size)) +} + func defaultMargin() [4]sizeSpec { return [4]sizeSpec{} } @@ -199,8 +208,15 @@ const ( posDown posLeft posRight + posCenter ) +type tmuxOptions struct { + width sizeSpec + height sizeSpec + position windowPosition +} + type layoutType int const ( @@ -248,6 +264,74 @@ func (o *previewOpts) Toggle() { o.hidden = !o.hidden } +func parseTmuxOptions(arg string) (*tmuxOptions, error) { + var err error + opts := tmuxOptions{} + tokens := splitRegexp.Split(arg, -1) + if len(tokens) == 0 || len(tokens) > 3 { + return nil, errors.New("invalid tmux option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]])") + } + + // Defaults to 'center' + switch tokens[0] { + case "top", "up": + opts.position = posUp + opts.width = sizeSpec{100, true} + case "bottom", "down": + opts.position = posDown + opts.width = sizeSpec{100, true} + case "left": + opts.position = posLeft + opts.height = sizeSpec{100, true} + case "right": + opts.position = posRight + opts.height = sizeSpec{100, true} + case "center": + opts.position = posCenter + opts.width = sizeSpec{50, true} + opts.height = sizeSpec{50, true} + default: + opts.position = posCenter + opts.width = sizeSpec{50, true} + opts.height = sizeSpec{50, true} + tokens = append([]string{"center"}, tokens...) + } + + // One size given + var size1 sizeSpec + if len(tokens) > 1 { + if size1, err = parseSize(tokens[1], 100, "size"); err != nil { + return nil, err + } + } + + // Two sizes given + var size2 sizeSpec + if len(tokens) == 3 { + if size2, err = parseSize(tokens[2], 100, "size"); err != nil { + return nil, err + } + opts.width = size1 + opts.height = size2 + } else if len(tokens) == 2 { + switch tokens[0] { + case "top", "up": + opts.height = size1 + case "bottom", "down": + opts.height = size1 + case "left": + opts.width = size1 + case "right": + opts.width = size1 + case "center": + opts.width = size1 + opts.height = size1 + } + } + + return &opts, nil +} + func parseLabelPosition(opts *labelOpts, arg string) error { opts.column = 0 opts.bottom = false @@ -296,6 +380,7 @@ type walkerOpts struct { type Options struct { Input chan string Output chan string + Tmux *tmuxOptions Bash bool Zsh bool Fish bool @@ -1787,6 +1872,16 @@ func parseOptions(opts *Options, allArgs []string) error { case "--version": clearExitingOpts() opts.Version = true + case "--tmux": + str, err := nextString(allArgs, &i, "tmux options required") + if err != nil { + return err + } + if opts.Tmux, err = parseTmuxOptions(str); err != nil { + return err + } + case "--no-tmux": + opts.Tmux = nil case "-x", "--extended": opts.Extended = true case "-e", "--exact": @@ -2264,6 +2359,10 @@ func parseOptions(opts *Options, allArgs []string) error { if opts.FuzzyAlgo, err = parseAlgo(value); err != nil { return err } + } else if match, value := optString(arg, "--tmux="); match { + if opts.Tmux, err = parseTmuxOptions(value); err != nil { + return err + } } else if match, value := optString(arg, "--scheme="); match { opts.Scheme = strings.ToLower(value) } else if match, value := optString(arg, "-q", "--query="); match { @@ -2478,6 +2577,10 @@ func postProcessOptions(opts *Options) error { uniseg.EastAsianAmbiguousWidth = 2 } + if opts.BorderShape == tui.BorderUndefined { + opts.BorderShape = tui.BorderNone + } + if err := validateSign(opts.Pointer, "pointer"); err != nil { return err } diff --git a/src/tmux.go b/src/tmux.go new file mode 100644 index 0000000..ea1816a --- /dev/null +++ b/src/tmux.go @@ -0,0 +1,149 @@ +package fzf + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/junegunn/fzf/src/tui" + "github.com/junegunn/fzf/src/util" +) + +func escapeSingleQuote(str string) string { + return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'" +} + +func runTmux(args []string, opts *Options) (int, error) { + ns := time.Now().UnixNano() + + output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-tmux-output-%d", ns)) + if err := mkfifo(output, 0666); err != nil { + return ExitError, err + } + defer os.Remove(output) + + // Find fzf executable + fzf := "fzf" + if found, err := os.Executable(); err == nil { + fzf = found + } + + // Prepare arguments + args = append([]string{"--bind=ctrl-z:ignore"}, args...) + if opts.BorderShape == tui.BorderUndefined { + args = append(args, "--border") + } + args = append(args, "--no-height") + args = append(args, "--no-tmux") + argStr := "" + for _, arg := range args { + // %q formatting escapes $'foo\nbar' to "foo\nbar" + argStr += " " + escapeSingleQuote(arg) + } + + // Build command + var command string + if opts.Input == nil && util.IsTty() { + command = fmt.Sprintf(`%q%s > %q`, fzf, argStr, output) + } else { + input := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-tmux-input-%d", ns)) + if err := mkfifo(input, 0644); err != nil { + return ExitError, err + } + defer os.Remove(input) + + go func() { + inputFile, err := os.OpenFile(input, os.O_WRONLY, 0) + if err != nil { + return + } + if opts.Input == nil { + io.Copy(inputFile, os.Stdin) + } else { + for item := range opts.Input { + fmt.Fprint(inputFile, item+opts.PrintSep) + } + } + inputFile.Close() + }() + + command = fmt.Sprintf(`%q%s < %q > %q`, fzf, argStr, input, output) + } + + // Get current directory + dir, err := os.Getwd() + if err != nil { + dir = "." + } + + // Set tmux options for popup placement + // C Both The centre of the terminal + // R -x The right side of the terminal + // P Both The bottom left of the pane + // M Both The mouse position + // W Both The window position on the status line + // S -y The line above or below the status line + tmuxArgs := []string{"display-popup", "-E", "-B", "-d", dir} + switch opts.Tmux.position { + case posUp: + tmuxArgs = append(tmuxArgs, "-xC", "-y0") + case posDown: + tmuxArgs = append(tmuxArgs, "-xC", "-yS") + case posLeft: + tmuxArgs = append(tmuxArgs, "-x0", "-yC") + case posRight: + tmuxArgs = append(tmuxArgs, "-xR", "-yC") + case posCenter: + tmuxArgs = append(tmuxArgs, "-xC", "-yC") + } + tmuxArgs = append(tmuxArgs, "-w"+opts.Tmux.width.String()) + tmuxArgs = append(tmuxArgs, "-h"+opts.Tmux.height.String()) + + // To ensure that the options are processed by a POSIX-compliant shell, + // we need to write the command to a temporary file and execute it with sh. + exports := os.Environ() + for idx, pairStr := range exports { + pair := strings.SplitN(pairStr, "=", 2) + exports[idx] = fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])) + } + temp := writeTemporaryFile(append(exports, command), "\n") + defer os.Remove(temp) + tmuxArgs = append(tmuxArgs, "sh", temp) + + // Take the output + go func() { + outputFile, err := os.OpenFile(output, os.O_RDONLY, 0) + if err != nil { + return + } + if opts.Output == nil { + io.Copy(os.Stdout, outputFile) + } else { + reader := bufio.NewReader(outputFile) + sep := opts.PrintSep[0] + for { + item, err := reader.ReadString(sep) + if err != nil { + break + } + opts.Output <- item + } + } + + outputFile.Close() + }() + + cmd := exec.Command("tmux", tmuxArgs...) + if err := cmd.Run(); err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + return exitError.ExitCode(), err + } + } + + return ExitOk, nil +} diff --git a/src/tmux_unix.go b/src/tmux_unix.go new file mode 100644 index 0000000..f6ddd58 --- /dev/null +++ b/src/tmux_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package fzf + +import "golang.org/x/sys/unix" + +func mkfifo(path string, mode uint32) error { + return unix.Mkfifo(path, mode) +} diff --git a/src/tmux_windows.go b/src/tmux_windows.go new file mode 100644 index 0000000..bd35636 --- /dev/null +++ b/src/tmux_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package fzf + +import ( + "os/exec" + "strconv" +) + +func mkfifo(path string, mode uint32) error { + m := strconv.FormatUint(uint64(mode), 8) + cmd := exec.Command("mkfifo", "-m", m, path) + if err := cmd.Run(); err != nil { + return err + } + return nil +} diff --git a/src/tui/tui.go b/src/tui/tui.go index aed41a9..ee49774 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -356,7 +356,8 @@ type MouseEvent struct { type BorderShape int const ( - BorderNone BorderShape = iota + BorderUndefined BorderShape = iota + BorderNone BorderRounded BorderSharp BorderBold