Add GET endpoints for getting the state of the finder

* GET / (or GET /current)
* GET /query
This commit is contained in:
Junegunn Choi 2022-12-25 16:27:02 +09:00
parent de0da86bd7
commit 750b2a6313
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
6 changed files with 66 additions and 22 deletions

View File

@ -3,13 +3,21 @@ CHANGELOG
0.36.0 0.36.0
------ ------
- Added `--listen=HTTP_PORT` option to receive actions from external processes - Added `--listen=HTTP_PORT` option to start HTTP server. It allows external
processes to send actions to perform via POST method, or retrieve the
current state of the finder.
```sh ```sh
# Start HTTP server on port 6266 # Start HTTP server on port 6266
fzf --listen 6266 fzf --listen 6266
# Send actions to the server # Send action to the server via POST method
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' curl localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
# Retrieve the current item
curl localhost:6266
# Retrieve the query string
curl localhost:6266/query
``` ```
- Added `next-selected` and `prev-selected` actions to move between selected - Added `next-selected` and `prev-selected` actions to move between selected
items items

View File

@ -722,14 +722,22 @@ e.g. \fBfzf --multi | fzf --sync\fR
.RE .RE
.TP .TP
.B "--listen=HTTP_PORT" .B "--listen=HTTP_PORT"
Start HTTP server on the given port to receive actions via POST requests. Start HTTP server on the given port. It allows external processes to send
actions to perform via POST method, or retrieve the current state of the
finder.
e.g. e.g.
\fB# Start HTTP server on port 6266 \fB# Start HTTP server on port 6266
fzf --listen 6266 fzf --listen 6266
# Send action to the server # Send action to the server via POST method
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' curl localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
# Retrieve the current item
curl localhost:6266
# Retrieve the query string
curl localhost:6266/query
\fR \fR
The port number is exported as \fB$FZF_LISTEN_PORT\fR on the child processes. The port number is exported as \fB$FZF_LISTEN_PORT\fR on the child processes.

View File

@ -113,7 +113,7 @@ const usage = `usage: fzf [options]
--read0 Read input delimited by ASCII NUL characters --read0 Read input delimited by ASCII NUL characters
--print0 Print output delimited by ASCII NUL characters --print0 Print output delimited by ASCII NUL characters
--sync Synchronous search for multi-staged filtering --sync Synchronous search for multi-staged filtering
--listen=HTTP_PORT Start HTTP server to receive actions (POST /) --listen=HTTP_PORT Start HTTP server to receive actions
--version Display version information and exit --version Display version information and exit
Environment variables Environment variables

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -13,13 +14,18 @@ import (
const ( const (
crlf = "\r\n" crlf = "\r\n"
httpPattern = "^(GET|POST) (/[^ ]*) HTTP"
httpOk = "HTTP/1.1 200 OK" + crlf httpOk = "HTTP/1.1 200 OK" + crlf
httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf
httpReadTimeout = 10 * time.Second httpReadTimeout = 10 * time.Second
maxContentLength = 1024 * 1024 maxContentLength = 1024 * 1024
) )
func startHttpServer(port int, channel chan []*action) error { var (
httpRegexp *regexp.Regexp
)
func startHttpServer(port int, requestChan chan []*action, responseChan chan string) error {
if port == 0 { if port == 0 {
return nil return nil
} }
@ -29,6 +35,7 @@ func startHttpServer(port int, channel chan []*action) error {
return fmt.Errorf("port not available: %d", port) return fmt.Errorf("port not available: %d", port)
} }
httpRegexp = regexp.MustCompile(httpPattern)
go func() { go func() {
for { for {
conn, err := listener.Accept() conn, err := listener.Accept()
@ -39,7 +46,7 @@ func startHttpServer(port int, channel chan []*action) error {
continue continue
} }
} }
conn.Write([]byte(handleHttpRequest(conn, channel))) conn.Write([]byte(handleHttpRequest(conn, requestChan, responseChan)))
conn.Close() conn.Close()
} }
listener.Close() listener.Close()
@ -54,12 +61,14 @@ func startHttpServer(port int, channel chan []*action) error {
// * No --listen: 2.8MB // * No --listen: 2.8MB
// * --listen with net/http: 5.7MB // * --listen with net/http: 5.7MB
// * --listen w/o net/http: 3.3MB // * --listen w/o net/http: 3.3MB
func handleHttpRequest(conn net.Conn, channel chan []*action) string { func handleHttpRequest(conn net.Conn, requestChan chan []*action, responseChan chan string) string {
contentLength := 0 contentLength := 0
body := "" body := ""
response := func(header string, message string) string {
return header + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
}
bad := func(message string) string { bad := func(message string) string {
message += "\n" return response(httpBadRequest, strings.TrimSpace(message)+"\n")
return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
} }
conn.SetReadDeadline(time.Now().Add(httpReadTimeout)) conn.SetReadDeadline(time.Now().Add(httpReadTimeout))
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
@ -80,8 +89,13 @@ func handleHttpRequest(conn net.Conn, channel chan []*action) string {
text := scanner.Text() text := scanner.Text()
switch section { switch section {
case 0: case 0:
if !strings.HasPrefix(text, "POST / HTTP") { httpMatch := httpRegexp.FindStringSubmatch(text)
return bad("invalid request method") if len(httpMatch) != 3 {
return bad("invalid HTTP request: " + text)
}
if httpMatch[1] == "GET" {
requestChan <- []*action{{t: actEvaluate, a: httpMatch[2][1:]}}
return response(httpOk, <-responseChan)
} }
section++ section++
case 1: case 1:
@ -120,7 +134,6 @@ func handleHttpRequest(conn net.Conn, channel chan []*action) string {
if len(actions) == 0 { if len(actions) == 0 {
return bad("no action specified") return bad("no action specified")
} }
requestChan <- actions
channel <- actions
return httpOk return httpOk
} }

View File

@ -201,7 +201,8 @@ type Terminal struct {
sigstop bool sigstop bool
startChan chan fitpad startChan chan fitpad
killChan chan int killChan chan int
serverChan chan []*action serverRequestChan chan []*action
serverResponseChan chan string
slab *util.Slab slab *util.Slab
theme *tui.ColorTheme theme *tui.ColorTheme
tui tui.Renderer tui tui.Renderer
@ -276,6 +277,7 @@ const (
actDeleteChar actDeleteChar
actDeleteCharEOF actDeleteCharEOF
actEndOfLine actEndOfLine
actEvaluate
actForwardChar actForwardChar
actForwardWord actForwardWord
actKillLine actKillLine
@ -599,7 +601,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
theme: opts.Theme, theme: opts.Theme,
startChan: make(chan fitpad, 1), startChan: make(chan fitpad, 1),
killChan: make(chan int), killChan: make(chan int),
serverChan: make(chan []*action), serverRequestChan: make(chan []*action),
serverResponseChan: make(chan string),
tui: renderer, tui: renderer,
initFunc: func() { renderer.Init() }, initFunc: func() { renderer.Init() },
executing: util.NewAtomicBool(false)} executing: util.NewAtomicBool(false)}
@ -621,7 +624,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true) t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
} }
if err := startHttpServer(t.listenPort, t.serverChan); err != nil { if err := startHttpServer(t.listenPort, t.serverRequestChan, t.serverResponseChan); err != nil {
errorExit(err.Error()) errorExit(err.Error())
} }
@ -2531,7 +2534,7 @@ func (t *Terminal) Loop() {
select { select {
case event = <-eventChan: case event = <-eventChan:
needBarrier = true needBarrier = true
case actions = <-t.serverChan: case actions = <-t.serverRequestChan:
event = tui.Invalid.AsEvent() event = tui.Invalid.AsEvent()
needBarrier = false needBarrier = false
} }
@ -2614,6 +2617,15 @@ func (t *Terminal) Loop() {
t.executeCommand(a.a, false, a.t == actExecuteSilent) t.executeCommand(a.a, false, a.t == actExecuteSilent)
case actExecuteMulti: case actExecuteMulti:
t.executeCommand(a.a, true, false) t.executeCommand(a.a, true, false)
case actEvaluate:
response := ""
switch a.a {
case "", "current":
response = t.currentItem().AsString(t.ansi)
case "query":
response = string(t.input)
}
t.serverResponseChan <- response
case actInvalid: case actInvalid:
t.mutex.Unlock() t.mutex.Unlock()
return false return false

View File

@ -2440,9 +2440,12 @@ class TestGoFZF < TestBase
def test_listen def test_listen
tmux.send_keys 'seq 10 | fzf --listen 6266', :Enter tmux.send_keys 'seq 10 | fzf --listen 6266', :Enter
tmux.until { |lines| assert_equal 10, lines.item_count } 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> ') Net::HTTP.post(URI('http://localhost:6266'), 'change-query(00)+reload(seq 100)+change-prompt:hundred> ')
tmux.until { |lines| assert_equal 100, lines.item_count } tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] } tmux.until { |lines| assert_equal 'hundred> 00', lines[-1] }
assert_equal '100', Net::HTTP.get(URI('http://localhost:6266'))
assert_equal '100', Net::HTTP.get(URI('http://localhost:6266/current'))
assert_equal '00', Net::HTTP.get(URI('http://localhost:6266/query'))
end end
end end