Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
package intercept
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
)
func formatRawRequest(req *intercept.PendingRequest) string {
r := req.Flow.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func formatRawResponse(resp *intercept.PendingResponse) string {
r := resp.Flow.Response
if r == nil {
return "(no response)"
}
var sb strings.Builder
proto := resp.Flow.Request.Proto
if proto == "" {
proto = "HTTP/1.1"
}
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func parseRawRequest(content string, req *intercept.PendingRequest) {
r := req.Flow.Request
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
r.URL.Path = u.Path
r.URL.RawQuery = u.RawQuery
}
}
if len(parts) >= 3 {
r.Proto = strings.TrimSpace(parts[2])
}
r.Header = make(http.Header)
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
}
i++
}
if i < len(lines) {
body := strings.Join(lines[i:], "\n")
body = strings.TrimRight(body, "\n")
if body != "" {
r.Body = []byte(body)
} else {
r.Body = nil
}
}
}
func parseRawResponse(content string, resp *intercept.PendingResponse) {
r := resp.Flow.Response
if r == nil {
return
}
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 2 {
if code, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
r.StatusCode = code
}
}
r.Header = make(http.Header)
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
}
i++
}
if i < len(lines) {
body := strings.Join(lines[i:], "\n")
body = strings.TrimRight(body, "\n")
if body != "" {
r.Body = []byte(body)
} else {
r.Body = nil
}
}
r.Header.Set("Content-Length", strconv.Itoa(len(r.Body)))
}
func (m *Model) currentLabel() string {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return ""
}
resp := m.responseQueue[m.responseCursor]
code := 0
if resp.Flow.Response != nil {
code = resp.Flow.Response.StatusCode
}
return fmt.Sprintf("%d %s %s", code, http.StatusText(code), resp.Flow.Request.URL.RequestURI())
}
if len(m.queue) == 0 {
return ""
}
req := m.queue[m.cursor]
return req.Flow.Request.Method + " " + req.Flow.Request.URL.RequestURI()
}
func (m *Model) removeFromQueue(index int) {
m.queue = append(m.queue[:index], m.queue[index+1:]...)
if m.cursor >= len(m.queue) && m.cursor > 0 {
m.cursor--
}
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) removeFromResponseQueue(index int) {
m.responseQueue = append(m.responseQueue[:index], m.responseQueue[index+1:]...)
if m.responseCursor >= len(m.responseQueue) && m.responseCursor > 0 {
m.responseCursor--
}
m.refreshResponseListViewport()
m.refreshBody()
}
func (m *Model) applyAndDecide(d intercept.Decision) {
if len(m.queue) == 0 {
return
}
req := m.queue[m.cursor]
if d == intercept.Forward {
if edited, ok := m.pendingEdits[req]; ok {
parseRawRequest(edited, req)
}
}
delete(m.pendingEdits, req)
m.broker.Decide(req, d)
m.removeFromQueue(m.cursor)
}
func (m *Model) applyAndDecideResponse(d intercept.Decision) {
if len(m.responseQueue) == 0 {
return
}
resp := m.responseQueue[m.responseCursor]
if d == intercept.Forward {
if edited, ok := m.pendingResponseEdits[resp]; ok {
parseRawResponse(edited, resp)
}
}
delete(m.pendingResponseEdits, resp)
m.broker.DecideResponse(resp, d)
m.removeFromResponseQueue(m.responseCursor)
}
func (m *Model) listHalfWidths() (leftW, rightW int) {
leftW = m.width / 2
rightW = m.width - leftW
return
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
bodyInner := m.width - 2
if bodyInner < 0 {
bodyInner = 0
}
bodyVH := style.PanelContentH(bodyH)
m.textarea.SetWidth(bodyInner)
m.textarea.SetHeight(bodyVH)
m.bodyViewport.SetWidth(bodyInner)
m.bodyViewport.SetHeight(bodyVH)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
if m.captureResponse {
leftW, rightW := m.listHalfWidths()
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
m.listViewport.SetWidth(leftInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
m.responseViewport.SetWidth(rightInner)
m.responseViewport.SetHeight(listVH)
m.responsePager.PerPage = listVH
if m.responsePager.PerPage < 1 {
m.responsePager.PerPage = 1
}
} else {
listInner := m.width - 2
if listInner < 0 {
listInner = 0
}
m.listViewport.SetWidth(listInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.queue))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshResponseListViewport() {
if m.responsePager.PerPage > 0 {
m.responsePager.Page = m.responseCursor / m.responsePager.PerPage
m.responsePager.SetTotalPages(len(m.responseQueue))
}
m.responseViewport.SetContent(m.renderResponseList())
}
// saveCurrentEdit must only be called when exiting edit mode.
func (m *Model) saveCurrentEdit() {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) > 0 {
m.pendingResponseEdits[m.responseQueue[m.responseCursor]] = m.textarea.Value()
}
} else {
if len(m.queue) > 0 {
m.pendingEdits[m.queue[m.cursor]] = m.textarea.Value()
}
}
}
const maxInlineEditBytes = 32 * 1024
func (m *Model) loadIntoTextarea() {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
m.textarea.SetValue(edited)
} else {
m.textarea.SetValue(formatRawResponse(resp))
}
} else {
if len(m.queue) == 0 {
return
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
m.textarea.SetValue(edited)
} else {
m.textarea.SetValue(formatRawRequest(req))
}
}
}
// refreshBody does not touch the textarea - it is only loaded when entering edit mode.
func (m *Model) refreshBody() {
var raw string
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
m.bodyViewport.SetContent("")
return
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
raw = edited
} else {
raw = formatRawResponse(resp)
}
} else {
if len(m.queue) == 0 {
m.bodyViewport.SetContent("")
return
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
raw = edited
} else {
raw = formatRawRequest(req)
}
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
func (m *Model) refreshBodyViewport() {
m.bodyViewport.SetContent(style.HighlightHTTP(m.textarea.Value()))
}
+34
View File
@@ -0,0 +1,34 @@
package intercept
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func newHelp() help.Model { return style.NewHelp() }
type interceptKeyMap struct{ width int }
func iconBinding(b key.Binding, icon string) key.Binding {
h := b.Help()
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
}
func (interceptKeyMap) ShortHelp() []key.Binding {
ic := keys.Keys.Intercept
i := icons.I
return []key.Binding{
iconBinding(ic.Forward, i.Forward),
iconBinding(ic.Drop, i.Drop),
iconBinding(ic.Edit, i.Edit),
keys.Keys.Global.Help,
}
}
func (m interceptKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Intercept.Bindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+118
View File
@@ -0,0 +1,118 @@
package intercept
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
)
type panel int
const (
panelRequests panel = iota
panelResponses
)
type Model struct {
broker *intercept.Broker
queue []*intercept.PendingRequest
cursor int
captureResponse bool
focusedPanel panel
responseQueue []*intercept.PendingResponse
responseCursor int
editing bool
autoForward bool
pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string
listViewport viewport.Model
responseViewport viewport.Model
bodyViewport viewport.Model
textarea textarea.Model
pager paginator.Model
responsePager paginator.Model
help help.Model
width int
height int
}
func New(broker *intercept.Broker) Model {
cfg := config.Global
ta := style.NewTextarea(false)
ta.Blur()
lv := style.NewViewport()
rv := style.NewViewport()
bv := style.NewViewport()
p := style.NewPaginator()
rp := style.NewPaginator()
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
return Model{
broker: broker,
autoForward: cfg.Intercept.DefaultAutoForward,
captureResponse: cfg.Intercept.DefaultCaptureResponse,
listViewport: lv,
responseViewport: rv,
bodyViewport: bv,
textarea: ta,
pager: p,
responsePager: rp,
help: newHelp(),
pendingEdits: make(map[*intercept.PendingRequest]string),
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) CurrentScheme() string {
if len(m.queue) == 0 {
return "https"
}
scheme := m.queue[m.cursor].Flow.Request.URL.Scheme
if scheme == "" {
return "https"
}
return scheme
}
func (m Model) CurrentRaw() string {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return ""
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
return edited
}
return formatRawResponse(resp)
}
if len(m.queue) == 0 {
return ""
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
return edited
}
return formatRawRequest(req)
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
+296
View File
@@ -0,0 +1,296 @@
package intercept
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case intercept.RequestArrivedMsg:
if m.autoForward {
m.broker.Decide(msg.Req, intercept.Forward)
break
}
wasEmpty := len(m.queue) == 0
m.queue = append(m.queue, msg.Req)
m.refreshListViewport()
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
m.refreshBody()
}
case intercept.ResponseArrivedMsg:
wasEmpty := len(m.responseQueue) == 0
m.responseQueue = append(m.responseQueue, msg.Resp)
m.refreshResponseListViewport()
if wasEmpty && m.captureResponse && m.focusedPanel == panelResponses {
m.refreshBody()
}
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" {
m.textarea.SetValue(msg.Content)
m.refreshBodyViewport()
}
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
}
case tea.KeyPressMsg:
if m.editing {
return m.updateEditMode(msg, &cmds)
}
return m.updateNormalMode(msg, &cmds)
}
return m, tea.Batch(cmds...)
}
func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Up):
if onResponses {
if m.responseCursor > 0 {
m.responseCursor--
m.refreshResponseListViewport()
m.refreshBody()
}
} else {
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Global.Down):
if onResponses {
if m.responseCursor < len(m.responseQueue)-1 {
m.responseCursor++
m.refreshResponseListViewport()
m.refreshBody()
}
} else {
if m.cursor < len(m.queue)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Global.CycleFocus):
if m.captureResponse {
if m.focusedPanel == panelRequests {
m.focusedPanel = panelResponses
} else {
m.focusedPanel = panelRequests
}
m.refreshBody()
}
case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, keys.Keys.Global.Left):
m.bodyViewport.ScrollLeft(6)
case key.Matches(msg, keys.Keys.Global.Right):
m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.refreshBody()
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Intercept.AutoForward):
m.autoForward = !m.autoForward
if m.autoForward {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward)
}
}
case key.Matches(msg, keys.Keys.Intercept.CaptureResponse):
m.captureResponse = !m.captureResponse
m.broker.SetCaptureResponse(m.captureResponse)
if !m.captureResponse {
for len(m.responseQueue) > 0 {
m.broker.DecideResponse(m.responseQueue[0], intercept.Forward)
m.responseQueue = m.responseQueue[1:]
}
m.responseCursor = 0
m.focusedPanel = panelRequests
}
m.recalcSizes()
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
case key.Matches(msg, keys.Keys.Intercept.Forward):
if onResponses {
m.applyAndDecideResponse(intercept.Forward)
} else {
m.applyAndDecide(intercept.Forward)
}
case key.Matches(msg, keys.Keys.Intercept.ForwardAll):
if onResponses {
for len(m.responseQueue) > 0 {
m.applyAndDecideResponse(intercept.Forward)
}
} else {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward)
}
}
case key.Matches(msg, keys.Keys.Intercept.Drop):
if onResponses {
m.applyAndDecideResponse(intercept.Drop)
} else {
m.applyAndDecide(intercept.Drop)
}
case key.Matches(msg, keys.Keys.Intercept.DropAll):
if onResponses {
for len(m.responseQueue) > 0 {
m.applyAndDecideResponse(intercept.Drop)
}
} else {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Drop)
}
}
case key.Matches(msg, keys.Keys.Intercept.Edit):
hasItem := (!onResponses && len(m.queue) > 0) || (onResponses && len(m.responseQueue) > 0)
if hasItem {
raw := m.CurrentRaw()
if len(raw) > maxInlineEditBytes {
return m, util.OpenExternalEditor(raw)
}
m.loadIntoTextarea()
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
if !onResponses && len(m.queue) > 0 {
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor]))
}
if onResponses && len(m.responseQueue) > 0 {
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor]))
}
case key.Matches(msg, keys.Keys.Global.SendToReplay):
if !onResponses && len(m.queue) > 0 {
req := m.queue[m.cursor]
raw := m.CurrentRaw()
scheme := req.Flow.Request.URL.Scheme
if scheme == "" {
scheme = "https"
}
return m, func() tea.Msg {
return replayUI.SendToReplayMsg{
Scheme: scheme,
Host: req.Flow.Request.URL.Host,
RequestRaw: raw,
}
}
}
case key.Matches(msg, keys.Keys.Global.SendToDiff):
raw := m.CurrentRaw()
if raw != "" {
label := m.currentLabel()
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
}
return m, tea.Batch(*cmds...)
}
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit()
m.editing = false
m.textarea.Blur()
m.refreshBodyViewport()
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor]))
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor]))
}
}
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
*cmds = append(*cmds, cmd)
}
return m, tea.Batch(*cmds...)
}
+220
View File
@@ -0,0 +1,220 @@
package intercept
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
var listRow string
if m.captureResponse {
leftW, rightW := m.listHalfWidths()
listRow = lipgloss.JoinHorizontal(lipgloss.Top,
m.renderListPanel(leftW, listH),
m.renderResponseListPanel(rightW, listH),
)
} else {
listRow = m.renderListPanel(m.width, listH)
}
content := lipgloss.JoinVertical(lipgloss.Left,
listRow,
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
border := s.Panel
if focused {
border = s.PanelFocused
}
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
title := icons.I.Request + "Requests"
if m.autoForward {
title += " [auto forward]"
}
return style.RenderWithTitle(border, title, inner, w, h)
}
func (m *Model) renderResponseListPanel(w, h int) string {
s := style.S
focused := !m.editing && m.focusedPanel == panelResponses
border := s.Panel
if focused {
border = s.PanelFocused
}
dots := s.Faint.Render(m.responsePager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.responseViewport.View(),
lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
var body string
if m.editing {
body = m.textarea.View()
} else {
body = m.bodyViewport.View()
}
border := s.Panel
if m.editing {
border = s.PanelFocused
}
title := icons.I.Detail + "Details"
return style.RenderWithTitle(border, title, body, m.width, h)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(interceptKeyMap{width: m.width}))
}
func (m *Model) renderList() string {
if len(m.queue) == 0 {
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request"))
}
s := style.S
start, end := m.pager.GetSliceBounds(len(m.queue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, req := range m.queue[start:end] {
globalIdx := start + i
r := req.Flow.Request
path := r.URL.Path
if path == "" {
path = "/"
}
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 2
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(selBg)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
s.Method(r.Method).Background(selBg).Render(r.Method),
bg.Width(2).Render(""),
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(r.Method).Render(r.Method),
s.Faint.Render(" "),
s.Bold.Render(r.URL.Host),
s.Faint.Render(path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
func (m *Model) renderResponseList() string {
if len(m.responseQueue) == 0 {
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet"))
}
s := style.S
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, resp := range m.responseQueue[start:end] {
globalIdx := start + i
f := resp.Flow
path := f.Request.URL.Path
if path == "" {
path = "/"
}
code := 0
if f.Response != nil {
code = f.Response.StatusCode
}
statusStr := fmt.Sprintf("%d", code)
selected := globalIdx == m.responseCursor
selBg := s.Selection
statusSt := style.StatusStyle(code, 7)
w := m.responseViewport.Width()
const fixedW = 2 + 7 + 2
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(selBg)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
statusSt.Background(selBg).Render(statusStr),
bg.Width(2).Render(""),
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
statusSt.Render(statusStr),
s.Faint.Render(" "),
s.Bold.Render(f.Request.URL.Host),
s.Faint.Render(path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}