From 958fc7308a47f5eaf5f0febb4407c2e4a30a9c53 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Wed, 15 Apr 2026 12:33:08 -0700 Subject: [PATCH] feat: implemented the panels --- internal/app/process.go | 5 ++ internal/app/proxy.go | 3 + internal/model/event.go | 3 + internal/process/runner.go | 5 ++ internal/proxy/handler.go | 8 ++ internal/proxy/server.go | 1 + internal/tui/messages.go | 19 ++++- internal/tui/model.go | 5 +- internal/tui/panes.go | 165 ++++++++++++++++++++++++++++++------- internal/tui/update.go | 23 ++++++ internal/tui/view.go | 85 ++----------------- 11 files changed, 212 insertions(+), 110 deletions(-) diff --git a/internal/app/process.go b/internal/app/process.go index 349654f..44b7389 100644 --- a/internal/app/process.go +++ b/internal/app/process.go @@ -13,6 +13,7 @@ import ( func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProcessStarting, Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)), } @@ -35,6 +36,7 @@ func StopProcess(proc *model.Process, ch chan<- model.Event, sig syscall.Signal) } ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProcessSignaled, Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid), PID: proc.Exec.Process.Pid, @@ -58,6 +60,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) { if err := proc.Exec.Wait(); err != nil { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProcessExited, Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid), PID: proc.Exec.Process.Pid, @@ -68,6 +71,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) { } ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeFatal, Body: fmt.Sprintf("%q", err), } @@ -76,6 +80,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) { } ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProcessExited, Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid), PID: proc.Exec.Process.Pid, diff --git a/internal/app/proxy.go b/internal/app/proxy.go index 4f9f348..51674fc 100644 --- a/internal/app/proxy.go +++ b/internal/app/proxy.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "time" "termtap.dev/internal/model" ) @@ -14,6 +15,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) { } ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProxyStarting, Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()), } @@ -24,6 +26,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) { } ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeFatal, Body: fmt.Sprintf("fatal error in proxy server: %q", err), } diff --git a/internal/model/event.go b/internal/model/event.go index 0fc3628..5d532d1 100644 --- a/internal/model/event.go +++ b/internal/model/event.go @@ -1,5 +1,7 @@ package model +import "time" + type EventType string const ( @@ -26,6 +28,7 @@ const ( ) type Event struct { + Time time.Time Type EventType Body string PID int diff --git a/internal/process/runner.go b/internal/process/runner.go index 27d9b4a..521b1b9 100644 --- a/internal/process/runner.go +++ b/internal/process/runner.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "termtap.dev/internal/model" ) @@ -24,6 +25,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr stdout, err := proc.StdoutPipe() if err != nil { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("could not open stdout pipe: %q", err), PID: proc.Process.Pid, @@ -35,6 +37,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr stderr, err := proc.StderrPipe() if err != nil { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("could not open stderr pipe: %q", err), PID: proc.Process.Pid, @@ -70,6 +73,7 @@ func readPipe(pipe io.Reader, t model.EventType, ch chan<- model.Event) { scanner := bufio.NewScanner(pipe) for scanner.Scan() { ch <- model.Event{ + Time: time.Now().Local(), Type: t, Body: scanner.Text(), } @@ -100,6 +104,7 @@ func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) { } ch <- model.Event{ + Time: time.Now().Local(), Type: t, Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status), PID: proc.Exec.Process.Pid, diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index 70d7a7b..3ec26f3 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -28,6 +28,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { if req.Method == http.MethodConnect { http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host), } @@ -37,6 +38,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { if req.URL.Scheme == "" || req.URL.Host == "" { http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()), } @@ -61,6 +63,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { requestPreview, err := readAndRestoreBody(&req.Body) if err != nil { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("(%s) failed to read request body", request.ID), Request: request, @@ -81,6 +84,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { request.RawURL = outReq.URL.String() ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeRequestStarted, Body: fmt.Sprintf("-> %+v", request), Request: request, @@ -97,6 +101,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { request.Status = status ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeRequestFailed, Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err), Request: request, @@ -108,6 +113,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { responsePreview, err := readAndRestoreBody(&resp.Body) if err != nil { ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("(%s) failed to read response body", request.ID), Request: request, @@ -125,6 +131,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { request.Status = resp.StatusCode ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeRequestFailed, Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err), } @@ -137,6 +144,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler { request.Pending = false ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeRequestFinished, Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)), Request: request, diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 7ff5132..33b85e3 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -35,6 +35,7 @@ func Destroy(ps *model.ProxyServer, ch chan<- model.Event) { if ps != nil && ps.Server != nil { _ = ps.Server.Shutdown(ctx) ch <- model.Event{ + Time: time.Now().Local(), Type: model.EventTypeProxyStarted, Body: "proxy server was destroyed", } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 08f8e7d..91bac3a 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -1,6 +1,11 @@ package tui -import "termtap.dev/internal/model" +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + "termtap.dev/internal/model" +) type EventMsg struct { value model.Event @@ -9,3 +14,15 @@ type EventMsg struct { type ErrMsg struct { err error } + +type TickMsg struct { + Now time.Time +} + +const tick = 20 * time.Millisecond + +func tickCmd() tea.Cmd { + return tea.Tick(tick, func(t time.Time) tea.Msg { + return TickMsg{Now: t} + }) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 5b40714..0fd56a0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "time" tea "github.com/charmbracelet/bubbletea" "termtap.dev/internal/model" @@ -25,6 +26,8 @@ type Model struct { showEvents bool showStd bool showSearch bool + + now time.Time } func NewModel(ch <-chan model.Event) Model { @@ -47,7 +50,7 @@ func Run(ch <-chan model.Event) error { } func (m Model) Init() tea.Cmd { - return waitForEvent(m.channel) + return tea.Batch(waitForEvent(m.channel), tickCmd()) } func waitForEvent(ch <-chan model.Event) tea.Cmd { diff --git a/internal/tui/panes.go b/internal/tui/panes.go index f8ccaf9..6017916 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -3,10 +3,12 @@ package tui import ( "fmt" "strings" + "time" + + "termtap.dev/internal/model" ) func (m Model) renderStatusBar(w int) string { - // TODO: Optimize somehow var errCount int for _, req := range m.requests { if req.Failed || (req.Status >= 400 && req.Status < 600) { @@ -32,28 +34,58 @@ func (m Model) renderSearchPane(w, h int) []string { } func (m Model) renderRequestPane(w, h int) []string { - if w < 0 { - w = 0 - } - if h < 0 { - h = 0 + var lines []string + + // Render header + headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH") + headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME") + headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight))) + header := headerLeft + headerSpace + headerRight + lines = append(lines, header) + + for i := len(m.requests) - 1; i >= 0; i-- { + req := m.requests[i] + + // Some formatting magic here maybe + left := fmt.Sprintf( + " %-7s %-24s %s", + strings.ToUpper(req.Method), + req.Host, + req.URL, + ) + right := fmt.Sprintf( + "%4d %8s ", + req.Status, + formatDuration(req.Duration), + ) + if req.Pending && !req.StartTime.IsZero() { + right = fmt.Sprintf( + "%4s %8s ", + "", + formatDuration(time.Since(req.StartTime)), + ) + } + space := strings.Repeat(" ", max(0, w-len(left+right))) + + line := left + space + right + lines = append(lines, line) } - lines := make([]string, h) - for y := range lines { - lines[y] = strings.Repeat(".", w) + // Cleanup + if len(lines) < h { + for i := len(lines); i < h; i++ { + lines = append(lines, strings.Repeat(" ", w)) + } } + + if len(lines) > h { + lines = lines[:h] + } + return lines } func (m Model) renderDetailsPane(w, h int) []string { - if w < 0 { - w = 0 - } - if h < 0 { - h = 0 - } - lines := make([]string, h) for y := range lines { lines[y] = strings.Repeat("^", w) @@ -61,32 +93,101 @@ func (m Model) renderDetailsPane(w, h int) []string { return lines } +// TODO: This can be done better +// TODO: Should h be max or defined? func (m Model) renderEventsPane(w, h int) []string { - if w < 0 { - w = 0 - } - if h < 0 { - h = 0 + // Remove the stdout or stderr logs + var events []model.Event + for _, ev := range m.events { + if ev.Type != model.EventTypeProcessStderr && + ev.Type != model.EventTypeProcessStdout { + events = append(events, ev) + } } - lines := make([]string, h) - for y := range lines { - lines[y] = strings.Repeat("~", w) + displayCount := max(h-1, 0) + + if displayCount < len(events) { + events = events[len(events)-displayCount:] } + + lines := []string{ + fmt.Sprintf("EVENT LOG - %d EVENTS", len(events)), + } + + for _, event := range events { + line := fmt.Sprintf( + "%s %-15s %s", + event.Time.Format("15:04:05"), + event.Type, + event.Body, + ) + if event.PID > 0 { + line = fmt.Sprintf( + "%s %-15s %d %s", + event.Time.Format("15:04:05"), + event.Type, + event.PID, + event.Body, + ) + } + lines = append(lines, truncate(line, w)) + } + + // Cleanup + if len(lines) < h { + for i := len(lines); i < h; i++ { + lines = append(lines, "") + } + } + return lines } +// TODO: Should h be max or defined? func (m Model) renderStdPane(w, h int) []string { - if w < 0 { - w = 0 - } - if h < 0 { - h = 0 + // Only the stdout or stderr logs + var logs []model.Event + for _, ev := range m.events { + if ev.Type == model.EventTypeProcessStderr || + ev.Type == model.EventTypeProcessStdout { + logs = append(logs, ev) + } } - lines := make([]string, h) - for y := range lines { - lines[y] = strings.Repeat(" ", w) + displayCount := max(h-1, 0) + + if displayCount < len(logs) { + logs = logs[len(logs)-displayCount:] } + + lines := []string{ + fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs)), + } + + for _, log := range logs { + var t string + if log.Type == model.EventTypeProcessStderr { + t = "STDERR" + } + if log.Type == model.EventTypeProcessStdout { + t = "STDOUT" + } + line := fmt.Sprintf( + "%s %6s %s", + log.Time.Format("15:04:05"), + t, + log.Body, + ) + lines = append(lines, truncate(line, w)) + } + + // Cleanup + if len(lines) < h { + for i := len(lines); i < h; i++ { + lines = append(lines, "") + } + } + return lines } diff --git a/internal/tui/update.go b/internal/tui/update.go index f92b520..074f655 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "time" tea "github.com/charmbracelet/bubbletea" "termtap.dev/internal/model" @@ -14,6 +15,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil + case TickMsg: + m.now = msg.Now + if m.hasPendingRequests() { + return m, tickCmd() + } + return m, nil + // TODO: Abstract the keymaps case tea.KeyMsg: switch msg.String() { @@ -32,6 +40,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ErrMsg: m.events = append(m.events, model.Event{ + Time: time.Now().Local(), Type: model.EventTypeWarn, Body: fmt.Sprintf("tui event stream closed: %v", msg.err), }) @@ -40,6 +49,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case EventMsg: m.pushEvent(msg.value) m.applyMessage(msg.value) + if m.hasPendingRequests() { + return m, tea.Batch(waitForEvent(m.channel), tickCmd()) + } return m, waitForEvent(m.channel) } @@ -82,3 +94,14 @@ func (m *Model) updateRequest(req model.Request) { } } } + +func (m Model) hasPendingRequests() bool { + // Traverse backward to be a bit more efficient, the most recent requests are more + // like to be pending. + for i := len(m.requests) - 1; i >= 0; i-- { + if m.requests[i].Pending { + return true + } + } + return false +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 55c4d6c..eb84e0f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,95 +2,28 @@ package tui import ( "fmt" - "strings" - - "termtap.dev/internal/model" + "time" ) // TODO: This is all temporary func (m Model) View() string { return m.renderAppPane() - - // eventLines := m.renderEvents(8) - // requestLines := m.renderRequests(12) - // - // return strings.Join([]string{ - // "termtap - live session", - // fmt.Sprintf("events=%d requests=%d", len(m.events), len(m.requests)), - // fmt.Sprintf("%dx%d", m.height, m.width), - // "keys: q/esc/ctrl+c quit", - // "", - // "Recent events:", - // eventLines, - // "", - // "Recent requests:", - // requestLines, - // }, "\n") } -func (m Model) renderEvents(limit int) string { - if len(m.events) == 0 { - return " (none yet)" +func formatDuration(d time.Duration) string { + if d == 0 { + return "PENDING" } - start := max(len(m.events)-limit, 0) - - rows := make([]string, 0, len(m.events)-start) - for i := start; i < len(m.events); i++ { - e := m.events[i] - rows = append(rows, fmt.Sprintf(" [%s] %s", e.Type, truncate(e.Body, 100))) + if d >= 10*time.Second { + return fmt.Sprintf("%.2fs", d.Seconds()) } - return strings.Join(rows, "\n") -} - -func (m Model) renderRequests(limit int) string { - if len(m.requests) == 0 { - return " (none yet)" + if d >= time.Millisecond { + return fmt.Sprintf("%dms", d.Milliseconds()) } - start := max(0, len(m.requests)-limit) - - // Traverse backwards since we don't have a stack - rows := make([]string, 0, len(m.requests)-start) - for i := len(m.requests) - 1; i >= start; i-- { - req := m.requests[i] - - state := "done" - if req.Pending { - state = "pending" - } else if req.Failed { - state = "failed" - } - - rows = append(rows, fmt.Sprintf( - " %s %s status=%d duration=%s state=%s", - req.Method, - requestPath(req), - req.Status, - req.Duration, - state, - )) - } - - if len(rows) == 0 { - return " (none yet)" - } - - return strings.Join(rows, "\n") -} - -func requestPath(req model.Request) string { - if req.URL != "" { - return truncate(req.URL, 80) - } - if req.RawURL != "" { - return truncate(req.RawURL, 80) - } - if req.Host != "" { - return truncate(req.Host, 80) - } - return "" + return fmt.Sprintf("%dus", d.Microseconds()) } func truncate(s string, max int) string {