Merge branch 'junegunn:master' into master

pull/3097/head
polluks2 1 year ago committed by GitHub
commit e978d72f5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -73,6 +73,7 @@ builds:
- arm
- arm64
- loong64
- ppc64le
goarm:
- 5
- 6

@ -3,6 +3,24 @@ CHANGELOG
0.36.0
------
- Added `--listen=HTTP_PORT` option to start HTTP server. It allows external
processes to send actions to perform via POST method.
```sh
# Start HTTP server on port 6266
fzf --listen 6266
# Send actions to the server
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
```
- Added `pos(...)` action to move the cursor to the numeric position
- `first` and `last` are equivalent to `pos(1)` and `pos(-1)` respectively
```sh
# Put the cursor on the 10th item
seq 100 | fzf --sync --bind 'start:pos(10)'
# Put the cursor on the 10th to last item
seq 100 | fzf --sync --bind 'start:pos(-10)'
```
- Added `next-selected` and `prev-selected` actions to move between selected
items
```sh
@ -13,7 +31,28 @@ CHANGELOG
# Both actions respect --layout option
seq 10 | fzf --multi --bind ctrl-n:next-selected,ctrl-p:prev-selected --layout reverse
```
- Added `change-query(...)` action
- Added `change-query(...)` action that simply changes the query string to the
given static string. This can be useful when used with `--listen`.
```sh
curl localhost:6266 -d "change-query:$(date)"
```
- Added `transform-query(...)` action for transforming the query string using
an external command
```sh
# Press space to convert the query to uppercase letters
fzf --bind 'space:transform-query(tr [:lower:] [:upper:] <<< {q})'
# Bind it to 'change' event for automatic conversion
fzf --bind 'change:transform-query(tr [:lower:] [:upper:] <<< {q})'
# Can only type numbers
fzf --bind 'change:transform-query(sed 's/[^0-9]//g' <<< {q})'
```
- `put` action can optionally take an argument string
```sh
# a will put 'alpha' on the prompt, ctrl-b will put 'bravo'
fzf --bind 'a:put+put(lpha),ctrl-b:put(bravo)'
```
- `double-click` will behave the same as `enter` unless otherwise specified,
so you don't have to repeat the same action twice in `--bind` in most cases.
```sh

@ -12,6 +12,9 @@ differ depending on the package manager.
" If installed using Homebrew
set rtp+=/usr/local/opt/fzf
" If installed using Homebrew on Apple Silicon
set rtp+=/opt/homebrew/opt/fzf
" If installed using git
set rtp+=~/.fzf
```

@ -176,6 +176,7 @@ case "$archi" in
Linux\ armv8*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ loongarch64) download fzf-$version-linux_loong64.tar.gz ;;
Linux\ ppc64le) download fzf-$version-linux_ppc64le.tar.gz ;;
Linux\ *64) download fzf-$version-linux_amd64.tar.gz ;;
FreeBSD\ *64) download fzf-$version-freebsd_amd64.tar.gz ;;
OpenBSD\ *64) download fzf-$version-openbsd_amd64.tar.gz ;;

@ -721,6 +721,18 @@ ncurses finder only after the input stream is complete.
e.g. \fBfzf --multi | fzf --sync\fR
.RE
.TP
.B "--listen=HTTP_PORT"
Start HTTP server on the given port. It allows external processes to send
actions to perform via POST method.
e.g.
\fB# Start HTTP server on port 6266
fzf --listen 6266
# Send action to the server
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
\fR
.TP
.B "--version"
Display version information and exit
@ -965,7 +977,7 @@ A key or an event can be bound to one or more of the following actions.
\fBend-of-line\fR \fIctrl-e end\fR
\fBexecute(...)\fR (see below for the details)
\fBexecute-silent(...)\fR (see below for the details)
\fBfirst\fR (move to the first match)
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
\fBforward-char\fR \fIctrl-f right\fR
\fBforward-word\fR \fIalt-f shift-right\fR
\fBignore\fR
@ -973,13 +985,14 @@ A key or an event can be bound to one or more of the following actions.
\fBjump-accept\fR (jump and accept)
\fBkill-line\fR
\fBkill-word\fR \fIalt-d\fR
\fBlast\fR (move to the last match)
\fBlast\fR (move to the last match; same as \fBpos(-1)\fR)
\fBnext-history\fR (\fIctrl-n\fR on \fB--history\fR)
\fBnext-selected\fR (move to the next selected item)
\fBpage-down\fR \fIpgdn\fR
\fBpage-up\fR \fIpgup\fR
\fBhalf-page-down\fR
\fBhalf-page-up\fR
\fBpos(...)\fR (move cursor to the numeric position; negative number to count from the end)
\fBprev-history\fR (\fIctrl-p\fR on \fB--history\fR)
\fBprev-selected\fR (move to the previous selected item)
\fBpreview(...)\fR (see below for the details)
@ -993,6 +1006,7 @@ A key or an event can be bound to one or more of the following actions.
\fBpreview-top\fR
\fBprint-query\fR (print query and exit)
\fBput\fR (put the character to the prompt)
\fBput(...)\fR (put the given string to the prompt)
\fBrefresh-preview\fR
\fBrebind(...)\fR (rebind bindings after \fBunbind\fR)
\fBreload(...)\fR (see below for the details)
@ -1009,6 +1023,7 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle-search\fR (toggle search functionality)
\fBtoggle-sort\fR
\fBtoggle+up\fR \fIbtab (shift-tab)\fR
\fBtransform-query(...)\fR (transform query string using an external command)
\fBunbind(...)\fR (unbind bindings)
\fBunix-line-discard\fR \fIctrl-u\fR
\fBunix-word-rubout\fR \fIctrl-w\fR
@ -1036,6 +1051,8 @@ that case, you can use any of the following alternative notations to avoid
parse errors.
\fBaction-name[...]\fR
\fBaction-name{...}\fR
\fBaction-name<...>\fR
\fBaction-name~...~\fR
\fBaction-name!...!\fR
\fBaction-name@...@\fR

@ -113,6 +113,7 @@ const usage = `usage: fzf [options]
--read0 Read input delimited by ASCII NUL characters
--print0 Print output delimited by ASCII NUL characters
--sync Synchronous search for multi-staged filtering
--listen=HTTP_PORT Start HTTP server to receive actions (POST /)
--version Display version information and exit
Environment variables
@ -296,6 +297,7 @@ type Options struct {
PreviewLabel labelOpts
Unicode bool
Tabstop int
ListenPort int
ClearOnExit bool
Version bool
}
@ -868,8 +870,9 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) *tui.ColorTheme {
}
var (
executeRegexp *regexp.Regexp
splitRegexp *regexp.Regexp
executeRegexp *regexp.Regexp
splitRegexp *regexp.Regexp
actionNameRegexp *regexp.Regexp
)
func firstKey(keymap map[tui.Event]string) tui.Event {
@ -886,50 +889,264 @@ const (
)
func init() {
// Backreferences are not supported.
// "~!@#$%^&*;/|".each_char.map { |c| Regexp.escape(c) }.map { |c| "#{c}[^#{c}]*#{c}" }.join('|')
executeRegexp = regexp.MustCompile(
`(?si)[:+](execute(?:-multi|-silent)?|reload|preview|change-query|change-prompt|change-preview-window|change-preview|(?:re|un)bind):.+|[:+](execute(?:-multi|-silent)?|reload|preview|change-query|change-prompt|change-preview-window|change-preview|(?:re|un)bind)(\([^)]*\)|\[[^\]]*\]|~[^~]*~|![^!]*!|@[^@]*@|\#[^\#]*\#|\$[^\$]*\$|%[^%]*%|\^[^\^]*\^|&[^&]*&|\*[^\*]*\*|;[^;]*;|/[^/]*/|\|[^\|]*\|)`)
`(?si)[:+](execute(?:-multi|-silent)?|reload|preview|change-query|change-prompt|change-preview-window|change-preview|(?:re|un)bind|pos|put|transform-query)`)
splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
}
func parseKeymap(keymap map[tui.Event][]*action, str string) {
masked := executeRegexp.ReplaceAllStringFunc(str, func(src string) string {
symbol := ":"
if strings.HasPrefix(src, "+") {
symbol = "+"
func maskActionContents(action string) string {
masked := ""
Loop:
for len(action) > 0 {
loc := executeRegexp.FindStringIndex(action)
if loc == nil {
masked += action
break
}
prefix := symbol + "execute"
if strings.HasPrefix(src[1:], "reload") {
prefix = symbol + "reload"
} else if strings.HasPrefix(src[1:], "change-preview-window") {
prefix = symbol + "change-preview-window"
} else if strings.HasPrefix(src[1:], "change-preview") {
prefix = symbol + "change-preview"
} else if strings.HasPrefix(src[1:], "preview") {
prefix = symbol + "preview"
} else if strings.HasPrefix(src[1:], "unbind") {
prefix = symbol + "unbind"
} else if strings.HasPrefix(src[1:], "rebind") {
prefix = symbol + "rebind"
} else if strings.HasPrefix(src[1:], "change-query") {
prefix = symbol + "change-query"
} else if strings.HasPrefix(src[1:], "change-prompt") {
prefix = symbol + "change-prompt"
} else if src[len(prefix)] == '-' {
c := src[len(prefix)+1]
if c == 's' || c == 'S' {
prefix += "-silent"
} else {
prefix += "-multi"
}
masked += action[:loc[1]]
action = action[loc[1]:]
if len(action) == 0 {
break
}
cs := string(action[0])
ce := ")"
switch action[0] {
case ':':
masked += strings.Repeat(" ", len(action))
break Loop
case '(':
ce = ")"
case '{':
ce = "}"
case '[':
ce = "]"
case '<':
ce = ">"
case '~', '!', '@', '#', '$', '%', '^', '&', '*', ';', '/', '|':
ce = string(cs)
default:
continue
}
cs = regexp.QuoteMeta(cs)
ce = regexp.QuoteMeta(ce)
// @$ or @+
loc = regexp.MustCompile(fmt.Sprintf(`^%s.*?(%s[+,]|%s$)`, cs, ce, ce)).FindStringIndex(action)
if loc == nil {
masked += action
break
}
return prefix + "(" + strings.Repeat(" ", len(src)-len(prefix)-2) + ")"
})
// Keep + or , at the end
lastChar := action[loc[1]-1]
if lastChar == '+' || lastChar == ',' {
loc[1]--
}
masked += strings.Repeat(" ", loc[1])
action = action[loc[1]:]
}
masked = strings.Replace(masked, "::", string([]rune{escapedColon, ':'}), -1)
masked = strings.Replace(masked, ",:", string([]rune{escapedComma, ':'}), -1)
masked = strings.Replace(masked, "+:", string([]rune{escapedPlus, ':'}), -1)
return masked
}
func parseSingleActionList(str string, exit func(string)) []*action {
// We prepend a colon to satisfy executeRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false, exit)
}
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool, exit func(string)) []*action {
maskedStrings := strings.Split(masked, "+")
originalStrings := make([]string, len(maskedStrings))
idx := 0
for i, maskedString := range maskedStrings {
originalStrings[i] = original[idx : idx+len(maskedString)]
idx += len(maskedString) + 1
}
actions := make([]*action, 0, len(maskedStrings))
appendAction := func(types ...actionType) {
actions = append(actions, toActions(types...)...)
}
prevSpec := ""
for specIndex, spec := range originalStrings {
spec = prevSpec + spec
specLower := strings.ToLower(spec)
switch specLower {
case "ignore":
appendAction(actIgnore)
case "beginning-of-line":
appendAction(actBeginningOfLine)
case "abort":
appendAction(actAbort)
case "accept":
appendAction(actAccept)
case "accept-non-empty":
appendAction(actAcceptNonEmpty)
case "print-query":
appendAction(actPrintQuery)
case "refresh-preview":
appendAction(actRefreshPreview)
case "replace-query":
appendAction(actReplaceQuery)
case "backward-char":
appendAction(actBackwardChar)
case "backward-delete-char":
appendAction(actBackwardDeleteChar)
case "backward-delete-char/eof":
appendAction(actBackwardDeleteCharEOF)
case "backward-word":
appendAction(actBackwardWord)
case "clear-screen":
appendAction(actClearScreen)
case "delete-char":
appendAction(actDeleteChar)
case "delete-char/eof":
appendAction(actDeleteCharEOF)
case "deselect":
appendAction(actDeselect)
case "end-of-line":
appendAction(actEndOfLine)
case "cancel":
appendAction(actCancel)
case "clear-query":
appendAction(actClearQuery)
case "clear-selection":
appendAction(actClearSelection)
case "forward-char":
appendAction(actForwardChar)
case "forward-word":
appendAction(actForwardWord)
case "jump":
appendAction(actJump)
case "jump-accept":
appendAction(actJumpAccept)
case "kill-line":
appendAction(actKillLine)
case "kill-word":
appendAction(actKillWord)
case "unix-line-discard", "line-discard":
appendAction(actUnixLineDiscard)
case "unix-word-rubout", "word-rubout":
appendAction(actUnixWordRubout)
case "yank":
appendAction(actYank)
case "backward-kill-word":
appendAction(actBackwardKillWord)
case "toggle-down":
appendAction(actToggle, actDown)
case "toggle-up":
appendAction(actToggle, actUp)
case "toggle-in":
appendAction(actToggleIn)
case "toggle-out":
appendAction(actToggleOut)
case "toggle-all":
appendAction(actToggleAll)
case "toggle-search":
appendAction(actToggleSearch)
case "select":
appendAction(actSelect)
case "select-all":
appendAction(actSelectAll)
case "deselect-all":
appendAction(actDeselectAll)
case "close":
appendAction(actClose)
case "toggle":
appendAction(actToggle)
case "down":
appendAction(actDown)
case "up":
appendAction(actUp)
case "first", "top":
appendAction(actFirst)
case "last":
appendAction(actLast)
case "page-up":
appendAction(actPageUp)
case "page-down":
appendAction(actPageDown)
case "half-page-up":
appendAction(actHalfPageUp)
case "half-page-down":
appendAction(actHalfPageDown)
case "prev-history", "previous-history":
appendAction(actPrevHistory)
case "next-history":
appendAction(actNextHistory)
case "prev-selected":
appendAction(actPrevSelected)
case "next-selected":
appendAction(actNextSelected)
case "toggle-preview":
appendAction(actTogglePreview)
case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap)
case "toggle-sort":
appendAction(actToggleSort)
case "preview-top":
appendAction(actPreviewTop)
case "preview-bottom":
appendAction(actPreviewBottom)
case "preview-up":
appendAction(actPreviewUp)
case "preview-down":
appendAction(actPreviewDown)
case "preview-page-up":
appendAction(actPreviewPageUp)
case "preview-page-down":
appendAction(actPreviewPageDown)
case "preview-half-page-up":
appendAction(actPreviewHalfPageUp)
case "preview-half-page-down":
appendAction(actPreviewHalfPageDown)
case "enable-search":
appendAction(actEnableSearch)
case "disable-search":
appendAction(actDisableSearch)
case "put":
if putAllowed {
appendAction(actRune)
} else {
exit("unable to put non-printable character")
}
default:
t := isExecuteAction(specLower)
if t == actIgnore {
if specIndex == 0 && specLower == "" {
actions = append(prevActions, actions...)
} else {
exit("unknown action: " + spec)
}
} else {
offset := len(actionNameRegexp.FindString(spec))
var actionArg string
if spec[offset] == ':' {
if specIndex == len(originalStrings)-1 {
actionArg = spec[offset+1:]
actions = append(actions, &action{t: t, a: actionArg})
} else {
prevSpec = spec + "+"
continue
}
} else {
actionArg = spec[offset+1 : len(spec)-1]
actions = append(actions, &action{t: t, a: actionArg})
}
if t == actUnbind || t == actRebind {
parseKeyChords(actionArg, spec[0:offset]+" target required")
}
}
}
prevSpec = ""
}
return actions
}
func parseKeymap(keymap map[tui.Event][]*action, str string, exit func(string)) {
masked := maskActionContents(str)
idx := 0
for _, pairStr := range strings.Split(masked, ",") {
origPairStr := str[idx : idx+len(pairStr)]
@ -937,7 +1154,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) {
pair := strings.SplitN(pairStr, ":", 2)
if len(pair) < 2 {
errorExit("bind action not specified: " + origPairStr)
exit("bind action not specified: " + origPairStr)
}
var key tui.Event
if len(pair[0]) == 1 && pair[0][0] == escapedColon {
@ -950,225 +1167,19 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) {
keys := parseKeyChords(pair[0], "key name required")
key = firstKey(keys)
}
idx2 := len(pair[0]) + 1
specs := strings.Split(pair[1], "+")
actions := make([]*action, 0, len(specs))
appendAction := func(types ...actionType) {
actions = append(actions, toActions(types...)...)
}
prevSpec := ""
for specIndex, maskedSpec := range specs {
spec := origPairStr[idx2 : idx2+len(maskedSpec)]
idx2 += len(maskedSpec) + 1
spec = prevSpec + spec
specLower := strings.ToLower(spec)
switch specLower {
case "ignore":
appendAction(actIgnore)
case "beginning-of-line":
appendAction(actBeginningOfLine)
case "abort":
appendAction(actAbort)
case "accept":
appendAction(actAccept)
case "accept-non-empty":
appendAction(actAcceptNonEmpty)
case "print-query":
appendAction(actPrintQuery)
case "refresh-preview":
appendAction(actRefreshPreview)
case "replace-query":
appendAction(actReplaceQuery)
case "backward-char":
appendAction(actBackwardChar)
case "backward-delete-char":
appendAction(actBackwardDeleteChar)
case "backward-delete-char/eof":
appendAction(actBackwardDeleteCharEOF)
case "backward-word":
appendAction(actBackwardWord)
case "clear-screen":
appendAction(actClearScreen)
case "delete-char":
appendAction(actDeleteChar)
case "delete-char/eof":
appendAction(actDeleteCharEOF)
case "deselect":
appendAction(actDeselect)
case "end-of-line":
appendAction(actEndOfLine)
case "cancel":
appendAction(actCancel)
case "clear-query":
appendAction(actClearQuery)
case "clear-selection":
appendAction(actClearSelection)
case "forward-char":
appendAction(actForwardChar)
case "forward-word":
appendAction(actForwardWord)
case "jump":
appendAction(actJump)
case "jump-accept":
appendAction(actJumpAccept)
case "kill-line":
appendAction(actKillLine)
case "kill-word":
appendAction(actKillWord)
case "unix-line-discard", "line-discard":
appendAction(actUnixLineDiscard)
case "unix-word-rubout", "word-rubout":
appendAction(actUnixWordRubout)
case "yank":
appendAction(actYank)
case "backward-kill-word":
appendAction(actBackwardKillWord)
case "toggle-down":
appendAction(actToggle, actDown)
case "toggle-up":
appendAction(actToggle, actUp)
case "toggle-in":
appendAction(actToggleIn)
case "toggle-out":
appendAction(actToggleOut)
case "toggle-all":
appendAction(actToggleAll)
case "toggle-search":
appendAction(actToggleSearch)
case "select":
appendAction(actSelect)
case "select-all":
appendAction(actSelectAll)
case "deselect-all":
appendAction(actDeselectAll)
case "close":
appendAction(actClose)
case "toggle":
appendAction(actToggle)
case "down":
appendAction(actDown)
case "up":
appendAction(actUp)
case "first", "top":
appendAction(actFirst)
case "last":
appendAction(actLast)
case "page-up":
appendAction(actPageUp)
case "page-down":
appendAction(actPageDown)
case "half-page-up":
appendAction(actHalfPageUp)
case "half-page-down":
appendAction(actHalfPageDown)
case "prev-history", "previous-history":
appendAction(actPrevHistory)
case "next-history":
appendAction(actNextHistory)
case "prev-selected":
appendAction(actPrevSelected)
case "next-selected":
appendAction(actNextSelected)
case "toggle-preview":
appendAction(actTogglePreview)
case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap)
case "toggle-sort":
appendAction(actToggleSort)
case "preview-top":
appendAction(actPreviewTop)
case "preview-bottom":
appendAction(actPreviewBottom)
case "preview-up":
appendAction(actPreviewUp)
case "preview-down":
appendAction(actPreviewDown)
case "preview-page-up":
appendAction(actPreviewPageUp)
case "preview-page-down":
appendAction(actPreviewPageDown)
case "preview-half-page-up":
appendAction(actPreviewHalfPageUp)
case "preview-half-page-down":
appendAction(actPreviewHalfPageDown)
case "enable-search":
appendAction(actEnableSearch)
case "disable-search":
appendAction(actDisableSearch)
case "put":
if key.Type == tui.Rune && unicode.IsGraphic(key.Char) {
appendAction(actRune)
} else {
errorExit("unable to put non-printable character: " + pair[0])
}
default:
t := isExecuteAction(specLower)
if t == actIgnore {
if specIndex == 0 && specLower == "" {
actions = append(keymap[key], actions...)
} else {
errorExit("unknown action: " + spec)
}
} else {
var offset int
switch t {
case actReload:
offset = len("reload")
case actPreview:
offset = len("preview")
case actChangePreviewWindow:
offset = len("change-preview-window")
case actChangePreview:
offset = len("change-preview")
case actChangePrompt:
offset = len("change-prompt")
case actChangeQuery:
offset = len("change-query")
case actUnbind:
offset = len("unbind")
case actRebind:
offset = len("rebind")
case actExecuteSilent:
offset = len("execute-silent")
case actExecuteMulti:
offset = len("execute-multi")
default:
offset = len("execute")
}
var actionArg string
if spec[offset] == ':' {
if specIndex == len(specs)-1 {
actionArg = spec[offset+1:]
actions = append(actions, &action{t: t, a: actionArg})
} else {
prevSpec = spec + "+"
continue
}
} else {
actionArg = spec[offset+1 : len(spec)-1]
actions = append(actions, &action{t: t, a: actionArg})
}
if t == actUnbind || t == actRebind {
parseKeyChords(actionArg, spec[0:offset]+" target required")
}
}
}
prevSpec = ""
}
keymap[key] = actions
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key] = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed, exit)
}
}
func isExecuteAction(str string) actionType {
matches := executeRegexp.FindAllStringSubmatch(":"+str, -1)
if matches == nil || len(matches) != 1 {
masked := maskActionContents(":" + str)[1:]
if masked == str {
// Not masked
return actIgnore
}
prefix := matches[0][1]
if len(prefix) == 0 {
prefix = matches[0][2]
}
prefix := actionNameRegexp.FindString(str)
switch prefix {
case "reload":
return actReload
@ -1186,12 +1197,18 @@ func isExecuteAction(str string) actionType {
return actChangePrompt
case "change-query":
return actChangeQuery
case "pos":
return actPosition
case "execute":
return actExecute
case "execute-silent":
return actExecuteSilent
case "execute-multi":
return actExecuteMulti
case "put":
return actPut
case "transform-query":
return actTransformQuery
}
return actIgnore
}
@ -1455,7 +1472,7 @@ func parseOptions(opts *Options, allArgs []string) {
case "--tiebreak":
opts.Criteria = parseTiebreak(nextString(allArgs, &i, "sort criterion required"))
case "--bind":
parseKeymap(opts.Keymap, nextString(allArgs, &i, "bind expression required"))
parseKeymap(opts.Keymap, nextString(allArgs, &i, "bind expression required"), errorExit)
case "--color":
_, spec := optionalNextString(allArgs, &i)
if len(spec) == 0 {
@ -1657,6 +1674,10 @@ func parseOptions(opts *Options, allArgs []string) {
nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)"))
case "--tabstop":
opts.Tabstop = nextInt(allArgs, &i, "tab stop required")
case "--listen":
opts.ListenPort = nextInt(allArgs, &i, "listen port required")
case "--no-listen":
opts.ListenPort = 0
case "--clear":
opts.ClearOnExit = true
case "--no-clear":
@ -1723,7 +1744,7 @@ func parseOptions(opts *Options, allArgs []string) {
} else if match, value := optString(arg, "--color="); match {
opts.Theme = parseTheme(opts.Theme, value)
} else if match, value := optString(arg, "--bind="); match {
parseKeymap(opts.Keymap, value)
parseKeymap(opts.Keymap, value, errorExit)
} else if match, value := optString(arg, "--history="); match {
setHistory(value)
} else if match, value := optString(arg, "--history-size="); match {
@ -1744,6 +1765,8 @@ func parseOptions(opts *Options, allArgs []string) {
opts.Padding = parseMargin("padding", value)
} else if match, value := optString(arg, "--tabstop="); match {
opts.Tabstop = atoi(value)
} else if match, value := optString(arg, "--listen="); match {
opts.ListenPort = atoi(value)
} else if match, value := optString(arg, "--hscroll-off="); match {
opts.HscrollOff = atoi(value)
} else if match, value := optString(arg, "--scroll-off="); match {
@ -1773,6 +1796,10 @@ func parseOptions(opts *Options, allArgs []string) {
errorExit("tab stop must be a positive integer")
}
if opts.ListenPort < 0 || opts.ListenPort > 65535 {
errorExit("invalid listen port")
}
if len(opts.JumpLabels) == 0 {
errorExit("empty jump labels")
}

@ -262,13 +262,17 @@ func TestBind(t *testing.T) {
}
}
check(tui.CtrlA.AsEvent(), "", actBeginningOfLine)
errorString := ""
errorFn := func(e string) {
errorString = e
}
parseKeymap(keymap,
"ctrl-a:kill-line,ctrl-b:toggle-sort+up+down,c:page-up,alt-z:page-down,"+
"f1:execute(ls {+})+abort+execute(echo {+})+select-all,f2:execute/echo {}, {}, {}/,f3:execute[echo '({})'],f4:execute;less {};,"+
"alt-a:execute-Multi@echo (,),[,],/,:,;,%,{}@,alt-b:execute;echo (,),[,],/,:,@,%,{};,"+
"x:Execute(foo+bar),X:execute/bar+baz/"+
",f1:+first,f1:+top"+
",,:abort,::accept,+:execute:++\nfoobar,Y:execute(baz)+up")
",,:abort,::accept,+:execute:++\nfoobar,Y:execute(baz)+up", errorFn)
check(tui.CtrlA.AsEvent(), "", actKillLine)
check(tui.CtrlB.AsEvent(), "", actToggleSort, actUp, actDown)
check(tui.Key('c'), "", actPageUp)
@ -286,12 +290,15 @@ func TestBind(t *testing.T) {
check(tui.Key('+'), "++\nfoobar,Y:execute(baz)+up", actExecute)
for idx, char := range []rune{'~', '!', '@', '#', '$', '%', '^', '&', '*', '|', ';', '/'} {
parseKeymap(keymap, fmt.Sprintf("%d:execute%cfoobar%c", idx%10, char, char))
parseKeymap(keymap, fmt.Sprintf("%d:execute%cfoobar%c", idx%10, char, char), errorFn)
check(tui.Key([]rune(fmt.Sprintf("%d", idx%10))[0]), "foobar", actExecute)
}
parseKeymap(keymap, "f1:abort")
parseKeymap(keymap, "f1:abort", errorFn)
check(tui.F1.AsEvent(), "", actAbort)
if len(errorString) > 0 {
t.Errorf("error parsing keymap: %s", errorString)
}
}
func TestColorSpec(t *testing.T) {
@ -466,3 +473,38 @@ func TestValidateSign(t *testing.T) {
}
}
}
func TestParseSingleActionList(t *testing.T) {
actions := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", func(string) {})
if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions))
}
if actions[0].t != actExecute || actions[0].a != "foo+bar,baz" {
t.Errorf("Invalid action parsed: %v", actions[0])
}
if actions[1].t != actUp || actions[2].t != actUp {
t.Errorf("Invalid action parsed: %v / %v", actions[1], actions[2])
}
if actions[3].t != actReload || actions[3].a != "down+down" {
t.Errorf("Invalid action parsed: %v", actions[3])
}
}
func TestParseSingleActionListError(t *testing.T) {
err := ""
parseSingleActionList("change-query(foobar)baz", func(e string) {
err = e
})
if len(err) == 0 {
t.Errorf("Failed to detect error")
}
}
func TestMaskActionContents(t *testing.T) {
original := ":execute((f)(o)(o)(b)(a)(r))+change-query@qu@ry@+up,x:reload:hello:world"
expected := ":execute +change-query +up,x:reload "
masked := maskActionContents(original)
if masked != expected {
t.Errorf("Not masked: %s", masked)
}
}

@ -0,0 +1,126 @@
package fzf
import (
"bufio"
"bytes"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
)
const (
crlf = "\r\n"
httpOk = "HTTP/1.1 200 OK" + crlf
httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf
httpReadTimeout = 10 * time.Second
maxContentLength = 1024 * 1024
)
func startHttpServer(port int, channel chan []*action) error {
if port == 0 {
return nil
}
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("port not available: %d", port)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
break
} else {
continue
}
}
conn.Write([]byte(handleHttpRequest(conn, channel)))
conn.Close()
}
listener.Close()
}()
return nil
}
// Here we are writing a simplistic HTTP server without using net/http
// package to reduce the size of the binary.
//
// * No --listen: 2.8MB
// * --listen with net/http: 5.7MB
// * --listen w/o net/http: 3.3MB
func handleHttpRequest(conn net.Conn, channel chan []*action) string {
contentLength := 0
body := ""
bad := func(message string) string {
message += "\n"
return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
}
conn.SetReadDeadline(time.Now().Add(httpReadTimeout))
scanner := bufio.NewScanner(conn)
scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) {
found := bytes.Index(data, []byte(crlf))
if found >= 0 {
token := data[:found+len(crlf)]
return len(token), token, nil
}
if atEOF || len(body)+len(data) >= contentLength {
return 0, data, bufio.ErrFinalToken
}
return 0, nil, nil
})
section := 0
for scanner.Scan() {
text := scanner.Text()
switch section {
case 0:
if !strings.HasPrefix(text, "POST / HTTP") {
return bad("invalid request method")
}
section++
case 1:
if text == crlf {
if contentLength == 0 {
return bad("content-length header missing")
}
section++
continue
}
pair := strings.SplitN(text, ":", 2)
if len(pair) == 2 && strings.ToLower(pair[0]) == "content-length" {
length, err := strconv.Atoi(strings.TrimSpace(pair[1]))
if err != nil || length <= 0 || length > maxContentLength {
return bad("invalid content length")
}
contentLength = length
}
case 2:
body += text
}
}
if len(body) < contentLength {
return bad("incomplete request")
}
body = body[:contentLength]
errorMessage := ""
actions := parseSingleActionList(strings.Trim(string(body), "\r\n"), func(message string) {
errorMessage = message
})
if len(errorMessage) > 0 {
return bad(errorMessage)
}
if len(actions) == 0 {
return bad("no action specified")
}
channel <- actions
return httpOk
}

@ -167,6 +167,7 @@ type Terminal struct {
padding [4]sizeSpec
strong tui.Attr
unicode bool
listenPort int
borderShape tui.BorderShape
cleanExit bool
paused bool
@ -200,6 +201,7 @@ type Terminal struct {
sigstop bool
startChan chan fitpad
killChan chan int
serverChan chan []*action
slab *util.Slab
theme *tui.ColorTheme
tui tui.Renderer
@ -295,6 +297,7 @@ const (
actUp
actPageUp
actPageDown
actPosition
actHalfPageUp
actHalfPageDown
actJump
@ -305,6 +308,7 @@ const (
actToggleSort
actTogglePreview
actTogglePreviewWrap
actTransformQuery
actPreview
actChangePreview
actChangePreviewWindow
@ -318,6 +322,7 @@ const (
actPreviewHalfPageDown
actPrevHistory
actPrevSelected
actPut
actNextHistory
actNextSelected
actExecute
@ -481,7 +486,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
}
var previewBox *util.EventBox
showPreviewWindow := len(opts.Preview.command) > 0 && !opts.Preview.hidden
if len(opts.Preview.command) > 0 || hasPreviewAction(opts) {
// We need to start previewer if HTTP server is enabled even when --preview option is not specified
if len(opts.Preview.command) > 0 || hasPreviewAction(opts) || opts.ListenPort > 0 {
previewBox = util.NewEventBox()
}
strongAttr := tui.Bold
@ -556,6 +562,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
margin: opts.Margin,
padding: opts.Padding,
unicode: opts.Unicode,
listenPort: opts.ListenPort,
borderShape: opts.BorderShape,
borderLabel: nil,
borderLabelOpts: opts.BorderLabel,
@ -595,6 +602,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
theme: opts.Theme,
startChan: make(chan fitpad, 1),
killChan: make(chan int),
serverChan: make(chan []*action, 10),
tui: renderer,
initFunc: func() { renderer.Init() },
executing: util.NewAtomicBool(false)}
@ -616,6 +624,10 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
}
if err := startHttpServer(t.listenPort, t.serverChan); err != nil {
errorExit(err.Error())
}
return &t
}
@ -2003,10 +2015,11 @@ func (t *Terminal) redraw(clear bool) {
t.printAll()
}
func (t *Terminal) executeCommand(template string, forcePlus bool, background bool) {
func (t *Terminal) executeCommand(template string, forcePlus bool, background bool, captureFirstLine bool) string {
line := ""
valid, list := t.buildPlusList(template, forcePlus)
if !valid {
return
return line
}
command := t.replacePlaceholder(template, forcePlus, string(t.input), list)
cmd := util.ExecCommand(command, false)
@ -2022,11 +2035,21 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
t.refresh()
} else {
t.tui.Pause(false)
cmd.Run()
if captureFirstLine {
out, _ := cmd.StdoutPipe()
reader := bufio.NewReader(out)
cmd.Start()
line, _ = reader.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
cmd.Wait()
} else {
cmd.Run()
}
t.tui.Resume(false, false)
}
t.executing.Set(false)
cleanTemporaryFiles()
return line
}
func (t *Terminal) hasPreviewer() bool {
@ -2492,6 +2515,15 @@ func (t *Terminal) Loop() {
looping := true
_, startEvent := t.keymap[tui.Start.AsEvent()]
eventChan := make(chan tui.Event)
needBarrier := true
barrier := make(chan bool)
go func() {
for {
<-barrier
eventChan <- t.tui.GetChar()
}
}()
for looping {
var newCommand *string
changed := false
@ -2499,11 +2531,21 @@ func (t *Terminal) Loop() {
queryChanged := false
var event tui.Event
actions := []*action{}
if startEvent {
event = tui.Start.AsEvent()
startEvent = false
} else {
event = t.tui.GetChar()
if needBarrier {
barrier <- true
}
select {
case event = <-eventChan:
needBarrier = true
case actions = <-t.serverChan:
event = tui.Invalid.AsEvent()
needBarrier = false
}
}
t.mutex.Lock()
@ -2580,9 +2622,9 @@ func (t *Terminal) Loop() {
switch a.t {
case actIgnore:
case actExecute, actExecuteSilent:
t.executeCommand(a.a, false, a.t == actExecuteSilent)
t.executeCommand(a.a, false, a.t == actExecuteSilent, false)
case actExecuteMulti:
t.executeCommand(a.a, true, false)
t.executeCommand(a.a, true, false, false)
case actInvalid:
t.mutex.Unlock()
return false
@ -2605,6 +2647,10 @@ func (t *Terminal) Loop() {
t.previewed.version = 0
req(reqPreviewRefresh)
}
case actTransformQuery:
query := t.executeCommand(a.a, false, true, true)
t.input = []rune(query)
t.cx = len(t.input)
case actToggleSort:
t.sort = !t.sort
changed = true
@ -2806,6 +2852,21 @@ func (t *Terminal) Loop() {
case actLast:
t.vset(t.merger.Length() - 1)
req(reqList)
case actPosition:
if n, e := strconv.Atoi(a.a); e == nil {
if n > 0 {
n--
} else if n < 0 {
n += t.merger.Length()
}
t.vset(n)
req(reqList)
}
case actPut:
str := []rune(a.a)
suffix := copySlice(t.input[t.cx:])
t.input = append(append(t.input[:t.cx], str...), suffix...)
t.cx += len(str)
case actUnixLineDiscard:
beof = len(t.input) == 0
if t.cx > 0 {
@ -3042,8 +3103,15 @@ func (t *Terminal) Loop() {
return true
}
if t.jumping == jumpDisabled {
actions := t.keymap[event.Comparable()]
if t.jumping == jumpDisabled || len(actions) > 0 {
// Break out of jump mode if any action is submitted to the server
if t.jumping != jumpDisabled {
t.jumping = jumpDisabled
req(reqList)
}
if len(actions) == 0 {
actions = t.keymap[event.Comparable()]
}
if len(actions) == 0 && event.Type == tui.Rune {
doAction(&action{t: actRune})
} else if !doActions(actions) {

@ -7,6 +7,7 @@ require 'English'
require 'shellwords'
require 'erb'
require 'tempfile'
require 'net/http'
TEMPLATE = DATA.read
UNSETS = %w[
@ -1604,6 +1605,30 @@ class TestGoFZF < TestBase
tmux.send_keys :Enter
end
def test_pos
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:pos(3),b:pos(-3),c:pos(1),d:pos(-1),e:pos(0)' --preview 'echo {}/{}'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_includes lines[1], ' 3/3' }
tmux.send_keys :b
tmux.until { |lines| assert_includes lines[1], ' 998/998' }
tmux.send_keys :c
tmux.until { |lines| assert_includes lines[1], ' 1/1' }
tmux.send_keys :d
tmux.until { |lines| assert_includes lines[1], ' 1000/1000' }
tmux.send_keys :e
tmux.until { |lines| assert_includes lines[1], ' 1/1' }
end
def test_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:put+put,b:put+put(ravo)' --preview 'echo {q}/{q}'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_includes lines[1], ' aa/aa' }
tmux.send_keys :b
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
end
def test_accept_non_empty
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
@ -1772,6 +1797,15 @@ class TestGoFZF < TestBase
tmux.until { |lines| assert_equal '> foobarbaz', lines.last }
end
def test_transform_query
tmux.send_keys %{#{FZF} --bind 'ctrl-r:transform-query(rev <<< {q}),ctrl-u:transform-query: tr "[:lower:]" "[:upper:]" <<< {q}' --query bar}, :Enter
tmux.until { |lines| assert_equal '> bar', lines[-1] }
tmux.send_keys 'C-r'
tmux.until { |lines| assert_equal '> rab', lines[-1] }
tmux.send_keys 'C-u'
tmux.until { |lines| assert_equal '> RAB', lines[-1] }
end
def test_clear_selection
tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@ -2435,6 +2469,14 @@ class TestGoFZF < TestBase
tmux.send_keys 'C-p'
tmux.until { |lines| assert_includes lines, '>>2' }
end
def test_listen
tmux.send_keys 'seq 10 | fzf --listen 6266', :Enter
tmux.until { |lines| assert_equal 10, lines.item_count }
Net::HTTP.post(URI('http://localhost:6266'), 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ')
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
end
end
module TestShell

Loading…
Cancel
Save