feat: implemented the panels

This commit is contained in:
Hayden Hargreaves 2026-04-15 12:33:08 -07:00
parent ea201b4c91
commit 958fc7308a
11 changed files with 212 additions and 110 deletions

View File

@ -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,

View File

@ -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),
}

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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",
}

View File

@ -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}
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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 "<unknown>"
return fmt.Sprintf("%dus", d.Microseconds())
}
func truncate(s string, max int) string {