mirror of
https://github.com/junegunn/fzf.git
synced 2026-10-15 02:31:17 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227e96f4ad | ||
|
|
c0a193e031 | ||
|
|
cbc11e4d95 | ||
|
|
f126ff2954 | ||
|
|
d8aeec3058 | ||
|
|
981f1539b0 | ||
|
|
25adb67453 | ||
|
|
33a3456921 | ||
|
|
b563e208d6 | ||
|
|
c009c106b9 | ||
|
|
b1be3a8be1 | ||
|
|
961793cf39 |
No files matched your search
@@ -3,10 +3,6 @@ name: Issue Template
|
||||
description: Report a problem or bug related to fzf to help us improve
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
@@ -32,7 +28,6 @@ body:
|
||||
- label: Linux
|
||||
- label: macOS
|
||||
- label: Windows
|
||||
- label: Etc.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@@ -41,9 +36,23 @@ body:
|
||||
- label: bash
|
||||
- label: zsh
|
||||
- label: fish
|
||||
- label: nushell
|
||||
- label: PowerShell
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem / Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: How did you run into this?
|
||||
description: |
|
||||
What were you actually trying to do? Include the command line or
|
||||
configuration from your real setup.
|
||||
|
||||
If you did not hit this in actual use, say how you found it
|
||||
(reading the code, automated analysis, etc.).
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,6 +1,21 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.74.5
|
||||
------
|
||||
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
|
||||
- On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window
|
||||
- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920)
|
||||
- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926)
|
||||
- Fixed a key lost when it arrived in the same read as an escape sequence
|
||||
- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930)
|
||||
- With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key
|
||||
- A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change
|
||||
- Security fixes
|
||||
- fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82)
|
||||
- zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm)
|
||||
- Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions
|
||||
|
||||
0.74.4
|
||||
------
|
||||
- Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899)
|
||||
|
||||
@@ -94,8 +94,12 @@ test: $(SOURCES)
|
||||
github.com/junegunn/fzf/src/tui \
|
||||
github.com/junegunn/fzf/src/util
|
||||
|
||||
# Run in a separate tmux server without user config, like CI (80x24)
|
||||
itest:
|
||||
ruby test/runner.rb
|
||||
@dir=$$(mktemp -d) && \
|
||||
env -u TMUX TMUX_TMPDIR=$$dir sh -c \
|
||||
'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \
|
||||
s=$$?; rm -rf $$dir; exit $$s
|
||||
|
||||
# Actively fuzz the matcher fast paths against the general algorithm.
|
||||
# Go fuzzes one target at a time, so iterate. Override duration with
|
||||
|
||||
+9
-5
@@ -7,7 +7,11 @@ triggered by a tag push.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update version in the following files and commit on `master`:
|
||||
1. Sign in at https://developer.apple.com/account and accept any pending
|
||||
agreements (e.g. an updated Apple Developer Program License Agreement).
|
||||
Otherwise notarization fails.
|
||||
|
||||
2. Update version in the following files and commit on `master`:
|
||||
- `CHANGELOG.md`
|
||||
- `main.go`
|
||||
- `install`
|
||||
@@ -15,10 +19,10 @@ triggered by a tag push.
|
||||
- `man/man1/fzf.1`
|
||||
- `man/man1/fzf-tmux.1`
|
||||
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
3. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.74.4
|
||||
make tag VERSION=0.74.5
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
@@ -29,10 +33,10 @@ triggered by a tag push.
|
||||
old version, so `/master/install` keeps resolving against existing
|
||||
binaries during the publish window.
|
||||
|
||||
3. The workflow fires on the tag push and pauses on the `release`
|
||||
4. The workflow fires on the tag push and pauses on the `release`
|
||||
environment gate. Approve it in the Actions tab to release.
|
||||
|
||||
4. After the GitHub release is published, fast-forward `master`:
|
||||
5. After the GitHub release is published, fast-forward `master`:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
|
||||
@@ -78,6 +78,7 @@ while [[ $# -gt 0 ]]; do
|
||||
fi
|
||||
if [[ ${#arg} -gt 2 ]]; then
|
||||
size="${arg:2}"
|
||||
[[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue
|
||||
else
|
||||
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
|
||||
size="$1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.74.4
|
||||
version=0.74.5
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.74.4"
|
||||
$version="0.74.5"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
+1
-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 "Sep 2026" "fzf 0.74.4" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+5
-2
@@ -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 "Sep 2026" "fzf 0.74.4" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -1909,7 +1909,10 @@ e.g.
|
||||
\fIfocus\fR
|
||||
.RS
|
||||
Triggered when the focus changes due to a vertical cursor movement or a search
|
||||
result update.
|
||||
result update. After a reload, the focused item is considered new unless
|
||||
\fB\-\-id\-nth\fR is set and the item has the same key as the previously
|
||||
focused item. Because of this, binding \fBreload\fR to this event can cause an
|
||||
infinite loop.
|
||||
|
||||
e.g.
|
||||
\fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}'
|
||||
|
||||
+43
-21
@@ -8,6 +8,15 @@
|
||||
# An implementation of completion.nu
|
||||
# This loads FZF as a Nushell External Completer
|
||||
# https://www.nushell.sh/cookbook/external_completers.html
|
||||
#
|
||||
# Requires Nushell 0.116.0 or newer.
|
||||
#
|
||||
# On 0.116+, the registered completer closure must reach a command carrying the
|
||||
# `@interactive` attribute: interactivity is seen through the closure to that
|
||||
# command (a closure cannot carry the attribute itself), and that is what makes
|
||||
# the completion run on the REPL thread with the terminal, so fzf can take it.
|
||||
# A closure that does not reach such a command runs on a background worker
|
||||
# without the terminal, where fzf cannot run.
|
||||
|
||||
|
||||
# --- Default Environment Variables ---
|
||||
@@ -367,9 +376,9 @@ def _fzf_complete_kill [query: string] {
|
||||
|
||||
# --- Main FZF External Completer ---
|
||||
|
||||
# This function is registered with Nushell's external completion system.
|
||||
# It gets called when Tab is pressed.
|
||||
let fzf_external_completer = {|spans|
|
||||
# Main completion logic. It receives the token list of the command being
|
||||
# completed (the legacy `spans`). Called directly, so no attribute is needed.
|
||||
def __fzf_completer_logic [spans] {
|
||||
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||
|
||||
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
|
||||
@@ -446,6 +455,29 @@ let fzf_external_completer = {|spans|
|
||||
}
|
||||
}
|
||||
|
||||
# Nushell 0.116+ entry point. The registered closure must END with a call to
|
||||
# this command for the completion to run on the REPL thread with the terminal.
|
||||
#
|
||||
# It declares `place` (a named input) rather than the legacy `spans`, so the
|
||||
# completer never goes through the deprecated positional bridge. `$place.command`
|
||||
# is the token list of the command being completed, the same value the old
|
||||
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
|
||||
@interactive
|
||||
def __fzf_external_completer [place] {
|
||||
let result = (__fzf_completer_logic $place.command)
|
||||
if $result != null {
|
||||
$result
|
||||
} else {
|
||||
# Fall back to the previously configured external completer, if any.
|
||||
let previous = ($env.__fzf_previous_completer? | default null)
|
||||
let previous_result = if $previous != null { do $previous $place } else { null }
|
||||
# An @interactive completer cannot decline with null: null reads as a
|
||||
# dismissed picker with an empty, final answer. Request the fallback
|
||||
# explicitly so file completion still runs when the trigger is absent.
|
||||
if $previous_result != null { $previous_result } else { {completions: [], fallback: true} }
|
||||
}
|
||||
}
|
||||
|
||||
# --- WRAPPER AND REGISTRATION ---
|
||||
|
||||
# Guard against re-sourcing: wrapping the completer multiple times would
|
||||
@@ -455,25 +487,15 @@ if ($env.__fzf_completer_registered? | default false) != true {
|
||||
# Get the currently configured external completer, if any exists
|
||||
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
|
||||
|
||||
# Define the new wrapper completer
|
||||
let fzf_wrapper_completer = {|spans|
|
||||
# 1. Try the FZF completer logic first
|
||||
let fzf_result = do $fzf_external_completer $spans
|
||||
# Stash it for `__fzf_external_completer`: a def cannot capture a local
|
||||
# variable, and the fallback to the previous completer happens inside it.
|
||||
$env.__fzf_previous_completer = $previous_external_completer
|
||||
|
||||
# 2. If FZF returned a result (a list, even an empty one), return it.
|
||||
# `null` means FZF didn't handle it because the trigger wasn't present.
|
||||
if $fzf_result != null {
|
||||
$fzf_result
|
||||
} else {
|
||||
# 3. FZF didn't handle it, so call the previous completer (if it exists).
|
||||
if $previous_external_completer != null {
|
||||
do $previous_external_completer $spans
|
||||
} else {
|
||||
# 4. No previous completer, and FZF didn't handle it. Return null.
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
# The registered closure must END with a call to an `@interactive` command so
|
||||
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
|
||||
# (it inspects the last pipeline element of the closure). Declaring `place`
|
||||
# instead of `spans` keeps it off the deprecated positional bridge.
|
||||
let fzf_wrapper_completer = {|place| __fzf_external_completer $place }
|
||||
|
||||
# Register the new wrapper completer
|
||||
# This ensures external completions are enabled and sets our wrapper.
|
||||
|
||||
@@ -215,7 +215,7 @@ _fzf_dir_completion() {
|
||||
|
||||
_fzf_feed_fifo() {
|
||||
command rm -f "$1"
|
||||
mkfifo "$1"
|
||||
command mkfifo "$1" || return
|
||||
cat <&0 > "$1" &|
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ _fzf_complete() {
|
||||
rest=("$@")
|
||||
fi
|
||||
|
||||
local fifo lbuf matches post
|
||||
fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$"
|
||||
local fifo fifo_dir lbuf matches post
|
||||
fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return
|
||||
fifo="$fifo_dir/fifo"
|
||||
lbuf=${rest[0]}
|
||||
post="${funcstack[1]}_post"
|
||||
type $post > /dev/null 2>&1 || post=cat
|
||||
@@ -256,7 +257,7 @@ _fzf_complete() {
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches"
|
||||
fi
|
||||
command rm -f "$fifo"
|
||||
command rm -rf "$fifo_dir"
|
||||
}
|
||||
|
||||
# To use custom hostname lists, override __fzf_list_hosts.
|
||||
|
||||
+33
-7
@@ -467,7 +467,7 @@ type Terminal struct {
|
||||
termSize tui.TermSize
|
||||
lastAction actionType
|
||||
lastKey string
|
||||
lastFocus int32
|
||||
lastFocus focusedItem
|
||||
areaLines int
|
||||
areaColumns int
|
||||
forcePreview bool
|
||||
@@ -492,6 +492,11 @@ type selectedItem struct {
|
||||
item *Item
|
||||
}
|
||||
|
||||
type focusedItem struct {
|
||||
major int // revision.major; changes on reload
|
||||
item *Item
|
||||
}
|
||||
|
||||
type byTimeOrder []selectedItem
|
||||
|
||||
func (a byTimeOrder) Len() int {
|
||||
@@ -1186,7 +1191,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
||||
initFunc: func() error { return renderer.Init() },
|
||||
executing: util.NewAtomicBool(false),
|
||||
lastAction: actStart,
|
||||
lastFocus: minItem.Index(),
|
||||
lastFocus: focusedItem{},
|
||||
lastActivity: time.Now(),
|
||||
numLinesCache: make(map[int32]numLinesCacheValue),
|
||||
// The initial load counts as a search in progress ('start:wait').
|
||||
@@ -6066,6 +6071,26 @@ func (t *Terminal) currentIndex() int32 {
|
||||
return minItem.Index()
|
||||
}
|
||||
|
||||
func (t *Terminal) currentFocus() focusedItem {
|
||||
return focusedItem{t.revision.major, t.currentItem()}
|
||||
}
|
||||
|
||||
// Records the current focus and returns whether it differs from the last one.
|
||||
// Indexes restart after a reload, so items are compared by --id-nth key, if
|
||||
// set. Without it, any focused item after a reload is a new one.
|
||||
func (t *Terminal) updateFocus() bool {
|
||||
curr := t.currentFocus()
|
||||
last := t.lastFocus
|
||||
t.lastFocus = curr
|
||||
if curr.item == nil || last.item == nil {
|
||||
return curr.item != last.item
|
||||
}
|
||||
if curr.major == last.major {
|
||||
return curr.item.Index() != last.item.Index()
|
||||
}
|
||||
return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth)
|
||||
}
|
||||
|
||||
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
||||
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
||||
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
||||
@@ -6631,8 +6656,7 @@ func (t *Terminal) Loop() error {
|
||||
info = true
|
||||
}
|
||||
focusChanged := focusedIndex != currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
|
||||
t.lastFocus = currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() {
|
||||
t.eventChan <- tui.Focus.AsEvent()
|
||||
if t.infoCommand != "" {
|
||||
info = true
|
||||
@@ -7017,9 +7041,11 @@ func (t *Terminal) Loop() error {
|
||||
}
|
||||
}
|
||||
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
|
||||
if newIndex := t.currentIndex(); newIndex != currentIndex {
|
||||
t.lastFocus = newIndex
|
||||
// Actions are ignored while blocked. The render loop will
|
||||
// report the focus after the reload.
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked {
|
||||
if t.currentIndex() != currentIndex {
|
||||
t.lastFocus = t.currentFocus()
|
||||
if t.infoCommand != "" {
|
||||
req(reqInfo)
|
||||
}
|
||||
|
||||
+23
-16
@@ -5,14 +5,19 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
// The flag group of new-pane usage includes -A (tmux 3.8 or above)
|
||||
var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`)
|
||||
|
||||
// Returns the size of the current window if the tmux server supports
|
||||
// floating panes (tmux 3.7 or above)
|
||||
func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// floating panes (tmux 3.7 or above), and whether a floating pane can stay
|
||||
// above a zoomed pane
|
||||
func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
// TMUX_PANE is not set when fzf is not started from within a pane
|
||||
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
|
||||
// pane there; a blocking run-shell suspends key processing for the
|
||||
@@ -25,7 +30,7 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// https://github.com/tmux/tmux/issues/5384
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
if target == "" {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// A single invocation for both checks. Cannot rely on the exit status;
|
||||
// tmux versions before 3.7 exit normally with empty output for an
|
||||
@@ -33,17 +38,17 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
|
||||
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
|
||||
if err != nil || !strings.Contains(string(out), "new-pane") {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
var width, height int
|
||||
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// The window is too small to fit a floating pane of the minimum size
|
||||
if width < 3 || height < 3 {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
return width, height, true
|
||||
return width, height, tmuxOverZoomFlag.Match(out), true
|
||||
}
|
||||
|
||||
// tmux ends a command at an argument ending in ';', so a trailing one is
|
||||
@@ -85,7 +90,7 @@ func tmuxDim(spec sizeSpec, window int) int {
|
||||
return max(3, min(dim, window))
|
||||
}
|
||||
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) {
|
||||
// Unlike display-popup, the size of a floating pane does not account for
|
||||
// the border around it, and the position is that of the content area. To
|
||||
// stay consistent with popups, treat the requested size as the total
|
||||
@@ -166,13 +171,15 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
|
||||
}
|
||||
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
|
||||
// window crashes the tmux server on 3.7b, and newer versions of
|
||||
// tmux unzoom the window anyway.
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
newPane := fmt.Sprintf(
|
||||
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
|
||||
newPane := "tmux new-pane -A"
|
||||
if !overZoom {
|
||||
// Unzoom the window first; creating a floating pane over a
|
||||
// zoomed window crashes the tmux server on 3.7b
|
||||
newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target))
|
||||
}
|
||||
newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
|
||||
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
|
||||
|
||||
@@ -222,10 +229,10 @@ func runTmux(args []string, opts *Options) (int, error) {
|
||||
// popup is used instead so that the fzf-drawn border is the only border
|
||||
// shown; the native border of a tmux floating pane cannot be removed.
|
||||
if nativeBorder(opts) {
|
||||
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
|
||||
if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok {
|
||||
opts.Tmux.border = true
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+214
-34
@@ -348,9 +348,125 @@ func getEnv(name string, defaultValue int) int {
|
||||
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
|
||||
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in an escape sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting in that case, so the
|
||||
// parser is never handed a fragment to guess at.
|
||||
// csiEnd returns the length of the CSI or SS3 sequence at the start of the
|
||||
// buffer, 0 if it has no final byte yet, or -1 if it is malformed.
|
||||
func csiEnd(buffer []byte) int {
|
||||
digits := true
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
b := buffer[i]
|
||||
// rxvt ends keys with $ after at least one digit, as in \e[7$ for
|
||||
// SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte.
|
||||
if csiFinal(b) || b == '$' && digits && i > 2 {
|
||||
return i + 1
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return -1
|
||||
}
|
||||
digits = digits && b >= '0' && b <= '9'
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
|
||||
// start of the buffer, or 0 while nothing has ended it. A terminator is
|
||||
// included, an ESC that introduces another sequence is not.
|
||||
func stringEnd(buffer []byte) int {
|
||||
if len(buffer) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
|
||||
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
|
||||
// frame only its first half and leave the rest to be typed into the query.
|
||||
bel := buffer[1] == ']'
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
switch buffer[i] {
|
||||
case '\a':
|
||||
if bel {
|
||||
return i + 1
|
||||
}
|
||||
case Esc.Byte():
|
||||
if i+1 == len(buffer) {
|
||||
return 0 // ST may still be arriving
|
||||
}
|
||||
if buffer[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
// Any other ESC ends the string and introduces a sequence of its
|
||||
// own, so frame only what precedes it and leave the ESC to be
|
||||
// parsed again. Scanning past it would swallow that sequence too.
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home.
|
||||
// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both.
|
||||
func rxvtKeyEnd(b byte) bool {
|
||||
return b == '~' || b == '$' || b == '^' || b == '@'
|
||||
}
|
||||
|
||||
// csiIntroducer reports whether the byte after ESC starts a sequence that is
|
||||
// framed by a final byte. The parser routes CSI and SS3 through the same
|
||||
// parameterized subcases, so both are framed by csiEnd.
|
||||
func csiIntroducer(b byte) bool {
|
||||
return b == '[' || b == 'O'
|
||||
}
|
||||
|
||||
// stringIntroducer reports whether the byte after ESC starts a string sequence.
|
||||
// Only the three that terminals reply with: OSC for colors, title and clipboard,
|
||||
// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing
|
||||
// sends SOS or PM.
|
||||
func stringIntroducer(b byte) bool {
|
||||
switch b {
|
||||
case 'P', ']', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringReply reports whether a framed string sequence starts the way a
|
||||
// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed
|
||||
// text after one, ended by CTRL-G or another key, frames like a reply when it
|
||||
// all arrives in one read.
|
||||
func stringReply(buffer []byte) bool {
|
||||
payload := buffer[2:]
|
||||
switch buffer[1] {
|
||||
case ']': // Ps ; ...
|
||||
i := 0
|
||||
for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
return i > 0 && i < len(payload) && payload[i] == ';'
|
||||
case 'P': // >| or !| for the version, [01]$r or [01]+r for settings
|
||||
if len(payload) < 2 {
|
||||
return false
|
||||
}
|
||||
if payload[1] == '|' {
|
||||
return payload[0] == '>' || payload[0] == '!'
|
||||
}
|
||||
return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') &&
|
||||
(payload[1] == '$' || payload[1] == '+') && payload[2] == 'r'
|
||||
case '_': // G, then key=value
|
||||
return len(payload) >= 3 && payload[0] == 'G' &&
|
||||
(payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') &&
|
||||
payload[2] == '='
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillArriving reports whether the CSI or SS3 sequence at the start of the
|
||||
// buffer may still be completed by more input. It must be the only one in the buffer:
|
||||
// whatever follows it, another sequence or a byte that ended it, means it is not
|
||||
// waiting for anything.
|
||||
func stillArriving(buffer []byte) bool {
|
||||
return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer)
|
||||
}
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting while it does, so a
|
||||
// fragment reaches the parser only once that wait has run out. A string sequence
|
||||
// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_.
|
||||
func incompleteEscape(buffer []byte) bool {
|
||||
// Only the tail can hold a sequence still arriving. This runs once per byte
|
||||
// read, so scanning all of a large paste would make the read quadratic.
|
||||
@@ -362,21 +478,10 @@ func incompleteEscape(buffer []byte) bool {
|
||||
if start < 0 || len(tail)-start < 2 {
|
||||
return false
|
||||
}
|
||||
switch tail[start+1] {
|
||||
case '[':
|
||||
for _, b := range tail[start+2:] {
|
||||
if csiFinal(b) {
|
||||
return false
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return false // malformed, do not wait for a terminator
|
||||
}
|
||||
}
|
||||
return true
|
||||
case 'O':
|
||||
return len(tail)-start < 3
|
||||
}
|
||||
return false
|
||||
// SS3 is judged like CSI: declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A. A malformed sequence is not waited for,
|
||||
// as nothing can complete it.
|
||||
return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
|
||||
@@ -483,17 +588,27 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
|
||||
return Event{CtrlSlash, 0, nil}
|
||||
case Esc.Byte():
|
||||
ev := r.escSequence(&sz)
|
||||
// Second chance
|
||||
if ev.Type == Invalid {
|
||||
r.buffer, result, err = r.getBytes(true)
|
||||
// Second chance, but only for an unfinished sequence. Re-reading
|
||||
// otherwise blocks until the next keystroke, holding back whatever
|
||||
// follows in the buffer.
|
||||
if ev.Type == Invalid && stillArriving(r.buffer) {
|
||||
waited := len(r.buffer)
|
||||
var buffer []byte
|
||||
buffer, result, err = r.getBytes(true)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
r.buffer = buffer
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
ev = r.escSequence(&sz)
|
||||
// This read has no time limit, so what it returned may be the next
|
||||
// keystroke rather than the rest of the sequence. Never drop it.
|
||||
if ev.Type == Invalid && sz > waited {
|
||||
sz = waited
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -525,7 +640,41 @@ func (r *LightRenderer) setCancel(f func()) {
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
// escSequence parses an escape sequence, then checks a CSI or SS3 result
|
||||
// against its frame. parseEscSequence can stop after a few bytes, and consuming
|
||||
// only those would leave the rest to be typed into the query. Complete
|
||||
// sequences it does not recognize are dropped there, unless they could be an
|
||||
// ALT key followed by typed text.
|
||||
func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
ev := r.parseEscSequence(sz)
|
||||
if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) {
|
||||
return ev
|
||||
}
|
||||
// A frame missing its final byte may still be arriving
|
||||
end := csiEnd(r.buffer)
|
||||
if end <= 0 {
|
||||
return ev
|
||||
}
|
||||
// The parser can also set the size before checking the bytes and then give
|
||||
// up, counting typed input past the frame, as in \e[3;1u followed by ~
|
||||
if ev.Type == Invalid && *sz > end {
|
||||
*sz = end
|
||||
}
|
||||
// Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends
|
||||
// keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE
|
||||
if end <= 4 {
|
||||
return ev
|
||||
}
|
||||
// A key matched on a prefix, such as Home for \e[70;5u, or a sequence given
|
||||
// up on partway
|
||||
if end > *sz {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if len(r.buffer) < 2 {
|
||||
return Event{Esc, 0, nil}
|
||||
}
|
||||
@@ -606,6 +755,11 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if len(r.buffer) < 4 {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
// A byte that cannot continue the sequence, such as Enter after
|
||||
// ALT-[ and a digit, is a key of its own
|
||||
if csiEnd(r.buffer) < 0 {
|
||||
break
|
||||
}
|
||||
*sz = 4
|
||||
switch r.buffer[2] {
|
||||
case '2':
|
||||
@@ -638,7 +792,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{Delete, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -657,7 +811,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftDelete, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -680,12 +834,14 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '4':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '5':
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageUp, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -704,7 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageUp, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -730,7 +886,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageDown, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -749,7 +905,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageDown, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -772,15 +928,19 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '7':
|
||||
return Event{Home, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{Home, 0, nil}
|
||||
}
|
||||
case '8':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '1':
|
||||
switch r.buffer[3] {
|
||||
case '~':
|
||||
return Event{Home, 0, nil}
|
||||
case '1', '2', '3', '4', '5', '7', '8', '9':
|
||||
if len(r.buffer) == 5 && r.buffer[4] == '~' {
|
||||
if len(r.buffer) > 4 && r.buffer[4] == '~' {
|
||||
*sz = 5
|
||||
switch r.buffer[3] {
|
||||
case '1':
|
||||
@@ -987,6 +1147,24 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[1]
|
||||
// Nothing matched. A framed sequence is dropped whole: reading its
|
||||
// introducer as an ALT-key below would type the rest into the query.
|
||||
// Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-],
|
||||
// ALT-P and ALT-_ arrive.
|
||||
if csiIntroducer(r.buffer[1]) {
|
||||
// ALT-[ or ALT-O and one or two typed characters can look like a short
|
||||
// sequence, so ask for at least two bytes before the final byte
|
||||
if end := csiEnd(r.buffer); end > 4 {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
} else if stringIntroducer(r.buffer[1]) {
|
||||
if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
}
|
||||
|
||||
rest := bytes.NewBuffer(r.buffer[1:])
|
||||
c, size, err := rest.ReadRune()
|
||||
if err == nil {
|
||||
@@ -1003,11 +1181,13 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
rest := r.buffer[*sz:]
|
||||
end := bytes.IndexAny(rest, "mM")
|
||||
if end == -1 {
|
||||
// Not past the frame, where M or m can be a key typed later
|
||||
frame := csiEnd(r.buffer)
|
||||
if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
rest := r.buffer[*sz:]
|
||||
end := frame - 1 - *sz
|
||||
|
||||
elems := strings.SplitN(string(rest[:end]), ";", 3)
|
||||
if len(elems) != 3 {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// An unrecognized escape sequence must be consumed whole, CSI and string
|
||||
// sequences alike. Consuming only part of one leaves the rest to be read as
|
||||
// input and typed into the query.
|
||||
func TestUnknownEscapeSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Key encodings fzf does not implement
|
||||
{"\x1b[97;5u", Invalid, 7},
|
||||
{"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches
|
||||
{"\x1b[42;5u", Invalid, 7}, // nor End
|
||||
{"\x1b[4;5~", Invalid, 6},
|
||||
{"\x1b[127;5u", Invalid, 8},
|
||||
{"\x1b[27;5;127~", Invalid, 11},
|
||||
{"\x1b[57441;1u", Invalid, 10},
|
||||
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
|
||||
|
||||
// Replies to queries fzf did not send, or sent and stopped waiting for
|
||||
{"\x1b[?1;2c", Invalid, 7},
|
||||
{"\x1b[>0;95;0c", Invalid, 10},
|
||||
|
||||
// Mouse report arriving while mouse input is off
|
||||
{"\x1b[<0;1;1M", Invalid, 9},
|
||||
|
||||
// String sequences
|
||||
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
|
||||
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
|
||||
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
|
||||
{"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS
|
||||
{"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x"
|
||||
|
||||
// SS3 is framed like CSI, since the parser routes both the same way
|
||||
{"\x1bO9;5u", Invalid, 6},
|
||||
{"\x1bO1;5X", Invalid, 6}, // unknown final byte
|
||||
{"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged
|
||||
{"\x1bOP", F1, 3},
|
||||
|
||||
// Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive
|
||||
{"\x1b[a", Alt, 2},
|
||||
{"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI
|
||||
{"\x1b[1m", Alt, 2},
|
||||
{"\x1b[ x", Alt, 2},
|
||||
{"\x1bOx", Alt, 2},
|
||||
{"\x1bO9A", Alt, 2},
|
||||
|
||||
// rxvt keys fzf does not know: dropped, not typed
|
||||
{"\x1b[3^", Invalid, 4}, // CTRL-DELETE
|
||||
{"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP
|
||||
{"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE
|
||||
{"\x1b[11^", Invalid, 5},
|
||||
|
||||
// rxvt ends keys with $ after digits, so what follows is typed
|
||||
{"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x
|
||||
{"\x1b[7$1x", Home, 4},
|
||||
{"\x1b[3$x", Invalid, 4},
|
||||
{"\x1b[3$1x", Invalid, 4},
|
||||
{"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x
|
||||
{"\x1b[24$1x", Invalid, 5},
|
||||
|
||||
// A parse that gives up past the frame consumes only the frame, so the
|
||||
// typed ~ after these stays in the buffer
|
||||
{"\x1b[3;1u~", Invalid, 6},
|
||||
{"\x1b[5;1u~", Invalid, 6},
|
||||
{"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT
|
||||
{"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT
|
||||
{"\x1b[2A~", Invalid, 4},
|
||||
|
||||
// Elsewhere $ and other intermediate bytes do not end a sequence
|
||||
{"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches
|
||||
{"\x1b[4;2$y", Invalid, 7},
|
||||
{"\x1b[2 q", Invalid, 5},
|
||||
{"\x1b]abc", Alt, 2},
|
||||
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
|
||||
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
|
||||
{"\x1b]\x1b[A", Alt, 2},
|
||||
{"\x1bP\x1bP", Alt, 2},
|
||||
{"\x1b_\x1b_", Alt, 2},
|
||||
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
|
||||
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
|
||||
|
||||
// Typed text after the introducer is not a reply, even when a
|
||||
// terminator follows in the same read
|
||||
{"\x1b]a\a", Alt, 2},
|
||||
{"\x1b]1\a", Alt, 2},
|
||||
{"\x1b]a\x1b[A", Alt, 2},
|
||||
{"\x1bPx\x1b[A", Alt, 2},
|
||||
{"\x1b_ab\x1bb", Alt, 2},
|
||||
|
||||
// SOS and PM are not framed
|
||||
{"\x1bXsos\x1b\\", Alt, 2},
|
||||
{"\x1b^status\x1b\\", Alt, 2},
|
||||
|
||||
// Left alone: no final byte yet, so the sequence may still be arriving
|
||||
{"\x1b[", Invalid, 2},
|
||||
|
||||
// Recognized sequences keep their existing parsing
|
||||
{"\x1b[1;5A", CtrlUp, 6},
|
||||
{"\x1b[3;5~", CtrlDelete, 6},
|
||||
{"\x1b[2~", Insert, 4},
|
||||
{"\x1b[200~", BracketedPasteBegin, 6},
|
||||
{"\x1b[Z", ShiftTab, 3},
|
||||
{"\x1bOA", Up, 3},
|
||||
{"\x1b[12;34R", Invalid, 8},
|
||||
{"\x1b[?2004;2$y", Invalid, 11},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence)}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
// Terminated
|
||||
{"\x1b]0;t\a", 6},
|
||||
{"\x1b]0;t\x1b\\", 7},
|
||||
{"\x1b_G\x1b\\", 5},
|
||||
{"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it
|
||||
|
||||
// BEL terminates an OSC only
|
||||
{"\x1bP\a", 0},
|
||||
{"\x1b_Gi=1\a", 0},
|
||||
|
||||
// An ESC ends the string and introduces a sequence of its own
|
||||
{"\x1b]foo\x1bX\x1b\\", 5},
|
||||
{"\x1b]foo\x1bP", 5},
|
||||
{"\x1b]\x1b]", 2},
|
||||
|
||||
// Nothing has ended it yet
|
||||
{"\x1b]0;t", 0},
|
||||
{"\x1b]0;t\x1b", 0}, // ST half arrived
|
||||
{"\x1b]foo\x1b", 0},
|
||||
{"\x1b]", 0},
|
||||
{"\x1b", 0}, // shorter than an introducer
|
||||
|
||||
} {
|
||||
if got := stringEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped sequence followed by ALT-[ is not waiting for anything, so the
|
||||
// bytes in between must not be held back until the next keystroke
|
||||
func TestStillArriving(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b[97;5u\a\x1b[", false},
|
||||
{"\x1b[97;5u\x1b[", false},
|
||||
{"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one
|
||||
{"\x1b[1;\a", false}, // the BEL has already ended it
|
||||
{"\x1b[1;", true},
|
||||
{"\x1b[", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1b[<0;1", true},
|
||||
} {
|
||||
if got := stillArriving([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringReply(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b]11;rgb:1/2/3\a", true},
|
||||
{"\x1b]0;title\a", true},
|
||||
{"\x1b]52;c;YWJj\x1b\\", true},
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", true},
|
||||
{"\x1bP!|0\x1b\\", true},
|
||||
{"\x1bP1$r0m\x1b\\", true},
|
||||
{"\x1bP0+r\x1b\\", true},
|
||||
{"\x1b_Gi=1;OK\x1b\\", true},
|
||||
|
||||
// What typed text after ALT-], ALT-P or ALT-_ looks like
|
||||
{"\x1b]\a", false},
|
||||
{"\x1b]a\a", false},
|
||||
{"\x1b]1\a", false},
|
||||
{"\x1b];\a", false},
|
||||
{"\x1bPx\x1b\\", false},
|
||||
{"\x1bP1\x1b\\", false},
|
||||
{"\x1b_ab\x1b\\", false},
|
||||
{"\x1b_G\x1b\\", false},
|
||||
} {
|
||||
if got := stringReply([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsiEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
{"\x1b[97;5u", 7},
|
||||
{"\x1b[A", 3},
|
||||
{"\x1b[<0;1;1M", 9},
|
||||
{"\x1b[?2004;2$y", 11},
|
||||
{"\x1b[7$x", 4}, // rxvt ends keys with $ after digits
|
||||
{"\x1b[23$", 5},
|
||||
{"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere
|
||||
{"\x1b[97;5", 0}, // no final byte
|
||||
{"\x1b[", 0}, // no final byte
|
||||
{"\x1b[1\x01A", -1}, // malformed, do not frame it
|
||||
{"\x1b[\a", -1},
|
||||
} {
|
||||
if got := csiEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that follows a sequence in the same read must not be consumed with it
|
||||
func TestKeyAfterSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
mouse bool
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Recognized when more input follows, as from a paste or tmux send-keys
|
||||
{"\x1b[15~x", false, F5, 5},
|
||||
{"\x1b[3;5~x", false, CtrlDelete, 6},
|
||||
{"\x1b[5;13~x", false, CtrlAltPageUp, 7},
|
||||
{"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6},
|
||||
|
||||
// ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence
|
||||
{"\x1b[2\r", false, Alt, 2},
|
||||
{"\x1b[12\x03", false, Alt, 2},
|
||||
{"\x1bO2\a", false, Alt, 2},
|
||||
{"\x1b[4x", false, Alt, 2}, // not End
|
||||
{"\x1b[7\r", false, Alt, 2}, // not Home
|
||||
{"\x1b[7~x", false, Home, 4},
|
||||
{"\x1b[8^x", false, End, 4}, // rxvt CTRL-END
|
||||
|
||||
// A mouse report ends at its own final byte
|
||||
{"\x1b[<0;5;3Mx", true, Mouse, 9},
|
||||
{"\x1b[<0;1;\x1b[AM", true, Invalid, 3},
|
||||
{"\x1b[<0;1;1xM", true, Invalid, 9},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,46 @@ func TestIncompleteEscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or
|
||||
// ALT-_, and holding the read open on those both delayed the key and let typed
|
||||
// bytes accumulate into something that looked like a sequence.
|
||||
func TestIncompleteStringEscape(t *testing.T) {
|
||||
for _, buffer := range []string{
|
||||
"\x1b]11;rgb:",
|
||||
"\x1bP>|kitty",
|
||||
"\x1b_Gi=1",
|
||||
"\x1b]0;t\x1b",
|
||||
"\x1b]0;title\a",
|
||||
"ab\x1b]11;rgb:",
|
||||
} {
|
||||
if incompleteEscape([]byte(buffer)) {
|
||||
t.Errorf("incompleteEscape(%q) = true, want false", buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to
|
||||
// be judged the same way here. Declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A and type its tail into the query.
|
||||
func TestIncompleteSS3(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1bO", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1bO1;", true},
|
||||
{"\x1bO1;5", true},
|
||||
{"\x1bOA", false},
|
||||
{"\x1bOP", false},
|
||||
{"\x1bO1;5A", false},
|
||||
{"\x1b[23$", false}, // rxvt SHIFT-F11 is complete
|
||||
{"\x1b[7$", false},
|
||||
{"\x1b[4;2$", true}, // $ after a ; is intermediate
|
||||
} {
|
||||
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Drives queryStartup against a terminal simulated by pipes, with the replies
|
||||
@@ -126,3 +129,54 @@ func TestQueryStartup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The second-chance read has no time limit, so a key typed long after an
|
||||
// unfinished sequence must not be dropped with it.
|
||||
func TestLateKeyAfterUnfinishedSequence(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close(); inW.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("x")
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("y") // ends the wait if x was dropped
|
||||
}()
|
||||
|
||||
r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay}
|
||||
for range 3 {
|
||||
if ev := r.GetChar(false); ev.Type == Rune {
|
||||
if ev.Char != 'x' {
|
||||
t.Errorf("got %q, want 'x'", ev.Char)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the late key was dropped")
|
||||
}
|
||||
|
||||
// A read error during the second-chance read must end fzf cleanly, not panic.
|
||||
func TestSecondChanceReadError(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.Close()
|
||||
}()
|
||||
|
||||
out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}}
|
||||
if ev := r.GetChar(false); ev.Type != Fatal {
|
||||
t.Errorf("got %s, want Fatal", ev.Type.String())
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;1R", "!Invalid")
|
||||
assertEscSequence("\x1b[", "!Invalid")
|
||||
assertEscSequence("\x1b[1", "!Invalid")
|
||||
assertEscSequence("\x1b[3;3~1", "!Invalid")
|
||||
assertEscSequence("\x1b[13", "!Invalid")
|
||||
assertEscSequence("\x1b[1;3", "!Invalid")
|
||||
assertEscSequence("\x1b[1;10", "!Invalid")
|
||||
@@ -200,6 +199,7 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;3H", "alt-home")
|
||||
assertEscSequence("\x1b[1;3F", "alt-end")
|
||||
assertEscSequence("\x1b[3;3~", "alt-delete")
|
||||
assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1
|
||||
assertEscSequence("\x1b[5;3~", "alt-page-up")
|
||||
assertEscSequence("\x1b[6;3~", "alt-page-down")
|
||||
|
||||
|
||||
+9
-7
@@ -105,16 +105,18 @@ func RepeatToFill(str string, length int, limit int) string {
|
||||
rest := limit % length
|
||||
output := strings.Repeat(str, times)
|
||||
if rest > 0 {
|
||||
for _, r := range str {
|
||||
rest -= uniseg.StringWidth(string(r))
|
||||
if rest < 0 {
|
||||
break
|
||||
}
|
||||
output += string(r)
|
||||
if rest == 0 {
|
||||
// Iterate over grapheme clusters so that we don't cut a cluster in half
|
||||
end := 0
|
||||
graphemes := uniseg.NewGraphemes(str)
|
||||
for rest > 0 && graphemes.Next() {
|
||||
width := graphemes.Width()
|
||||
if width > rest {
|
||||
break
|
||||
}
|
||||
rest -= width
|
||||
_, end = graphemes.Positions()
|
||||
}
|
||||
output += str[:end]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -109,6 +109,27 @@ func TestRepeatToFill(t *testing.T) {
|
||||
if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] {
|
||||
t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2])
|
||||
}
|
||||
|
||||
// Should not cut a grapheme cluster in half
|
||||
for _, test := range []struct {
|
||||
str string
|
||||
limit int
|
||||
expected string
|
||||
}{
|
||||
{"a\u0301b", 1, "a\u0301"},
|
||||
{"a\u0301b", 3, "a\u0301ba\u0301"},
|
||||
{"a\u0301b", 4, "a\u0301ba\u0301b"},
|
||||
{"a\u4e00", 2, "a"},
|
||||
{"a\u4e00", 4, "a\u4e00a"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"},
|
||||
} {
|
||||
actual := RepeatToFill(test.str, StringWidth(test.str), test.limit)
|
||||
if actual != test.expected {
|
||||
t.Errorf("Expected: %q, actual: %q", test.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
|
||||
@@ -1532,6 +1532,61 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| refute_includes(lines[-1], '[[1]]') }
|
||||
end
|
||||
|
||||
def assert_focus_log(expected)
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
assert_equal expected, File.readlines(tempname, chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload
|
||||
# Without --id-nth, the item at the same index after reload is a new item
|
||||
tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> a' }
|
||||
assert_focus_log(%w[a])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> c' }
|
||||
assert_focus_log(%w[a c])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_same_key
|
||||
# Same --id-nth key after reload: no focus event
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 1 aa' }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_key_not_found
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 3 c' }
|
||||
assert_focus_log(['1 a', '3 c'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_async_reload_id_nth
|
||||
# No focus event for the items shown before the tracked item arrives.
|
||||
# 'result' binding makes fzf redraw the list while blocked.
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, '> 1 aa'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_result_event
|
||||
tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter
|
||||
tmux.until { |lines| assert_equal 11, lines.match_count }
|
||||
|
||||
Reference in new issue
Block a user