diff --git a/CHANGELOG.md b/CHANGELOG.md index eb353217..ad627c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ CHANGELOG 0.74.2 ------ +- `change-border-label` and `transform-border-label` now update the label on the native border of a tmux or Zellij floating pane + - On tmux, the label no longer goes through the pane title, so a program running in the pane cannot replace it by setting the title +- Fixed a Kitty graphics sequence from a preview command being taken by tmux as a request to set the pane title; it is now passed through to the terminal - Performance optimizations for short queries - The most critical case: fzf narrows results as you type, so short queries scan the largest candidate sets and the first keystroke scans the whole input. - Single-character queries are up to 2.4x faster diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 2e96ce5a..c88b0396 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -426,9 +426,13 @@ On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can switch to other panes and windows while fzf is running, and move and resize the pane with the mouse. The native border of the pane is the handle for moving and resizing it, so it is used by default and \fBborder\-native\fR is -implied. On tmux, \fB\-\-border\-label\fR is set as the title of the pane, -and is displayed on the border if \fBpane\-border\-status\fR is enabled in -tmux (\fB\-\-border\-label\-pos\fR is ignored). +implied. \fB\-\-border\-label\fR is displayed on the native border, and +\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it +(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in +the \fB@fzf\-border\-label\fR option of the pane and sets its +\fBpane\-border\-format\fR to read it back, so the label is displayed if +\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left +alone. On Zellij, the label is the name of the pane. fzf draws its own border instead when a border style is explicitly specified with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and diff --git a/src/proxy.go b/src/proxy.go index 866cc40a..e81205fa 100644 --- a/src/proxy.go +++ b/src/proxy.go @@ -11,6 +11,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" "github.com/junegunn/fzf/src/tui" @@ -19,10 +20,75 @@ import ( 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"} @@ -125,6 +191,9 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool) 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], "%%") { diff --git a/src/proxy_test.go b/src/proxy_test.go new file mode 100644 index 00000000..2cbec532 --- /dev/null +++ b/src/proxy_test.go @@ -0,0 +1,65 @@ +package fzf + +import ( + "strconv" + "sync" + "testing" + "time" +) + +func TestLabelUpdater(t *testing.T) { + var mu sync.Mutex + applied := []int{} + // The goroutine can still be running after the test function returns, so + // it records the label instead of reporting it, and -1 stands in for one + // that could not be parsed + updater := &labelUpdater{set: func(label string) { + n, err := strconv.Atoi(label) + if err != nil { + n = -1 + } + mu.Lock() + applied = append(applied, n) + mu.Unlock() + // Long enough for the burst to pile up behind the first update + time.Sleep(time.Millisecond) + }} + + const last = 99 + for i := 0; i <= last; i++ { + updater.update(strconv.Itoa(i)) + } + + // However many updates are dropped, the final one is always applied + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + done := len(applied) > 0 && applied[len(applied)-1] == last + mu.Unlock() + if done { + break + } + if time.Now().After(deadline) { + t.Fatalf("last update was not applied: %v", applied) + } + time.Sleep(time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + // Updates are never applied out of order, so the border cannot be left + // showing a label older than the last one requested + for i, n := range applied { + if i > 0 && n <= applied[i-1] { + t.Errorf("applied out of order: %v", applied) + break + } + } + // A burst collapses instead of running one command per update. The + // updates are queued in a tight loop while each one takes a millisecond + // to apply, so all of them landing would mean no coalescing at all + if len(applied) >= last+1 { + t.Errorf("expected updates to be coalesced, applied all %d", len(applied)) + } + t.Logf("applied %d of %d updates", len(applied), last+1) +} diff --git a/src/terminal.go b/src/terminal.go index c84970c4..ea748f16 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -470,6 +470,7 @@ type Terminal struct { clickFooterLine int clickFooterColumn int proxyScript string + setNativeLabel func(string) numLinesCache map[int32]numLinesCacheValue raw bool lastActivity time.Time @@ -1142,6 +1143,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor printer: opts.Printer, printsep: opts.PrintSep, proxyScript: opts.ProxyScript, + setNativeLabel: nativeLabelSetter(), merger: em, passMerger: em, resultMerger: em, @@ -7272,6 +7274,10 @@ func (t *Terminal) Loop() error { if t.border != nil { t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false) req(reqRedrawBorderLabel) + } else if t.setNativeLabel != nil { + // fzf draws no border of its own; the label is on the + // native border of the floating pane + t.setNativeLabel(label) } }) case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel: diff --git a/src/tmux.go b/src/tmux.go index 4b31fdff..ac952f85 100644 --- a/src/tmux.go +++ b/src/tmux.go @@ -55,12 +55,26 @@ func escapeTmuxSeparator(str string) string { return str } -// Escape a string for use as the pane title; select-pane -T expands format -// expressions denoted by '#', but not time conversion specifiers -func escapeTmuxTitle(str string) string { +// '#[...]' is still processed when a substituted value is drawn, so '#' is +// doubled. '#{...}' is already literal +func escapeTmuxFormat(str string) string { return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##")) } +// The option pane-border-format reads back, and the pane it is set on +const ( + tmuxBorderLabelOption = "@fzf-border-label" + tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE" +) + +// Errors are ignored. The pane may be gone +func setTmuxBorderLabel(pane string, label string) { + // Strip ANSI sequences. tmux would draw the escape's remainder as text + label, _, _ = extractColor(label, nil, nil) + exec.Command("tmux", "set-option", "-p", "-t", pane, + tmuxBorderLabelOption, escapeTmuxFormat(label)).Run() +} + // 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 { @@ -128,35 +142,28 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh // remain-on-exit is on. setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; ` - // Set --border-label as the title of the floating pane, and as its - // pane-border-format so that it is displayed on the border when - // pane-border-status is enabled. Without a label, the border text - // is cleared so that the default pane status content (e.g. the - // pane title) is not shown. pane-border-format is pane-scoped, - // but pane-border-status is a window option that only becomes - // pane-scoped in the next release of tmux, so it is left alone. + // --border-label lives in a pane-scoped user option that + // pane-border-format reads back, so a program in the pane cannot + // replace it by setting the title. Set even when empty, so + // change-border-label can fill it in and the default pane status is + // not shown. pane-border-status is a window option until the next + // tmux release, so it is left alone. // https://github.com/tmux/tmux/commit/7a18fa281db3 - // --border-label-pos is ignored. - // The label is left to fzf when it draws its own border with the - // label on it. '--border=none' is not the case; fzf would not - // display the label, but the native border of a floating pane - // cannot be removed, so display the label on it nonetheless. - format := "" - if opts.BorderLabel.label != "" && - (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine || - opts.BorderShape == tui.BorderNone) { + // --border-label-pos is ignored. With an explicit --border fzf draws + // the label itself, but the native border cannot be removed. + ownsLabel := noBorderSpecified(opts) + label := "" + if ownsLabel { // Strip ANSI sequences fzf would otherwise render itself - label, _, _ := extractColor(opts.BorderLabel.label, nil, nil) - if label != "" { - setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `, - escapeSingleQuote(escapeTmuxTitle(label))) - // The title is displayed verbatim; substituted values are - // not expanded again - format = "#{pane_title}" - } + label, _, _ = extractColor(opts.BorderLabel.label, nil, nil) } + setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `, + tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label))) setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `, - escapeSingleQuote(format)) + escapeSingleQuote("#{"+tmuxBorderLabelOption+"}")) + if ownsLabel { + setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv) + } paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s", setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal) // Unzoom the window first; creating a floating pane over a zoomed @@ -193,16 +200,20 @@ exit "$code"`, newPane, code, signal, signal, code, code, code) }, opts, true) } +// Whether no box border was asked for. 'none' and 'line' count as none. +// fzf draws no box for either +func noBorderSpecified(opts *Options) bool { + return opts.BorderShape == tui.BorderUndefined || + opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone +} + // Whether to use the multiplexer's native border for the floating pane. Its // native border is the handle that makes the pane movable and resizable with // the mouse, so it is the default; 'border-native' forces it. It is not used // when a border style is explicitly specified with --border, so that the -// fzf-drawn border is the only one shown. 'none' and 'line' are treated as no -// border; fzf draws no box for either, and 'line' only makes sense with -// --height. +// fzf-drawn border is the only one shown. func nativeBorder(opts *Options) bool { - return opts.Tmux.border || opts.BorderShape == tui.BorderUndefined || - opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone + return opts.Tmux.border || noBorderSpecified(opts) } func runTmux(args []string, opts *Options) (int, error) { diff --git a/src/tmux_test.go b/src/tmux_test.go index 3e9da617..3d57acd5 100644 --- a/src/tmux_test.go +++ b/src/tmux_test.go @@ -2,37 +2,45 @@ package fzf import "testing" -func TestEscapeTmuxTitle(t *testing.T) { +func TestEscapeTmuxFormat(t *testing.T) { for _, tc := range []struct { given string expected string }{ {"", ""}, {" fzf ", " fzf "}, - {"#", "##"}, - {"##", "####"}, - {" C# notes #S ", " C## notes ##S "}, {"100%", "100%"}, + // '#[' would be taken as a style directive when the label is drawn + {"#[fg=red]x", "##[fg=red]x"}, + // '#{' is already literal in a substituted value, but doubling it + // keeps a label that fzf renders itself looking the same + {"#{pane_id}", "##{pane_id}"}, + {" C# notes #S ", " C## notes ##S "}, {";", `\;`}, {"; rm", "; rm"}, - {" ; ", " ; "}, } { - if actual := escapeTmuxTitle(tc.given); actual != tc.expected { + if actual := escapeTmuxFormat(tc.given); actual != tc.expected { t.Errorf("expected %q, got %q", tc.expected, actual) } } } -func TestEscapeTmuxTitleSeparator(t *testing.T) { +func TestEscapeTmuxSeparator(t *testing.T) { for _, tc := range []struct { given string expected string }{ - {"#;", "##;"}, - {";#", ";##"}, + {"", ""}, + {" fzf ", " fzf "}, + {"#", "#"}, + {"#{pane_id}", "#{pane_id}"}, + {"100%", "100%"}, + {";", `\;`}, + {"; rm", "; rm"}, + {" ; ", " ; "}, {";;", ";;"}, } { - if actual := escapeTmuxTitle(tc.given); actual != tc.expected { + if actual := escapeTmuxSeparator(tc.given); actual != tc.expected { t.Errorf("expected %q, got %q", tc.expected, actual) } } diff --git a/src/zellij.go b/src/zellij.go index f9e2bb92..268b65ac 100644 --- a/src/zellij.go +++ b/src/zellij.go @@ -1,11 +1,20 @@ package fzf import ( + "fmt" "os/exec" - - "github.com/junegunn/fzf/src/tui" ) +const zellijBorderLabelEnv = internalEnvPrefix + "ZELLIJ_LABEL_PANE" + +// Errors are ignored. The pane may be gone +func setZellijBorderLabel(pane string, label string) { + // Strip ANSI sequences fzf would otherwise render itself + label, _, _ = extractColor(label, nil, nil) + // '--' so that a label starting with a hyphen is not parsed as a flag + exec.Command("zellij", "action", "rename-pane", "-p", pane, "--", label).Run() +} + func runZellij(args []string, opts *Options) (int, error) { // Use the native Zellij border by default, consistent with tmux, so that // the pane can be moved and resized with the mouse. Set before @@ -20,6 +29,7 @@ func runZellij(args []string, opts *Options) (int, error) { "run", "--floating", "--close-on-exit", "--block-until-exit", "--cwd", dir, } + ownsLabel := false if !opts.Tmux.border { zellijArgs = append(zellijArgs, "--borderless", "true") } else { @@ -33,8 +43,8 @@ func runZellij(args []string, opts *Options) (int, error) { // stripping ANSI sequences fzf would otherwise render itself. // --border-label-pos is ignored. label := "" - if opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine || - opts.BorderShape == tui.BorderNone { + ownsLabel = noBorderSpecified(opts) + if ownsLabel { label, _, _ = extractColor(opts.BorderLabel.label, nil, nil) } zellijArgs = append(zellijArgs, "--name="+label) @@ -60,7 +70,14 @@ func runZellij(args []string, opts *Options) (int, error) { if err != nil { return nil, err } - zellijArgs = append(zellijArgs, sh, temp) + if ownsLabel { + // 'zellij run' cannot set the new pane's environment, so the + // command exports it itself. exec avoids a lingering shell + zellijArgs = append(zellijArgs, sh, "-c", fmt.Sprintf(`export %s="$ZELLIJ_PANE_ID"; exec %s %s`, + zellijBorderLabelEnv, escapeSingleQuote(sh), escapeSingleQuote(temp))) + } else { + zellijArgs = append(zellijArgs, sh, temp) + } return exec.Command("zellij", zellijArgs...), nil }, opts, true) } diff --git a/test/test_tmux.rb b/test/test_tmux.rb index eebc4c66..d2c0b3d9 100644 --- a/test/test_tmux.rb +++ b/test/test_tmux.rb @@ -17,9 +17,11 @@ class TestTmux < TestInteractive 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 } - # Border text is cleared when no label is given - format = IO.popen(['tmux', 'show-options', '-p', '-t', floating_pane, 'pane-border-format'], &:read) - assert_includes format, "''" + # Border text is empty when no label is given + pane = floating_pane + refute_nil pane + assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format') + assert_equal '', pane_option(pane, '@fzf-border-label') tmux.send_keys '99' tmux.until { |lines| assert_equal 1, lines.match_count } tmux.send_keys :Enter @@ -40,10 +42,41 @@ class TestTmux < TestInteractive tmux.until { |lines| assert_equal 100, lines.item_count } pane = floating_pane refute_nil pane + # The label is held in a user option, not in the pane title, which is + # left to the user. '#' is doubled so that the label is drawn as it is + # written, instead of '#[...]' being taken as a style directive + assert_equal ' ##fzf-label 100% ', pane_option(pane, '@fzf-border-label') + assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format') title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read) - assert_equal ' #fzf-label 100% ', title.chomp - format = IO.popen(['tmux', 'show-options', '-p', '-t', pane, 'pane-border-format'], &:read) - assert_includes format, "\#{pane_title}" + refute_equal ' #fzf-label 100% ', title.chomp + tmux.send_keys :Enter + assert_equal '1', fzf_output + end + + def test_floating_pane_change_border_label + tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'space:change-border-label( #[fg=red] 100% )'))}", :Enter + tmux.until { |lines| assert_equal 100, lines.item_count } + pane = floating_pane + refute_nil pane + assert_equal '', pane_option(pane, '@fzf-border-label') + tmux.send_keys :Space + wait { assert_equal ' ##[fg=red] 100% ', pane_option(pane, '@fzf-border-label') } + tmux.send_keys :Enter + assert_equal '1', fzf_output + end + + # A program running in the pane owns the pane title, but not the label + def test_floating_pane_border_label_not_affected_by_title + tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' label ' --bind 'space:execute-silent(printf "\\033]2;hijacked\\033\\\\" > /dev/tty)'))}", :Enter + tmux.until { |lines| assert_equal 100, lines.item_count } + pane = floating_pane + refute_nil pane + tmux.send_keys :Space + wait do + title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read) + assert_equal 'hijacked', title.chomp + end + assert_equal ' label ', pane_option(pane, '@fzf-border-label') tmux.send_keys :Enter assert_equal '1', fzf_output end @@ -79,6 +112,12 @@ class TestTmux < TestInteractive private + # stderr is merged in so that an unset option, which prints 'invalid + # option' to stderr and nothing to stdout, is not read as an empty value + def pane_option(pane, name) + IO.popen(['tmux', 'show-options', '-p', '-t', pane, '-v', name], err: %i[child out], &:read).chomp + end + 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) }