Add 'GET /' endpoint for getting the program state (experimental)

Related #3372
This commit is contained in:
Junegunn Choi 2023-09-03 16:30:35 +09:00
parent c5e4b83de3
commit 0f50dc848e
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
5 changed files with 121 additions and 16 deletions

View File

@ -14,6 +14,14 @@ CHANGELOG
# Client # Client
curl localhost:6266 -H "x-api-key: $FZF_API_KEY" -d 'change-query(yo)' curl localhost:6266 -H "x-api-key: $FZF_API_KEY" -d 'change-query(yo)'
``` ```
- `--listen` server can report program state in JSON format (`GET /`)
```sh
# fzf server started in "headless" mode
fzf --listen 6266 2> /dev/null
# Get program state
curl localhost:6266 | jq .
```
- Added `toggle-header` action - Added `toggle-header` action
- Bug fixes - Bug fixes

View File

@ -795,6 +795,9 @@ e.g.
\fB# Start HTTP server on port 6266 \fB# Start HTTP server on port 6266
fzf --listen 6266 fzf --listen 6266
# Get program state in JSON format (experimental)
curl localhost:6266
# Send action to the server # Send action to the server
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'

View File

@ -24,10 +24,11 @@ const (
type httpServer struct { type httpServer struct {
apiKey []byte apiKey []byte
channel chan []*action actionChannel chan []*action
responseChannel chan string
} }
func startHttpServer(port int, channel chan []*action) (error, int) { func startHttpServer(port int, actionChannel chan []*action, responseChannel chan string) (error, int) {
if port < 0 { if port < 0 {
return nil, port return nil, port
} }
@ -51,7 +52,8 @@ func startHttpServer(port int, channel chan []*action) (error, int) {
server := httpServer{ server := httpServer{
apiKey: []byte(os.Getenv("FZF_API_KEY")), apiKey: []byte(os.Getenv("FZF_API_KEY")),
channel: channel, actionChannel: actionChannel,
responseChannel: responseChannel,
} }
go func() { go func() {
@ -83,13 +85,18 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0 contentLength := 0
apiKey := "" apiKey := ""
body := "" body := ""
unauthorized := func(message string) string { answer := func(code string, message string) string {
message += "\n" message += "\n"
return httpUnauthorized + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
}
unauthorized := func(message string) string {
return answer(httpUnauthorized, message)
} }
bad := func(message string) string { bad := func(message string) string {
message += "\n" return answer(httpBadRequest, message)
return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) }
good := func(message string) string {
return answer(httpOk+"Content-Type: application/json"+crlf, message)
} }
conn.SetReadDeadline(time.Now().Add(httpReadTimeout)) conn.SetReadDeadline(time.Now().Add(httpReadTimeout))
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
@ -110,7 +117,12 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
text := scanner.Text() text := scanner.Text()
switch section { switch section {
case 0: case 0:
if !strings.HasPrefix(text, "POST / HTTP") { // TODO: Parameter support e.g. "GET /?limit=100 HTTP"
if strings.HasPrefix(text, "GET / HTTP") {
server.actionChannel <- []*action{{t: actResponse}}
response := <-server.responseChannel
return good(response)
} else if !strings.HasPrefix(text, "POST / HTTP") {
return bad("invalid request method") return bad("invalid request method")
} }
section++ section++
@ -160,6 +172,6 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
return bad("no action specified") return bad("no action specified")
} }
server.channel <- actions server.actionChannel <- actions
return httpOk return httpOk
} }

View File

@ -2,6 +2,7 @@ package fzf
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -144,6 +145,23 @@ var emptyLine = itemLine{}
type labelPrinter func(tui.Window, int) type labelPrinter func(tui.Window, int)
type StatusItem struct {
Index int `json:"index"`
Text string `json:"text"`
}
type Status struct {
Reading bool `json:"reading"`
Query string `json:"query"`
Position int `json:"position"`
Sort bool `json:"sort"`
TotalCount int `json:"totalCount"`
MatchCount int `json:"matchCount"`
Current *StatusItem `json:"current"`
Matches []StatusItem `json:"matches"`
Selected []StatusItem `json:"selected"`
}
// Terminal represents terminal input/output // Terminal represents terminal input/output
type Terminal struct { type Terminal struct {
initDelay time.Duration initDelay time.Duration
@ -245,7 +263,8 @@ type Terminal struct {
sigstop bool sigstop bool
startChan chan fitpad startChan chan fitpad
killChan chan int killChan chan int
serverChan chan []*action serverInputChan chan []*action
serverOutputChan chan string
eventChan chan tui.Event eventChan chan tui.Event
slab *util.Slab slab *util.Slab
theme *tui.ColorTheme theme *tui.ColorTheme
@ -398,6 +417,7 @@ const (
actUnbind actUnbind
actRebind actRebind
actBecome actBecome
actResponse
) )
type placeholderFlags struct { type placeholderFlags struct {
@ -657,7 +677,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, 10), serverInputChan: make(chan []*action, 10),
serverOutputChan: make(chan string),
eventChan: make(chan tui.Event, 1), eventChan: make(chan tui.Event, 1),
tui: renderer, tui: renderer,
initFunc: func() { renderer.Init() }, initFunc: func() { renderer.Init() },
@ -703,7 +724,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
_, t.hasLoadActions = t.keymap[tui.Load.AsEvent()] _, t.hasLoadActions = t.keymap[tui.Load.AsEvent()]
if t.listenPort != nil { if t.listenPort != nil {
err, port := startHttpServer(*t.listenPort, t.serverChan) err, port := startHttpServer(*t.listenPort, t.serverInputChan, t.serverOutputChan)
if err != nil { if err != nil {
errorExit(err.Error()) errorExit(err.Error())
} }
@ -2813,7 +2834,7 @@ func (t *Terminal) Loop() {
t.printInfo() t.printInfo()
} }
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && focusChanged { if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && focusChanged {
t.serverChan <- onFocus t.serverInputChan <- onFocus
} }
if focusChanged || version != t.version { if focusChanged || version != t.version {
version = t.version version = t.version
@ -2925,7 +2946,7 @@ func (t *Terminal) Loop() {
select { select {
case event = <-t.eventChan: case event = <-t.eventChan:
needBarrier = !event.Is(tui.Load, tui.One, tui.Zero) needBarrier = !event.Is(tui.Load, tui.One, tui.Zero)
case actions = <-t.serverChan: case actions = <-t.serverInputChan:
event = tui.Invalid.AsEvent() event = tui.Invalid.AsEvent()
needBarrier = false needBarrier = false
} }
@ -3000,6 +3021,8 @@ func (t *Terminal) Loop() {
doAction = func(a *action) bool { doAction = func(a *action) bool {
switch a.t { switch a.t {
case actIgnore: case actIgnore:
case actResponse:
t.serverOutputChan <- t.dumpStatus()
case actBecome: case actBecome:
valid, list := t.buildPlusList(a.a, false) valid, list := t.buildPlusList(a.a, false)
if valid { if valid {
@ -3768,3 +3791,49 @@ func (t *Terminal) maxItems() int {
} }
return util.Max(max, 0) return util.Max(max, 0)
} }
func (t *Terminal) dumpItem(i *Item) StatusItem {
if i == nil {
return StatusItem{}
}
return StatusItem{
Index: int(i.Index()),
Text: i.AsString(t.ansi),
}
}
const dumpItemLimit = 100 // TODO: Make configurable via GET parameter
func (t *Terminal) dumpStatus() string {
selectedItems := t.sortSelected()
selected := make([]StatusItem, util.Min(dumpItemLimit, len(selectedItems)))
for i, selectedItem := range selectedItems[:len(selected)] {
selected[i] = t.dumpItem(selectedItem.item)
}
matches := make([]StatusItem, util.Min(dumpItemLimit, t.merger.Length()))
for i := range matches {
matches[i] = t.dumpItem(t.merger.Get(i).item)
}
var current *StatusItem
currentItem := t.currentItem()
if currentItem != nil {
item := t.dumpItem(currentItem)
current = &item
}
dump := Status{
Reading: t.reading,
Query: string(t.input),
Position: t.cy,
Sort: t.sort,
TotalCount: t.count,
MatchCount: t.merger.Length(),
Current: current,
Matches: matches,
Selected: selected,
}
bytes, _ := json.Marshal(&dump) // TODO: Errors?
return string(bytes)
}

View File

@ -8,6 +8,7 @@ require 'shellwords'
require 'erb' require 'erb'
require 'tempfile' require 'tempfile'
require 'net/http' require 'net/http'
require 'json'
TEMPLATE = DATA.read TEMPLATE = DATA.read
UNSETS = %w[ UNSETS = %w[
@ -2776,9 +2777,21 @@ class TestGoFZF < TestBase
-> { URI("http://localhost:#{File.read('/tmp/fzf-port').chomp}") } }.each do |opts, fn| -> { URI("http://localhost:#{File.read('/tmp/fzf-port').chomp}") } }.each do |opts, fn|
tmux.send_keys "seq 10 | fzf #{opts}", :Enter tmux.send_keys "seq 10 | fzf #{opts}", :Enter
tmux.until { |lines| assert_equal 10, lines.item_count } tmux.until { |lines| assert_equal 10, lines.item_count }
state = JSON.parse(Net::HTTP.get(fn.call), symbolize_names: true)
assert_equal 10, state[:totalCount]
assert_equal 10, state[:matchCount]
assert_empty state[:query]
assert_equal({ index: 0, text: '1' }, state[:current])
Net::HTTP.post(fn.call, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ') Net::HTTP.post(fn.call, 'change-query(yo)+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> yo', lines[-1] }
state = JSON.parse(Net::HTTP.get(fn.call), symbolize_names: true)
assert_equal 100, state[:totalCount]
assert_equal 0, state[:matchCount]
assert_equal 'yo', state[:query]
assert_nil state[:current]
teardown teardown
setup setup
end end