Add --tmux option to replace fzf-tmux script

This commit is contained in:
Junegunn Choi 2024-05-10 01:40:56 +09:00
parent 01e7668915
commit 83b6033906
12 changed files with 313 additions and 9 deletions

View File

@ -132,8 +132,10 @@ if [[ -z "$TMUX" ]]; then
exit $? exit $?
fi fi
# --height option is not allowed. CTRL-Z is also disabled. # * --height option is not allowed
args=("${args[@]}" "--no-height" "--bind=ctrl-z:ignore") # * 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 # 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 if [[ ! "$opt" =~ "-E" ]] && tmux list-panes -F '#F' | grep -q Z; then

View File

@ -35,7 +35,7 @@ func printScript(label string, content []byte) {
} }
func exit(code int, err error) { func exit(code int, err error) {
if err != nil { if code == fzf.ExitError {
fmt.Fprintln(os.Stderr, err.Error()) fmt.Fprintln(os.Stderr, err.Error())
} }
os.Exit(code) os.Exit(code)

View File

@ -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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. 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 .SH NAME
fzf-tmux - open fzf in tmux split pane fzf-tmux - open fzf in tmux split pane

View File

@ -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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. 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 .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@ -215,6 +215,24 @@ compatible with a negative height value.
.BI "--min-height=" "HEIGHT" .BI "--min-height=" "HEIGHT"
Minimum height when \fB--height\fR is given in percent (default: 10). Minimum height when \fB--height\fR is given in percent (default: 10).
Ignored when \fB--height\fR is not specified. 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 .TP
.BI "--layout=" "LAYOUT" .BI "--layout=" "LAYOUT"
Choose the layout (default: default) Choose the layout (default: default)

View File

@ -537,10 +537,10 @@ try
let use_term = 0 let use_term = 0
endif endif
if use_term if use_term
let optstr .= ' --no-height' let optstr .= ' --no-height --no-tmux'
elseif use_height elseif use_height
let height = s:calc_size(&lines, dict.down, dict) let height = s:calc_size(&lines, dict.down, dict)
let optstr .= ' --height='.height let optstr .= ' --no-tmux --height='.height
endif endif
" Respect --border option given in $FZF_DEFAULT_OPTS and 'options' " 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]) let optstr = join([s:border_opt(get(dict, 'window', 0)), s:extract_option($FZF_DEFAULT_OPTS, 'border'), optstr])

View File

@ -2,6 +2,7 @@
package fzf package fzf
import ( import (
"os"
"sync" "sync"
"time" "time"
@ -19,6 +20,10 @@ Matcher -> EvtHeader -> Terminal (update header)
// Run starts fzf // Run starts fzf
func Run(opts *Options) (int, error) { 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 { if err := postProcessOptions(opts); err != nil {
return ExitError, err return ExitError, err
} }

View File

@ -7,7 +7,7 @@ import (
) )
func writeTemporaryFile(data []string, printSep string) string { func writeTemporaryFile(data []string, printSep string) string {
f, err := os.CreateTemp("", "fzf-preview-*") f, err := os.CreateTemp("", "fzf-temp-*")
if err != nil { if err != nil {
// Unable to create temporary file // Unable to create temporary file
// FIXME: Should we terminate the program? // FIXME: Should we terminate the program?

View File

@ -63,6 +63,8 @@ const Usage = `usage: fzf [options]
according to the input size. according to the input size.
--min-height=HEIGHT Minimum height when --height is given in percent --min-height=HEIGHT Minimum height when --height is given in percent
(default: 10) (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] --layout=LAYOUT Choose layout: [default|reverse|reverse-list]
--border[=STYLE] Draw border around the finder --border[=STYLE] Draw border around the finder
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
@ -180,6 +182,13 @@ type sizeSpec struct {
percent bool 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 { func defaultMargin() [4]sizeSpec {
return [4]sizeSpec{} return [4]sizeSpec{}
} }
@ -199,8 +208,15 @@ const (
posDown posDown
posLeft posLeft
posRight posRight
posCenter
) )
type tmuxOptions struct {
width sizeSpec
height sizeSpec
position windowPosition
}
type layoutType int type layoutType int
const ( const (
@ -248,6 +264,74 @@ func (o *previewOpts) Toggle() {
o.hidden = !o.hidden 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 { func parseLabelPosition(opts *labelOpts, arg string) error {
opts.column = 0 opts.column = 0
opts.bottom = false opts.bottom = false
@ -296,6 +380,7 @@ type walkerOpts struct {
type Options struct { type Options struct {
Input chan string Input chan string
Output chan string Output chan string
Tmux *tmuxOptions
Bash bool Bash bool
Zsh bool Zsh bool
Fish bool Fish bool
@ -1787,6 +1872,16 @@ func parseOptions(opts *Options, allArgs []string) error {
case "--version": case "--version":
clearExitingOpts() clearExitingOpts()
opts.Version = true 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": case "-x", "--extended":
opts.Extended = true opts.Extended = true
case "-e", "--exact": case "-e", "--exact":
@ -2264,6 +2359,10 @@ func parseOptions(opts *Options, allArgs []string) error {
if opts.FuzzyAlgo, err = parseAlgo(value); err != nil { if opts.FuzzyAlgo, err = parseAlgo(value); err != nil {
return err 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 { } else if match, value := optString(arg, "--scheme="); match {
opts.Scheme = strings.ToLower(value) opts.Scheme = strings.ToLower(value)
} else if match, value := optString(arg, "-q", "--query="); match { } else if match, value := optString(arg, "-q", "--query="); match {
@ -2478,6 +2577,10 @@ func postProcessOptions(opts *Options) error {
uniseg.EastAsianAmbiguousWidth = 2 uniseg.EastAsianAmbiguousWidth = 2
} }
if opts.BorderShape == tui.BorderUndefined {
opts.BorderShape = tui.BorderNone
}
if err := validateSign(opts.Pointer, "pointer"); err != nil { if err := validateSign(opts.Pointer, "pointer"); err != nil {
return err return err
} }

149
src/tmux.go Normal file
View File

@ -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
}

9
src/tmux_unix.go Normal file
View File

@ -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)
}

17
src/tmux_windows.go Normal file
View File

@ -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
}

View File

@ -356,7 +356,8 @@ type MouseEvent struct {
type BorderShape int type BorderShape int
const ( const (
BorderNone BorderShape = iota BorderUndefined BorderShape = iota
BorderNone
BorderRounded BorderRounded
BorderSharp BorderSharp
BorderBold BorderBold