mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user