Files
fzf/src/proxy.go
T
Junegunn Choi 50e6c026ec Separate floating pane border label from pane title
--border-label of a tmux floating pane was stored in the pane title and
read back by pane-border-format as '#{pane_title}', so any program in the
pane could replace the border text by setting the title. On Zellij the
name was only set when the pane was created. Either way the built-in
label actions could not touch it, as fzf draws no border of its own
there.

- tmux: hold the label in pane-scoped @fzf-border-label. Set the option
  even without a label, so a later change-border-label has somewhere to
  write. Drop select-pane -T; the pane title is left to the user
- Zellij: run 'zellij action rename-pane' on label change. 'zellij run'
  has no option to set the environment of the new pane, so the command
  exports the target itself
- change-border-label and transform-border-label now update the native
  border, through the setter nativeLabelSetter picks by multiplexer
- The pane is named in a __FZF_INTERNAL_ variable that runProxy withholds
  from the environment replay, so the same name in the outer environment
  cannot redirect the update to another pane
2026-07-29 20:59:52 +09:00

268 lines
6.7 KiB
Go

package fzf
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/junegunn/fzf/src/tui"
"github.com/junegunn/fzf/src/util"
)
const becomeSuffix = ".become"
// Withheld from the environment replay below, so an outer value cannot
// override what the floating pane set
const internalEnvPrefix = "__FZF_INTERNAL_"
func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
}
// Read and remove, so preview and execute commands do not inherit it
func takeEnv(name string) string {
value := os.Getenv(name)
os.Unsetenv(name)
return value
}
// Applies label updates off the event loop, which would block on the fork
type labelUpdater struct {
set func(string)
mu sync.Mutex
pending *string
running bool
}
// A queue of one: a burst collapses to the newest label. A goroutine per
// update could apply them out of order
func (u *labelUpdater) update(label string) {
u.mu.Lock()
defer u.mu.Unlock()
u.pending = &label
if u.running {
return
}
u.running = true
go u.run()
}
// Ends once caught up, so nothing needs cancelling when Run returns
func (u *labelUpdater) run() {
for {
u.mu.Lock()
label := u.pending
if label == nil {
u.running = false
u.mu.Unlock()
return
}
u.pending = nil
u.mu.Unlock()
u.set(*label)
}
}
// Updates the label on the native border, or nil when it is not fzf's to update
func nativeLabelSetter() func(string) {
// Read both, so neither is left behind
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
var set func(string)
switch {
case tmuxPane != "":
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
case zellijPane != "":
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
default:
return nil
}
updater := &labelUpdater{set: set}
return updater.update
}
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 tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
for _, arg := range append(args, rest...) {
argStr += " " + escapeSingleQuote(arg)
}
argStr += ` --no-popup --no-height`
dir, err := os.Getwd()
if err != nil {
dir = "."
}
return argStr, dir
}
func fifo(name string) (string, error) {
ns := time.Now().UnixNano()
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
output, err := mkfifo(output, 0600)
if err != nil {
return output, err
}
return output, nil
}
func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool) (*exec.Cmd, error), opts *Options, withExports bool) (int, error) {
output, err := fifo("proxy-output")
if err != nil {
return ExitError, err
}
defer os.Remove(output)
// Take the output
go func() {
withOutputPipe(output, func(outputFile io.ReadCloser) {
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
}
}
})
}()
var command, input string
commandPrefix += ` --no-force-tty-in --proxy-script "$0"`
if opts.Input == nil && (opts.ForceTtyIn || util.IsTty(os.Stdin)) {
command = fmt.Sprintf(`%s > %q`, commandPrefix, output)
} else {
input, err = fifo("proxy-input")
if err != nil {
return ExitError, err
}
defer os.Remove(input)
go func() {
withInputPipe(input, func(inputFile io.WriteCloser) {
if opts.Input == nil {
io.Copy(inputFile, os.Stdin)
} else {
for item := range opts.Input {
fmt.Fprint(inputFile, item+opts.PrintSep)
}
}
})
}()
if withExports {
command = fmt.Sprintf(`%s < %q > %q`, commandPrefix, input, output)
} else {
// For mintty: cannot directly read named pipe from Go code
command = fmt.Sprintf(`command cat %q | %s > %q`, input, commandPrefix, output)
}
}
// Write the command to a temporary file and run it with sh to ensure POSIX compliance.
var exports []string
needBash := false
if withExports {
// Nullify FZF_DEFAULT_* variables as tmux popup may inject them even when undefined.
exports = []string{"FZF_DEFAULT_COMMAND=", "FZF_DEFAULT_OPTS=", "FZF_DEFAULT_OPTS_FILE="}
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
for _, pairStr := range os.Environ() {
pair := strings.SplitN(pairStr, "=", 2)
if strings.HasPrefix(pair[0], internalEnvPrefix) {
continue
}
if validIdentifier.MatchString(pair[0]) {
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
name := pair[0][10 : len(pair[0])-2]
exports = append(exports, name+pair[1])
exports = append(exports, "export -f "+name)
needBash = true
}
}
}
temp := WriteTemporaryFile(append(exports, command), "\n")
defer os.Remove(temp)
// Pre-create the become file so that another user on a shared TMPDIR
// cannot plant a file or a symbolic link at the predictable path while
// fzf is running
becomeFile := temp + becomeSuffix
becomeF, err := os.OpenFile(becomeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return ExitError, err
}
becomeF.Close()
defer os.Remove(becomeFile)
cmd, err := cmdBuilder(temp, needBash)
if err != nil {
return ExitError, err
}
cmd.Stderr = os.Stderr
intChan := make(chan os.Signal, 1)
defer close(intChan)
go func() {
if sig, valid := <-intChan; valid {
cmd.Process.Signal(sig)
}
}()
signal.Notify(intChan, os.Interrupt)
if err := cmd.Run(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
code := exitError.ExitCode()
if code == ExitBecome {
data, err := os.ReadFile(becomeFile)
os.Remove(becomeFile)
if err != nil {
return ExitError, err
}
if len(data) == 0 {
return ExitError, errors.New("invalid become command")
}
elems := strings.Split(string(data), "\x00")
command := elems[0]
env := []string{}
if len(elems) > 1 {
env = elems[1:]
}
executor := util.NewExecutor(opts.WithShell)
ttyin, err := tui.TtyIn(opts.TtyDefault)
if err != nil {
return ExitError, err
}
os.Remove(temp)
os.Remove(input)
os.Remove(output)
executor.Become(ttyin, env, command)
}
return code, err
}
}
return ExitOk, nil
}