diff --git a/.goreleaser.yml b/.goreleaser.yml index 5bec9029..fd3de3f1 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -73,6 +73,7 @@ builds: - arm - arm64 - loong64 + - ppc64le goarm: - 5 - 6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e239f1d..3608f4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README-VIM.md b/README-VIM.md index 425bf672..8d89de57 100644 --- a/README-VIM.md +++ b/README-VIM.md @@ -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 ``` diff --git a/install b/install index 73d8aa7f..463e3339 100755 --- a/install +++ b/install @@ -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 ;; diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 997bb57d..5ee10e0d 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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 diff --git a/src/options.go b/src/options.go index 64cf5609..1288fbe4 100644 --- a/src/options.go +++ b/src/options.go @@ -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") } diff --git a/src/options_test.go b/src/options_test.go index 0fb569fe..8754716c 100644 --- a/src/options_test.go +++ b/src/options_test.go @@ -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) + } +} diff --git a/src/server.go b/src/server.go new file mode 100644 index 00000000..421bc20b --- /dev/null +++ b/src/server.go @@ -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 +} diff --git a/src/terminal.go b/src/terminal.go index e0ff5b2b..685b1240 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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) { diff --git a/test/test_go.rb b/test/test_go.rb index 5bffaaf9..a4d2e2a8 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -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