@ 261102.1

260729.2 · 50e6c026ec23df76de316cca9fe2d9eea56cb38f

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

reading declarations

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

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

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], "%%") {

src/proxy_test.go

new file mode 100644@@ -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)+}

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:

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) {

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

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

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