diff --git a/CHANGELOG.md b/CHANGELOG.md index b22e667..0b0ae10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index e9de4fe..4388c97 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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> )' diff --git a/src/server.go b/src/server.go index c583400..3698015 100644 --- a/src/server.go +++ b/src/server.go @@ -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 } diff --git a/src/terminal.go b/src/terminal.go index 6418a5d..1d8b55b 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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) +} diff --git a/test/test_go.rb b/test/test_go.rb index f85f7a2..c192835 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -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