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
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
- Bug fixes

View File

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

View File

@ -23,11 +23,12 @@ const (
)
type httpServer struct {
apiKey []byte
channel chan []*action
apiKey []byte
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 {
return nil, port
}
@ -50,8 +51,9 @@ func startHttpServer(port int, channel chan []*action) (error, int) {
}
server := httpServer{
apiKey: []byte(os.Getenv("FZF_API_KEY")),
channel: channel,
apiKey: []byte(os.Getenv("FZF_API_KEY")),
actionChannel: actionChannel,
responseChannel: responseChannel,
}
go func() {
@ -83,13 +85,18 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0
apiKey := ""
body := ""
unauthorized := func(message string) string {
answer := func(code string, message string) string {
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 {
message += "\n"
return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
return answer(httpBadRequest, message)
}
good := func(message string) string {
return answer(httpOk+"Content-Type: application/json"+crlf, message)
}
conn.SetReadDeadline(time.Now().Add(httpReadTimeout))
scanner := bufio.NewScanner(conn)
@ -110,7 +117,12 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
text := scanner.Text()
switch section {
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")
}
section++
@ -160,6 +172,6 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
return bad("no action specified")
}
server.channel <- actions
server.actionChannel <- actions
return httpOk
}

View File

@ -2,6 +2,7 @@ package fzf
import (
"bufio"
"encoding/json"
"fmt"
"io"
"math"
@ -144,6 +145,23 @@ var emptyLine = itemLine{}
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
type Terminal struct {
initDelay time.Duration
@ -245,7 +263,8 @@ type Terminal struct {
sigstop bool
startChan chan fitpad
killChan chan int
serverChan chan []*action
serverInputChan chan []*action
serverOutputChan chan string
eventChan chan tui.Event
slab *util.Slab
theme *tui.ColorTheme
@ -398,6 +417,7 @@ const (
actUnbind
actRebind
actBecome
actResponse
)
type placeholderFlags struct {
@ -657,7 +677,8 @@ 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),
serverInputChan: make(chan []*action, 10),
serverOutputChan: make(chan string),
eventChan: make(chan tui.Event, 1),
tui: renderer,
initFunc: func() { renderer.Init() },
@ -703,7 +724,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
_, t.hasLoadActions = t.keymap[tui.Load.AsEvent()]
if t.listenPort != nil {
err, port := startHttpServer(*t.listenPort, t.serverChan)
err, port := startHttpServer(*t.listenPort, t.serverInputChan, t.serverOutputChan)
if err != nil {
errorExit(err.Error())
}
@ -2813,7 +2834,7 @@ func (t *Terminal) Loop() {
t.printInfo()
}
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && focusChanged {
t.serverChan <- onFocus
t.serverInputChan <- onFocus
}
if focusChanged || version != t.version {
version = t.version
@ -2925,7 +2946,7 @@ func (t *Terminal) Loop() {
select {
case event = <-t.eventChan:
needBarrier = !event.Is(tui.Load, tui.One, tui.Zero)
case actions = <-t.serverChan:
case actions = <-t.serverInputChan:
event = tui.Invalid.AsEvent()
needBarrier = false
}
@ -3000,6 +3021,8 @@ func (t *Terminal) Loop() {
doAction = func(a *action) bool {
switch a.t {
case actIgnore:
case actResponse:
t.serverOutputChan <- t.dumpStatus()
case actBecome:
valid, list := t.buildPlusList(a.a, false)
if valid {
@ -3768,3 +3791,49 @@ func (t *Terminal) maxItems() int {
}
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 'tempfile'
require 'net/http'
require 'json'
TEMPLATE = DATA.read
UNSETS = %w[
@ -2776,9 +2777,21 @@ class TestGoFZF < TestBase
-> { URI("http://localhost:#{File.read('/tmp/fzf-port').chomp}") } }.each do |opts, fn|
tmux.send_keys "seq 10 | fzf #{opts}", :Enter
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> ')
tmux.until { |lines| assert_equal 100, lines.item_count }
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
setup
end