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
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"log"
"os"
"path/filepath"
"strconv"
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
"github.com/sirupsen/logrus"
)
const tickInterval = 2 * time.Second
type tickMsg struct{}
func tickCmd() tea.Cmd {
return func() tea.Msg {
time.Sleep(tickInterval)
return tickMsg{}
}
}
var sidebarEntries = pageRegistry
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(sidebarEntries))
for i, e := range sidebarEntries {
m[strconv.Itoa(i+1)] = e.id
}
return m
}()
type Model struct {
broker *intercept.Broker
page page
projectName string
projectPath string
database *db.DB
logFile *os.File
pluginManager *plugins.Manager
width int
height int
sidebarState sidebarState
intercept interceptUI.Model
history historyUI.Model
replay replayUI.Model
diff diffUI.Model
docs docsUI.Model
scope scopeUI.Model
pluginsPage pluginsUI.Model
findingsPage findingsUI.Model
copyAs copyasUI.Model
notifications notificationsUI.Model
}
func New(broker *intercept.Broker, name, path string) Model {
cfg := config.Global
mgr := plugins.NewManager(broker)
m := Model{
broker: broker,
page: pageIntercept,
projectName: name,
projectPath: path,
pluginManager: mgr,
intercept: interceptUI.New(broker),
history: historyUI.New(),
replay: replayUI.New(),
diff: diffUI.New(),
docs: docsUI.New(),
scope: scopeUI.New(name, path),
pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(),
copyAs: copyasUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
}
if d, err := db.Open(path); err == nil {
m.database = d
broker.SetDB(d)
m.history.SetDB(d)
m.replay.SetDB(d)
m.findingsPage.SetDB(d)
mgr.SetDB(d)
if wl, bl, err := d.LoadScope(); err == nil {
broker.SetScope(wl, bl)
m.scope.SetScope(wl, bl)
}
}
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err)
}
m.pluginsPage.Refresh()
if lf, err := os.OpenFile(filepath.Join(filepath.Dir(path), "logs.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
m.logFile = lf
log.SetOutput(lf)
logrus.SetOutput(lf)
}
return m
}
func (m Model) Init() tea.Cmd {
mgr := m.pluginManager
return tea.Batch(
intercept.WaitForRequest(m.broker),
intercept.WaitForResponse(m.broker),
tickCmd(),
proxyPkg.StartCmd(m.broker, mgr),
plugins.WaitForNotif(mgr),
plugins.WaitForQuit(mgr),
findingsUI.RefreshCmd(m.database),
func() tea.Msg { mgr.RunOnStart(); return nil },
)
}
+146
View File
@@ -0,0 +1,146 @@
package app
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/icons"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
type page string
const (
pageIntercept page = "Intercept"
pageHistory page = "History"
pageReplay page = "Replay"
pageDiff page = "Diff"
pageScopes page = "Scopes"
pagePlugins page = "Plugins"
pageFindings page = "Findings"
pageDocs page = "Docs"
)
// pageEntry describes a page and all its integration hooks.
type pageEntry struct {
id page
icon func() string
// render returns the page's view content. nil = show "empty".
render func(m *Model) string
// update is called when this page is active. nil = no-op.
update func(m *Model, msg tea.Msg) tea.Cmd
// isEditing reports whether the page is in text-editing mode.
isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int)
}
var pageRegistry = []pageEntry{
{
id: pageIntercept,
icon: func() string { return icons.I.Intercept },
render: func(m *Model) string { return m.intercept.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
},
{
id: pageHistory,
icon: func() string { return icons.I.History },
render: func(m *Model) string { return m.history.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.history.IsEditing() },
resize: func(m *Model, w, h int) { m.history.SetSize(w, h) },
},
{
id: pageReplay,
icon: func() string { return icons.I.Replay },
render: func(m *Model) string { return m.replay.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.replay.IsEditing() },
resize: func(m *Model, w, h int) { m.replay.SetSize(w, h) },
},
{
id: pageDiff,
icon: func() string { return icons.I.Diff },
render: func(m *Model) string { return m.diff.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) },
},
{
id: pageScopes,
icon: func() string { return icons.I.Scope },
render: func(m *Model) string { return m.scope.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.scope.Update(msg)
m.scope = updated.(scopeUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.scope.IsEditing() },
resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) },
},
{
id: pagePlugins,
icon: func() string { return icons.I.Plugin },
render: func(m *Model) string { return m.pluginsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.pluginsPage.Update(msg)
m.pluginsPage = updated.(pluginsUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.pluginsPage.IsEditing() },
resize: func(m *Model, w, h int) { m.pluginsPage.SetSize(w, h) },
},
{
id: pageFindings,
icon: func() string { return icons.I.Findings },
render: func(m *Model) string { return m.findingsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
},
{
id: pageDocs,
icon: func() string { return icons.I.Docs },
render: func(m *Model) string { return m.docs.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.docs.Update(msg)
m.docs = updated.(docsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
},
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
type sidebarState string
const (
sidebarHidden sidebarState = "hidden"
sidebarCollapsed sidebarState = "collapsed"
sidebarExpanded sidebarState = "expanded"
)
func (m *Model) cycleSidebarState() {
switch m.sidebarState {
case sidebarHidden:
m.sidebarState = sidebarCollapsed
case sidebarCollapsed:
m.sidebarState = sidebarExpanded
default:
m.sidebarState = sidebarHidden
}
}
func (m Model) getSidebarWidth() int {
switch m.sidebarState {
case sidebarHidden:
return 0
case sidebarCollapsed:
return 8
default:
return 18
}
}
func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarHidden {
return ""
}
s := style.S
// content width inside bordered panel
inner := m.getSidebarWidth() - 2
titleText := "SPILLTEA"
if m.sidebarState == sidebarCollapsed {
titleText = "SPLT"
}
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
divider := strings.Repeat("─", inner)
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
textNormal := lipgloss.NewStyle().Foreground(s.Text)
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
for i, entry := range sidebarEntries {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
badgeStyle, textStyle = badgeSelected, textSelected
}
icon := ""
if entry.icon != nil {
icon = entry.icon()
}
label := " " + icon
if m.sidebarState != sidebarCollapsed {
label += string(entry.id)
}
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
items.WriteString(line + "\n")
}
body := lipgloss.JoinVertical(lipgloss.Left,
title,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
items.String(),
)
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
}
+238
View File
@@ -0,0 +1,238 @@
package app
import (
"log"
"os"
"os/exec"
"path/filepath"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Broker messages must always re-register their watchers
switch msg := msg.(type) {
case notificationsUI.NotificationMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case notificationsUI.DismissMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case intercept.RequestArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
case intercept.ResponseArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForResponse(m.broker))
case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager)
notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: msg.Title,
Body: msg.Body,
Kind: notificationsUI.KindInfo,
}
}
return m, tea.Batch(cmd, notifCmd)
case plugins.PluginQuitMsg:
log.Printf("plugin quit: %s", msg.Reason)
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if m.copyAs.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copyAs.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copyAs, cmd = m.copyAs.Update(msg)
return m, cmd
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeChildren()
case scopeUI.ScopeChangedMsg:
m.broker.SetScope(msg.Whitelist, msg.Blacklist)
if m.database != nil {
if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil {
log.Printf("failed to persist scope: %v", err)
}
}
return m, nil
case proxyPkg.ErrMsg:
if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err)
}
return m, nil
case tickMsg:
var cmds []tea.Cmd
cmds = append(cmds, tickCmd())
if m.page == pageHistory {
cmds = append(cmds, m.history.RefreshCmd())
}
cmds = append(cmds, findingsUI.RefreshCmd(m.database))
return m, tea.Batch(cmds...)
case findingsUI.FindingsLoadedMsg:
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return m, cmd
case replayUI.SendToReplayMsg:
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
if config.Global.Replay.SwitchToPageOnSend {
m.page = pageReplay
m.resizeChildren()
} else {
return m, tea.Batch(cmd, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Replay",
Body: "Request queued in replay",
Kind: notificationsUI.KindInfo,
}
})
}
return m, cmd
case diffUI.SendToDiffMsg:
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return m, cmd
case diffUI.DiffReadyMsg:
m.page = pageDiff
m.resizeChildren()
return m, nil
case historyUI.EntriesLoadedMsg:
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return m, cmd
case tea.KeyPressMsg:
// ctrl+c always quits, even when a textarea is focused.
if msg.String() == "ctrl+c" {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
}
if !m.activeIsEditing() {
switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest):
if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: "https",
})
}
} else if m.page == pageIntercept {
if raw := m.intercept.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: m.intercept.CurrentScheme(),
})
}
}
return m, nil
case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState()
m.resizeChildren()
default:
if p, ok := pageShortcuts[msg.String()]; ok {
prev := m.page
m.page = p
if p == pageHistory && prev != pageHistory {
return m, m.history.RefreshCmd()
}
if p == pageFindings {
return m, findingsUI.RefreshCmd(m.database)
}
}
}
}
}
var cmd tea.Cmd
m, cmd = m.updateActivePage(msg)
return m, cmd
}
func (m Model) activeIsEditing() bool {
for _, e := range pageRegistry {
if e.id == m.page && e.isEditing != nil {
return e.isEditing(&m)
}
}
return false
}
func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) {
for _, e := range pageRegistry {
if e.id == m.page && e.update != nil {
cmd := e.update(&m, msg)
return m, cmd
}
}
return m, nil
}
func (m *Model) resizeChildren() {
sidebarW := m.getSidebarWidth()
h := m.height
for _, e := range pageRegistry {
if e.resize == nil {
continue
}
e.resize(m, m.width-sidebarW, h)
}
m.notifications.SetSize(m.width, m.height)
}
+49
View File
@@ -0,0 +1,49 @@
package app
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
v := tea.NewView("")
v.AltScreen = true
return v
}
normal := m.renderNormal()
if m.copyAs.IsOpen() {
v := tea.NewView(m.copyAs.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal
if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal)
}
v := tea.NewView(rendered)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNormal() string {
sidebar := m.renderSidebar()
content := m.renderActivePage()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
}
func (m *Model) renderActivePage() string {
for _, e := range pageRegistry {
if e.id == m.page && e.render != nil {
return e.render(m)
}
}
return style.S.Faint.Render("Work in progress")
}
+200
View File
@@ -0,0 +1,200 @@
package copyas
import (
"fmt"
"strings"
)
type header struct{ key, value string }
type parsedRequest struct {
method string
path string
host string
scheme string
headers []header
body string
}
func parseRaw(raw, scheme string) parsedRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
pr := parsedRequest{scheme: scheme}
if len(lines) == 0 {
return pr
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
pr.method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
pr.path = strings.TrimSpace(parts[1])
}
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 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
pr.headers = append(pr.headers, header{k, v})
if strings.EqualFold(k, "host") {
pr.host = v
}
}
i++
}
if i < len(lines) {
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
}
return pr
}
func (pr parsedRequest) fullURL() string {
scheme := pr.scheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + pr.host + pr.path
}
func formatAs(id, raw, scheme string) string {
pr := parseRaw(raw, scheme)
switch id {
case "curl":
return toCurl(pr)
case "python":
return toPython(pr)
case "go":
return toGo(pr)
case "ffuf":
return toFFUF(pr)
case "markdown":
return toMarkdown(pr)
}
return raw
}
func toMarkdown(pr parsedRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
sb.WriteString("```\n")
sb.WriteString(pr.method + " " + pr.path + " HTTP/1.1\n")
for _, h := range pr.headers {
sb.WriteString(fmt.Sprintf("%s: %s\n", h.key, h.value))
}
if pr.body != "" {
sb.WriteString("\n" + pr.body)
}
sb.WriteString("\n```")
return sb.String()
}
func toCurl(pr parsedRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "curl -X %s '%s'", pr.method, pr.fullURL())
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value)
}
if pr.body != "" {
body := strings.ReplaceAll(pr.body, "'", "'\\''")
fmt.Fprintf(&sb, " \\\n --data '%s'", body)
}
return sb.String()
}
func toPython(pr parsedRequest) string {
var sb strings.Builder
sb.WriteString("import requests\n\n")
fmt.Fprintf(&sb, "url = %q\n", pr.fullURL())
sb.WriteString("headers = {\n")
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " %q: %q,\n", h.key, h.value)
}
sb.WriteString("}\n")
method := strings.ToLower(pr.method)
if pr.body != "" {
fmt.Fprintf(&sb, "data = %q\n\n", pr.body)
fmt.Fprintf(&sb, "response = requests.%s(url, headers=headers, data=data)\n", method)
} else {
fmt.Fprintf(&sb, "\nresponse = requests.%s(url, headers=headers)\n", method)
}
sb.WriteString("print(response.status_code)\n")
sb.WriteString("print(response.text)\n")
return sb.String()
}
func toGo(pr parsedRequest) string {
var sb strings.Builder
sb.WriteString("package main\n\nimport (\n")
if pr.body != "" {
sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n")
} else {
sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n")
}
sb.WriteString("func main() {\n")
if pr.body != "" {
fmt.Fprintf(&sb, "\tbody := strings.NewReader(%q)\n", pr.body)
fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, body)\n", pr.method, pr.fullURL())
} else {
fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, nil)\n", pr.method, pr.fullURL())
}
sb.WriteString("\tif err != nil { panic(err) }\n")
for _, h := range pr.headers {
if strings.EqualFold(h.key, "host") || strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, "\treq.Header.Set(%q, %q)\n", h.key, h.value)
}
sb.WriteString("\n\tclient := &http.Client{}\n")
sb.WriteString("\tresp, err := client.Do(req)\n")
sb.WriteString("\tif err != nil { panic(err) }\n")
sb.WriteString("\tdefer resp.Body.Close()\n")
sb.WriteString("\tbody2, _ := io.ReadAll(resp.Body)\n")
sb.WriteString("\tfmt.Printf(\"Status: %d\\n\", resp.StatusCode)\n")
sb.WriteString("\tfmt.Println(string(body2))\n")
sb.WriteString("}\n")
return sb.String()
}
func toFFUF(pr parsedRequest) string {
// Place FUZZ in the path: replace query string or append ?FUZZ
fuzzURL := pr.scheme + "://" + pr.host
if idx := strings.Index(pr.path, "?"); idx != -1 {
fuzzURL += pr.path[:idx] + "?FUZZ"
} else {
fuzzURL += pr.path + "?FUZZ"
}
var sb strings.Builder
fmt.Fprintf(&sb, "ffuf -u '%s'", fuzzURL)
sb.WriteString(" \\\n -w wordlist.txt")
fmt.Fprintf(&sb, " \\\n -X %s", pr.method)
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value)
}
if pr.body != "" {
body := strings.ReplaceAll(pr.body, "'", "'\\''")
fmt.Fprintf(&sb, " \\\n -d '%s'", body)
}
return sb.String()
}
+117
View File
@@ -0,0 +1,117 @@
package copyas
import (
"encoding/base64"
"fmt"
"os"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
const popupInnerW = 46
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct {
RawRequest string
Scheme string
}
type formatItem struct {
id string
title string
desc string
}
func (f formatItem) Title() string { return f.title }
func (f formatItem) Description() string { return f.desc }
func (f formatItem) FilterValue() string { return f.title }
var allFormats = []list.Item{
formatItem{"curl", "cURL", "command line HTTP request"},
formatItem{"python", "Python", "requests library"},
formatItem{"go", "Go", "net/http package"},
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
formatItem{"markdown", "Markdown", "formatted for documentation"},
}
type Model struct {
open bool
list list.Model
rawRequest string
scheme string
width int
height int
}
func New() Model {
s := style.S
delegate := list.NewDefaultDelegate()
delegate.SetSpacing(0)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1)
l := list.New(allFormats, delegate, popupInnerW, 8)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
return Model{list: l}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsOpen() bool { return m.open }
func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme
m.open = true
m.list.ResetFilter()
m.list.Select(0)
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m Model) popupHeight() int {
h := 14
if m.height > 0 && m.height-4 < h {
h = m.height - 4
}
if h < 6 {
h = 6
}
return h
}
// listHeight = panel content area - hint line (1)
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
}
+30
View File
@@ -0,0 +1,30 @@
package copyas
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch {
case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(formatItem); ok {
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
}
m.open = false
return m, nil
case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() {
break
}
m.open = false
return m, nil
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
+93
View File
@@ -0,0 +1,93 @@
package copyas
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
func (m *Model) View(background string) string {
s := style.S
hint := lipgloss.NewStyle().Foreground(s.Subtle).
Render(" enter: copy • /: filter • esc: cancel")
inner := lipgloss.JoinVertical(lipgloss.Left,
m.list.View(),
hint,
)
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary)
popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH)
return overlayCenter(background, popup, m.width, m.height)
}
func overlayCenter(bg, popup string, w, h int) string {
s := style.S
stripped := ansi.Strip(bg)
rawLines := strings.Split(stripped, "\n")
bgRunes := make([][]rune, h)
for y := 0; y < h; y++ {
var line []rune
if y < len(rawLines) {
line = []rune(rawLines[y])
}
if len(line) > w {
line = line[:w]
}
for len(line) < w {
line = append(line, ' ')
}
bgRunes[y] = line
}
popupLines := strings.Split(popup, "\n")
popupH := len(popupLines)
popupW := 0
for _, l := range popupLines {
if vw := lipgloss.Width(l); vw > popupW {
popupW = vw
}
}
startY := (h - popupH) / 2
startX := (w - popupW) / 2
if startY < 0 {
startY = 0
}
if startX < 0 {
startX = 0
}
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
result := make([]string, h)
for y := 0; y < h; y++ {
popupY := y - startY
if popupY >= 0 && popupY < popupH {
leftEnd := startX
if leftEnd > len(bgRunes[y]) {
leftEnd = len(bgRunes[y])
}
prefix := dim.Render(string(bgRunes[y][:leftEnd]))
rightStart := startX + popupW
suffix := ""
if rightStart < len(bgRunes[y]) {
suffix = dim.Render(string(bgRunes[y][rightStart:]))
}
result[y] = prefix + popupLines[popupY] + suffix
} else {
result[y] = dim.Render(string(bgRunes[y]))
}
}
return strings.Join(result, "\n")
}
@@ -0,0 +1,155 @@
package notifications
import (
"image/color"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
type Kind string
const (
KindInfo Kind = "info"
KindSuccess Kind = "success"
KindWarning Kind = "warning"
KindError Kind = "error"
)
type NotificationMsg struct {
Title string
Body string
Kind Kind
}
type DismissMsg struct{ ID int }
type notification struct {
id int
title string
body string
kind Kind
}
type Model struct {
queue []notification
nextID int
width int
height int
}
func New() Model { return Model{} }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
}
func (m Model) HasNotifications() bool {
return len(m.queue) > 0
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case NotificationMsg:
n := notification{id: m.nextID, title: msg.Title, body: msg.Body, kind: msg.Kind}
m.nextID++
m.queue = append(m.queue, n)
return m, tea.Tick(4*time.Second, func(time.Time) tea.Msg { return DismissMsg{ID: n.id} })
case DismissMsg:
for i, n := range m.queue {
if n.id == msg.ID {
m.queue = append(m.queue[:i], m.queue[i+1:]...)
break
}
}
}
return m, nil
}
func (m Model) View(background string) string {
if len(m.queue) == 0 {
return background
}
s := style.S
const popupW = 34
var popups []string
start := len(m.queue) - 3
if start < 0 {
start = 0
}
for i := start; i < len(m.queue); i++ {
n := m.queue[i]
var accent color.Color
switch n.kind {
case KindSuccess:
accent = s.Success
case KindWarning:
accent = s.Warning
case KindError:
accent = s.Error
default:
accent = s.Primary
}
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
bodyStr := lipgloss.NewStyle().Foreground(s.Text).Width(popupW).Render(n.body)
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(0, 1).
Render(inner)
popups = append(popups, box)
}
popup := strings.Join(popups, "\n")
return overlayTopRight(background, popup, m.width, m.height)
}
func overlayTopRight(bg, popup string, w, h int) string {
bgLines := strings.Split(bg, "\n")
popupLines := strings.Split(popup, "\n")
popupH := len(popupLines)
popupW := 0
for _, l := range popupLines {
if vw := lipgloss.Width(l); vw > popupW {
popupW = vw
}
}
const marginTop = 1
const marginRight = 2
startY := marginTop
startX := w - popupW - marginRight
if startX < 0 {
startX = 0
}
result := make([]string, h)
for y := 0; y < h; y++ {
bgLine := ""
if y < len(bgLines) {
bgLine = bgLines[y]
}
popupY := y - startY
if popupY >= 0 && popupY < popupH {
prefix := ansi.Truncate(bgLine, startX, "")
suffix := ansi.TruncateLeft(bgLine, startX+popupW, "")
result[y] = prefix + popupLines[popupY] + suffix
} else {
result[y] = bgLine
}
}
return strings.Join(result, "\n")
}
+72
View File
@@ -0,0 +1,72 @@
package teapot
import "strings"
// FrameLines returns the number of visual lines in a teapot frame.
func FrameLines() int {
frames := TeapotFrames()
if len(frames) == 0 {
return 0
}
return strings.Count(frames[0], "\n") + 1
}
func Teapot() string {
return "" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ "
}
func TeapotFrames() []string {
return []string{
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" \n" +
" (( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
}
}
+264
View File
@@ -0,0 +1,264 @@
package diff
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type slot struct {
label string
raw string
}
type focusedSlot int
const (
bothSlots focusedSlot = iota
leftSlot
rightSlot
)
func (f focusedSlot) next() focusedSlot {
return (f + 1) % 3
}
type lineKind int
const (
lineUnchanged lineKind = iota
lineAdded
lineRemoved
)
type diffLine struct {
text string
kind lineKind
}
type Model struct {
left slot
right slot
focus focusedSlot
leftLines []diffLine
rightLines []diffLine
leftViewport viewport.Model
rightViewport viewport.Model
help help.Model
width int
height int
}
func New() Model {
return Model{
leftViewport: style.NewViewport(),
rightViewport: style.NewViewport(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
// CurrentRaw returns the raw content of the focused slot (left when both are focused).
func (m Model) CurrentRaw() string {
if m.focus == rightSlot {
return m.right.raw
}
return m.left.raw
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
if panelH < 0 {
panelH = 0
}
leftW := m.width / 2
rightW := m.width - leftW
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
viewportH := style.PanelContentH(panelH)
m.leftViewport.SetWidth(leftInner)
m.leftViewport.SetHeight(viewportH)
m.rightViewport.SetWidth(rightInner)
m.rightViewport.SetHeight(viewportH)
m.refreshViewports()
}
func (m *Model) computeDiff() {
if m.left.raw == "" || m.right.raw == "" {
m.leftLines = nil
m.rightLines = nil
return
}
leftNorm := normRaw(m.left.raw)
rightNorm := normRaw(m.right.raw)
leftPlain := strings.Split(leftNorm, "\n")
rightPlain := strings.Split(rightNorm, "\n")
leftHL := hlLines(leftNorm)
rightHL := hlLines(rightNorm)
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
}
func normRaw(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.TrimRight(s, "\n")
}
func hlLines(raw string) []string {
s := strings.TrimRight(style.HighlightHTTP(raw), "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func (m *Model) refreshViewports() {
s := style.S
if m.left.raw == "" {
placeholder := lipgloss.Place(
m.leftViewport.Width(), m.leftViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(" <(^_^)>\nsend two entries here to compare"),
)
m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("")
return
}
if m.right.raw == "" {
m.leftViewport.SetContent(style.HighlightHTTP(normRaw(m.left.raw)))
placeholder := lipgloss.Place(
m.rightViewport.Width(), m.rightViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (・3・)\nwaiting for second entry…"),
)
m.rightViewport.SetContent(placeholder)
return
}
m.leftViewport.SetContent(renderLeftLines(m.leftLines))
m.rightViewport.SetContent(renderRightLines(m.rightLines))
}
func (m *Model) scroll(delta int) {
offset := m.leftViewport.YOffset() + delta
m.leftViewport.SetYOffset(offset)
m.rightViewport.SetYOffset(offset)
}
func (m *Model) scrollH(delta int) {
offset := m.leftViewport.XOffset() + delta
m.leftViewport.SetXOffset(offset)
m.rightViewport.SetXOffset(offset)
}
func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
hlA := func(i int) string {
if i < len(aHL) {
return aHL[i]
}
return a[i]
}
hlB := func(j int) string {
if j < len(bHL) {
return bHL[j]
}
return b[j]
}
n, m := len(a), len(b)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
left = make([]diffLine, 0, n+m)
right = make([]diffLine, 0, n+m)
i, j := n, m
for i > 0 || j > 0 {
switch {
case i > 0 && j > 0 && a[i-1] == b[j-1]:
left = append(left, diffLine{text: hlA(i-1), kind: lineUnchanged})
right = append(right, diffLine{text: hlB(j-1), kind: lineUnchanged})
i--
j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
left = append(left, diffLine{kind: lineAdded})
right = append(right, diffLine{text: hlB(j-1), kind: lineAdded})
j--
default:
left = append(left, diffLine{text: hlA(i-1), kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved})
i--
}
}
for lo, hi := 0, len(left)-1; lo < hi; lo, hi = lo+1, hi-1 {
left[lo], left[hi] = left[hi], left[lo]
right[lo], right[hi] = right[hi], right[lo]
}
return left, right
}
func diffBindings() []key.Binding {
g := keys.Keys.Global
return []key.Binding{
g.Up, g.Down, g.ScrollUp, g.ScrollDown,
g.CycleFocus, keys.Keys.Diff.Clear,
}
}
type diffKeyMap struct{ width int }
func (diffKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
return []key.Binding{g.Up, g.Down, g.CycleFocus, keys.Keys.Diff.Clear, g.Help}
}
func (m diffKeyMap) FullHelp() [][]key.Binding {
all := append(diffBindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+143
View File
@@ -0,0 +1,143 @@
package diff
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
)
// SendToDiffMsg carries a raw HTTP request or response to the diff page.
type SendToDiffMsg struct {
Label string
Raw string
}
// DiffReadyMsg is emitted when both slots are filled and the diff is ready to view.
type DiffReadyMsg struct{}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case SendToDiffMsg:
if m.left.raw == "" {
m.left = slot{label: msg.Label, raw: msg.Raw}
m.refreshViewports()
return m, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Entry selected",
Body: "Select a second entry to compare",
Kind: notificationsUI.KindInfo,
}
}
} else if m.right.raw == "" {
m.right = slot{label: msg.Label, raw: msg.Raw}
m.computeDiff()
m.focus = bothSlots
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
return m, func() tea.Msg { return DiffReadyMsg{} }
} else {
// Both full: reset and start new comparison
m.left = slot{label: msg.Label, raw: msg.Raw}
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
return m, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Entry replaced",
Body: "Select a second entry to compare",
Kind: notificationsUI.KindInfo,
}
}
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.scrollH(-6)
} else {
m.scroll(-1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.scrollH(6)
} else {
m.scroll(1)
}
case tea.MouseWheelLeft:
m.scrollH(-6)
case tea.MouseWheelRight:
m.scrollH(6)
}
case tea.KeyPressMsg:
switch {
case key.Matches(msg, keys.Keys.Global.CycleFocus):
m.focus = m.focus.next()
case key.Matches(msg, keys.Keys.Global.Up):
m.scroll(-1)
case key.Matches(msg, keys.Keys.Global.Down):
m.scroll(1)
case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.leftViewport.Height() / 2
if step < 1 {
step = 1
}
m.scroll(-step)
case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.leftViewport.Height() / 2
if step < 1 {
step = 1
}
m.scroll(step)
case key.Matches(msg, keys.Keys.Global.Left):
m.scrollH(-6)
case key.Matches(msg, keys.Keys.Global.Right):
m.scrollH(6)
case key.Matches(msg, keys.Keys.Diff.Clear):
switch m.focus {
case leftSlot:
m.left = m.right
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
case rightSlot:
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
default:
m.left = slot{}
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
}
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
+94
View File
@@ -0,0 +1,94 @@
package diff
import (
"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...")
}
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderPanels(panelH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderPanels(panelH int) string {
s := style.S
leftW := m.width / 2
rightW := m.width - leftW
leftTitle := icons.I.Diff + "First"
if m.left.label != "" {
leftTitle = icons.I.Diff + "First: " + m.left.label
}
rightTitle := icons.I.Diff + "Second"
if m.right.label != "" {
rightTitle = icons.I.Diff + "Second: " + m.right.label
}
leftBorder := s.Panel
rightBorder := s.Panel
switch m.focus {
case bothSlots:
leftBorder = s.PanelFocused
rightBorder = s.PanelFocused
case leftSlot:
leftBorder = s.PanelFocused
case rightSlot:
rightBorder = s.PanelFocused
}
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(diffKeyMap{width: m.width}))
}
func renderLeftLines(lines []diffLine) string {
s := style.S
var sb strings.Builder
for _, l := range lines {
switch l.kind {
case lineRemoved:
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
case lineAdded:
sb.WriteString("\n")
default:
sb.WriteString(" " + l.text + "\n")
}
}
return sb.String()
}
func renderRightLines(lines []diffLine) string {
s := style.S
var sb strings.Builder
for _, l := range lines {
switch l.kind {
case lineAdded:
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
case lineRemoved:
sb.WriteString("\n")
default:
sb.WriteString(" " + l.text + "\n")
}
}
return sb.String()
}
+37
View File
@@ -0,0 +1,37 @@
package docs
import (
"strings"
spilltea "github.com/anotherhadi/spilltea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
)
func readDoc(name string) string {
b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name)
return string(b)
}
var contentMarkdown = strings.Join([]string{
readDoc("main.md"),
readDoc("proxy.md"),
readDoc("certificate.md"),
readDoc("history.md"),
readDoc("scopes.md"),
}, "\n")
type Model struct {
viewport viewport.Model
}
func New() Model {
return Model{
viewport: viewport.New(),
}
}
func (e Model) Init() tea.Cmd {
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package docs
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
switch msg := msg.(type) {
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case tea.MouseWheelDown:
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
}
case tea.KeyPressMsg:
switch {
case key.Matches(msg, g.Up):
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case key.Matches(msg, g.Down):
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
case key.Matches(msg, g.ScrollUp):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() + step)
}
}
return e, nil
}
func (m *Model) SetSize(w, h int) {
frameW := windowStyle().GetHorizontalFrameSize()
frameH := windowStyle().GetVerticalFrameSize()
m.viewport.SetWidth(w - frameW)
m.viewport.SetHeight(h - frameH)
m.renderMarkdown()
}
+52
View File
@@ -0,0 +1,52 @@
package docs
import (
"bytes"
_ "embed"
"text/template"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/style"
)
func windowStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(style.S.Subtle).
Padding(0, 0)
}
func (e Model) View() tea.View {
return tea.NewView(windowStyle().Render(e.viewport.View()))
}
func (m *Model) renderMarkdown() {
cfg := config.Global
data := struct {
Cfg *config.Config
}{
Cfg: cfg,
}
tmpl, err := template.New("info").Parse(contentMarkdown)
if err != nil {
return
}
var processed bytes.Buffer
if err := tmpl.Execute(&processed, data); err != nil {
return
}
width := m.viewport.Width() - 2
renderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
glamour.WithWordWrap(width),
)
str, _ := renderer.Render(processed.String())
m.viewport.SetContent(str)
}
+156
View File
@@ -0,0 +1,156 @@
package findings
import (
"bytes"
"text/template"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
database *db.DB
findings []db.Finding
cursor int
listViewport viewport.Model
bodyViewport viewport.Model
pager paginator.Model
help help.Model
width int
height int
}
func New() Model {
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetDB(d *db.DB) {
m.database = d
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
inner := m.width - 2
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{}))
}
// RefreshCmd loads findings from the database.
func RefreshCmd(d *db.DB) tea.Cmd {
return func() tea.Msg {
if d == nil {
return FindingsLoadedMsg{}
}
list, err := d.LoadFindings()
if err != nil {
return FindingsLoadedMsg{Err: err}
}
return FindingsLoadedMsg{Findings: list}
}
}
type FindingsLoadedMsg struct {
Findings []db.Finding
Err error
}
func (m *Model) refreshBody() {
if len(m.findings) == 0 {
m.bodyViewport.SetContent("")
return
}
f := m.findings[m.cursor]
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered)
m.bodyViewport.GotoTop()
}
func renderMarkdown(src string, width int) string {
if src == "" {
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
}
tmpl, err := template.New("").Parse(src)
if err != nil {
return src
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, nil); err != nil {
return src
}
if width < 10 {
width = 80
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
)
if err != nil {
return buf.String()
}
out, err := r.Render(buf.String())
if err != nil {
return buf.String()
}
return out
}
type findingsKeyMap struct{}
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss}
}
func (findingsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{findingsKeyMap{}.ShortHelp()}
}
+86
View File
@@ -0,0 +1,86 @@
package findings
import (
"log"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case FindingsLoadedMsg:
if msg.Err != nil {
log.Printf("findings load error: %v", msg.Err)
return m, nil
}
m.findings = msg.Findings
if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1)
}
m.pager.SetTotalPages(len(m.findings))
m.refreshListViewport()
m.refreshBody()
return m, nil
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
return m, nil
case tea.KeyPressMsg:
g := keys.Keys.Global
f := keys.Keys.Findings
switch {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
if m.cursor < m.pager.Page*m.pager.PerPage {
m.pager.PrevPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.findings)-1 {
m.cursor++
if m.cursor >= (m.pager.Page+1)*m.pager.PerPage {
m.pager.NextPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
log.Printf("dismiss finding: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
}
}
return m, nil
}
func (m *Model) refreshListViewport() {
m.listViewport.SetContent(m.renderList())
}
+112
View File
@@ -0,0 +1,112 @@
package findings
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Findings+"Findings", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
title := "Description"
if len(m.findings) > 0 {
title = m.findings[m.cursor].Title
}
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
}
func (m *Model) renderList() string {
s := style.S
if len(m.findings) == 0 {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"),
)
}
start, end := m.pager.GetSliceBounds(len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, f := range m.findings[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05")
w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW
if titleW < 0 {
titleW = 0
}
pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(titleW).Render(f.Title),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
sevLabel,
" ",
pluginStr,
" ",
s.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(f.Title),
)
}
sb.WriteString(fmt.Sprintf("%s\n", line))
}
return sb.String()
}
+149
View File
@@ -0,0 +1,149 @@
package history
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type panel int
const (
panelRequest panel = iota
panelResponse
)
type Model struct {
database *db.DB
entries []db.Entry
cursor int
focusedPanel panel
listViewport viewport.Model
bodyViewport viewport.Model
pager paginator.Model
help help.Model
searchInput textinput.Model
searchKind searchKind
searchAccepted bool
searchErr string
width int
height int
}
func New() Model {
ti := textinput.New()
ti.Prompt = ""
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
searchInput: ti,
}
}
func (m Model) IsEditing() bool {
return m.searchKind != searchKindOff && !m.searchAccepted
}
// RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that
// background refreshes re-run the active search rather than resetting it.
func (m Model) RefreshCmd() tea.Cmd {
switch m.searchKind {
case searchKindFulltext:
return SearchCmd(m.database, m.searchInput.Value())
case searchKindSQL:
return nil
default:
return LoadEntriesCmd(m.database)
}
}
func (m *Model) clearSearch() tea.Cmd {
m.searchKind = searchKindOff
m.searchAccepted = false
m.searchErr = ""
m.searchInput.SetValue("")
m.searchInput.Blur()
m.recalcSizes()
return LoadEntriesCmd(m.database)
}
func (m *Model) acceptSearch() {
m.searchAccepted = true
m.searchInput.Blur()
m.recalcSizes()
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetDB(d *db.DB) {
m.database = d
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
// 2 (padding) + 2 (prefix char + space)
m.searchInput.SetWidth(m.width - 4)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
inner := m.width - 2
if inner < 0 {
inner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
type historyKeyMap struct{ width int }
func (historyKeyMap) ShortHelp() []key.Binding {
h := keys.Keys.History
g := keys.Keys.Global
return []key.Binding{
g.Up, g.Down, g.CycleFocus,
h.DeleteEntry, h.DeleteAll,
h.Filter, h.SqlQuery,
g.Help,
}
}
func (m historyKeyMap) FullHelp() [][]key.Binding {
h := keys.Keys.History
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
all = append(all, keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+48
View File
@@ -0,0 +1,48 @@
package history
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
)
type searchKind int
const (
searchKindOff searchKind = iota
searchKindFulltext
searchKindSQL
)
type SearchResultMsg struct {
Entries []db.Entry
}
type SearchErrMsg struct {
Err error
}
func SearchCmd(database *db.DB, term string) tea.Cmd {
return func() tea.Msg {
if database == nil {
return SearchResultMsg{}
}
entries, err := database.SearchEntries(term)
if err != nil {
return SearchErrMsg{Err: err}
}
return SearchResultMsg{Entries: entries}
}
}
func SQLCmd(database *db.DB, query string) tea.Cmd {
return func() tea.Msg {
if database == nil {
return SearchResultMsg{}
}
entries, err := database.QueryEntries(query)
if err != nil {
return SearchErrMsg{Err: err}
}
return SearchResultMsg{Entries: entries}
}
}
+303
View File
@@ -0,0 +1,303 @@
package history
import (
"fmt"
"net/http"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"github.com/anotherhadi/spilltea/internal/util"
)
type EntriesLoadedMsg struct {
Entries []db.Entry
}
func LoadEntriesCmd(database *db.DB) tea.Cmd {
return func() tea.Msg {
if database == nil {
return EntriesLoadedMsg{}
}
entries, _ := database.ListEntries()
return EntriesLoadedMsg{Entries: entries}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case EntriesLoadedMsg:
// Ignore background reloads while a search is active (but not during a mode switch reset).
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
return m, nil
}
prevCursor := m.cursor
m.entries = msg.Entries
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
if m.cursor != prevCursor {
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case SearchResultMsg:
m.entries = msg.Entries
m.cursor = 0
m.searchErr = ""
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
if m.searchKind == searchKindSQL {
m.acceptSearch()
}
case SearchErrMsg:
m.searchErr = msg.Err.Error()
m.entries = nil
m.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case tea.MouseWheelMsg:
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:
h := keys.Keys.History
g := keys.Keys.Global
if m.searchKind != searchKindOff && !m.searchAccepted {
// Actively typing: only search navigation + accept/cancel.
switch {
case key.Matches(msg, g.Escape):
return m, m.clearSearch()
case msg.String() == "enter":
if m.searchKind == searchKindSQL {
return m, SQLCmd(m.database, m.searchInput.Value())
}
m.acceptSearch()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
default:
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
if m.searchKind == searchKindFulltext {
return m, tea.Batch(cmd, SearchCmd(m.database, m.searchInput.Value()))
}
return m, cmd
}
return m, nil
}
if m.searchKind != searchKindOff && m.searchAccepted {
// Filter accepted: Escape clears, all other shortcuts fall through.
if key.Matches(msg, g.Escape) {
return m, m.clearSearch()
}
}
switch {
case key.Matches(msg, keys.Keys.History.Filter):
prev := m.searchKind
m.searchKind = searchKindFulltext
m.searchAccepted = false
m.searchInput.Placeholder = "filter requests..."
m.searchErr = ""
m.searchInput.Focus()
m.recalcSizes()
if prev != searchKindFulltext {
m.searchInput.SetValue("")
return m, LoadEntriesCmd(m.database)
}
case key.Matches(msg, keys.Keys.History.SqlQuery):
prev := m.searchKind
m.searchKind = searchKindSQL
m.searchAccepted = false
m.searchInput.Placeholder = "status_code = 200 AND host LIKE '%.api.%'"
m.searchErr = ""
m.searchInput.Focus()
m.recalcSizes()
if prev != searchKindSQL {
m.searchInput.SetValue("")
return m, LoadEntriesCmd(m.database)
}
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.CycleFocus):
if m.focusedPanel == panelRequest {
m.focusedPanel = panelResponse
} else {
m.focusedPanel = panelRequest
}
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.SendToReplay):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
scheme := util.InferScheme(e.Host)
return m, func() tea.Msg {
return replayUI.SendToReplayMsg{
Scheme: scheme,
Host: e.Host,
RequestRaw: e.RequestRaw,
}
}
}
case key.Matches(msg, g.SendToDiff):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
var raw, label string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
} else {
raw = e.RequestRaw
label = e.Method + " " + e.Host + e.Path
}
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
case key.Matches(msg, h.DeleteEntry):
if len(m.entries) > 0 {
id := m.entries[m.cursor].ID
if m.database != nil {
m.database.DeleteEntry(id)
}
return m, LoadEntriesCmd(m.database)
}
case key.Matches(msg, h.DeleteAll):
if m.database != nil {
if m.searchKind != searchKindOff {
for _, e := range m.entries {
m.database.DeleteEntry(e.ID)
}
} else {
m.database.DeleteAllEntries()
}
}
return m, m.clearSearch()
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.Left):
m.bodyViewport.ScrollLeft(6)
case key.Matches(msg, g.Right):
m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshBody() {
if len(m.entries) == 0 {
m.bodyViewport.SetContent("")
return
}
e := m.entries[m.cursor]
var raw string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
} else {
raw = e.RequestRaw
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
}
+150
View File
@@ -0,0 +1,150 @@
package history
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"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)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.History+"History", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
title := icons.I.Request + "Request"
if m.focusedPanel == panelResponse {
title = icons.I.Response + "Response"
}
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
}
func (m *Model) renderStatusBar() string {
s := style.S
pad := lipgloss.NewStyle().Padding(0, 1)
escKey := keys.Keys.Global.Escape.Help().Key
switch m.searchKind {
case searchKindFulltext:
filterKey := keys.Keys.History.Filter.Help().Key
if m.searchAccepted {
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
}
return pad.Render(s.Faint.Render(filterKey) + " " + m.searchInput.View())
case searchKindSQL:
sqlKey := keys.Keys.History.SqlQuery.Help().Key
if m.searchAccepted {
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
}
return pad.Render(s.Faint.Render(sqlKey) + " " + m.searchInput.View())
default:
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
}
}
func (m *Model) renderList() string {
s := style.S
if m.searchErr != "" {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.NewStyle().Foreground(s.Error).Render(m.searchErr),
)
}
if len(m.entries) == 0 {
msg := " (⌐■_■)\nno history yet"
if m.searchKind != searchKindOff {
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results"
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
)
}
start, end := m.pager.GetSliceBounds(len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, e := range m.entries[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
statusStr := fmt.Sprintf("%3d", e.StatusCode)
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
ts := e.Timestamp.Format("15:04:05")
statusSt := style.StatusStyle(e.StatusCode, 3)
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(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
+339
View File
@@ -0,0 +1,339 @@
package home
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
type itemKind int
const (
kindNew itemKind = iota
kindTemp
kindExisting
)
type listItem struct {
kind itemKind
name string
path string
count int
modTime time.Time
}
func (i listItem) icon() string {
ic := icons.I
switch i.kind {
case kindNew:
return ic.New
case kindTemp:
return ic.Temp
default:
return ic.Project
}
}
func (i listItem) title() string {
switch i.kind {
case kindNew:
return "New Project"
case kindTemp:
return "Temporary Session"
default:
return i.name
}
}
func (i listItem) description() string {
switch i.kind {
case kindNew:
return "create and name a new project"
case kindTemp:
return "isolated session, deleted on exit"
default:
date := i.modTime.Format("Jan 2, 2006")
if i.count == 1 {
return fmt.Sprintf("1 request · %s", date)
}
return fmt.Sprintf("%d requests · %s", i.count, date)
}
}
// FilterValue contains only the text (no icon) so fuzzy match indices map
// directly onto title() and don't need an offset to account for icon width.
func (i listItem) FilterValue() string { return i.title() }
type homeDelegate struct {
normalTitle lipgloss.Style
normalDesc lipgloss.Style
selectedTitle lipgloss.Style
selectedDesc lipgloss.Style
filterMatch lipgloss.Style
}
func newHomeDelegate() homeDelegate {
s := style.S
leftBorder := lipgloss.Border{Left: "│"}
return homeDelegate{
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
selectedTitle: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(3),
selectedDesc: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(3),
filterMatch: lipgloss.NewStyle().Underline(true),
}
}
func (d homeDelegate) Height() int { return 2 }
func (d homeDelegate) Spacing() int { return 1 }
func (d homeDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d homeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
li := item.(listItem)
selected := index == m.Index()
// Apply match highlighting only to the title text
// separately so its width never shifts the highlight indices.
titleText := li.title()
if m.IsFiltered() {
if matches := m.MatchesForItem(index); len(matches) > 0 {
base := lipgloss.NewStyle()
titleText = lipgloss.StyleRunes(titleText, matches, d.filterMatch.Inherit(base), base)
}
}
full := li.icon() + titleText
var titleLine, descLine string
if selected {
titleLine = d.selectedTitle.Render(full)
descLine = d.selectedDesc.Render(li.description())
} else {
titleLine = d.normalTitle.Render(full)
descLine = d.normalDesc.Render(li.description())
}
fmt.Fprintf(w, "%s\n%s", titleLine, descLine)
}
type Project struct {
Name string
Path string
Count int
ModTime time.Time
}
type inputMode int
const (
modeSelect inputMode = iota
modeNaming
)
const (
baseHeaderLines = 1 + 1 + 1 + 2
teapotMinH = 28 // minimum terminal height to show the teapot
maxInnerW = 80 // max content width inside the padding box
maxInnerH = 50 // max content height inside the padding box
)
type Model struct {
mode inputMode
list list.Model
projectDir string
nameInput textinput.Model
selected *Project
width int
height int
teapotFrame int
}
// Selected returns the project chosen by the user, or nil if the program was
// quit without making a selection.
func (m Model) Selected() *Project { return m.selected }
func New(projectDir string) Model {
projects := loadProjects(projectDir)
l := list.New(buildItems(projects), newHomeDelegate(), 0, 0)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
ti := textinput.New()
ti.Placeholder = "my-project"
ti.CharLimit = 64
ti.SetWidth(inputPanelMaxW - 2 - 4)
return Model{
projectDir: projectDir,
list: l,
nameInput: ti,
}
}
func (m Model) Init() tea.Cmd { return teapotTick() }
func (m Model) innerW() int {
w := m.width - 2
if w > maxInnerW {
w = maxInnerW
}
if w < 0 {
return 0
}
return w
}
func (m Model) innerH() int {
h := m.height - 2
if h > maxInnerH {
h = maxInnerH
}
if h < 0 {
return 0
}
return h
}
func (m Model) headerHeight() int {
if m.height > teapotMinH {
// teapot block replaces 1 \n (else branch) with frame \n's + \n\n
// net addition = FrameLines() (= frame_internal_\n + \n\n - else_\n)
return baseHeaderLines + teapot.FrameLines()
}
return baseHeaderLines
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
lw := m.listWidth()
lh := m.innerH() - m.headerHeight() - 1
if lh < 0 {
lh = 0
}
m.list.SetSize(lw, lh)
m.nameInput.SetWidth(inputPanelInnerW(m.innerW()))
}
func (m Model) IsEditing() bool { return m.mode == modeNaming }
func (m Model) listWidth() int {
return m.innerW()
}
func inputPanelInnerW(termW int) int {
panelW := inputPanelMaxW
if termW < panelW+4 {
panelW = termW - 4
}
if panelW < 10 {
panelW = 10
}
return panelW - 2 - 4 // border (2) + padding (2×2)
}
func loadProjects(projectDir string) []Project {
entries, err := os.ReadDir(projectDir)
if err != nil {
return nil
}
var projects []Project
for _, e := range entries {
if !e.IsDir() {
continue
}
dbPath := filepath.Join(projectDir, e.Name(), "data.db")
info, err := os.Stat(dbPath)
if err != nil {
continue
}
projects = append(projects, Project{
Name: e.Name(),
Path: dbPath,
Count: db.CountEntriesAt(dbPath),
ModTime: info.ModTime(),
})
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].ModTime.After(projects[j].ModTime)
})
return projects
}
func buildItems(projects []Project) []list.Item {
items := []list.Item{
listItem{kind: kindNew},
listItem{kind: kindTemp},
}
for _, p := range projects {
items = append(items, listItem{
kind: kindExisting,
name: p.Name,
path: p.Path,
count: p.Count,
modTime: p.ModTime,
})
}
return items
}
func (m Model) renderHelpLine() string {
s := style.S
k := keys.Keys.Home
fs := m.list.FilterState()
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
dStyle := s.Faint.Inline(true)
sep := s.Faint.Inline(true).Render(" • ")
item := func(keyStr, desc string) string {
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
}
binding := func(b key.Binding) string {
return item(b.Help().Key, b.Help().Desc)
}
var parts []string
if fs == list.Filtering {
parts = append(parts, item("enter", "apply filter"))
parts = append(parts, item("esc", "cancel"))
} else {
parts = append(parts, item("↑/↓", "navigate"))
if fs == list.FilterApplied {
parts = append(parts, item("esc", "clear filter"))
} else {
parts = append(parts, binding(k.Filter))
}
parts = append(parts, binding(k.Open))
parts = append(parts, binding(k.Delete))
parts = append(parts, item("q", "quit"))
}
return strings.Join(parts, sep)
}
+180
View File
@@ -0,0 +1,180 @@
package home
import (
crypto "crypto/rand"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
type teapotTickMsg struct{}
func teapotTick() tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return teapotTickMsg{}
})
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.SetSize(ws.Width, ws.Height)
return m, nil
}
if _, ok := msg.(teapotTickMsg); ok {
frames := teapot.TeapotFrames()
m.teapotFrame = (m.teapotFrame + 1) % len(frames)
return m, teapotTick()
}
if m.mode == modeNaming {
if kp, ok := msg.(tea.KeyPressMsg); ok {
return m.updateNaming(kp)
}
return m, nil
}
if kp, ok := msg.(tea.KeyPressMsg); ok {
if !m.list.SettingFilter() {
if key.Matches(kp, keys.Keys.Global.Quit) {
return m, tea.Quit
}
if key.Matches(kp, keys.Keys.Home.Open) {
return m.handleSelection()
}
if key.Matches(kp, keys.Keys.Home.Delete) {
return m.deleteSelected()
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) handleSelection() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok {
return m, nil
}
switch item.kind {
case kindNew:
m.mode = modeNaming
m.nameInput.SetValue("")
return m, m.nameInput.Focus()
case kindTemp:
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
m.selected = &Project{Name: item.name, Path: item.path}
return m, tea.Quit
}
}
func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok || item.kind != kindExisting {
return m, nil
}
dir := filepath.Dir(item.path) // parent dir of data.db
os.RemoveAll(dir)
idx := m.list.GlobalIndex()
m.list.RemoveItem(idx)
if idx > 0 && idx >= len(m.list.Items()) {
m.list.Select(idx - 1)
}
return m, nil
}
func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.mode = modeSelect
m.nameInput.Blur()
return m, nil
case msg.String() == "enter":
name := m.nameInput.Value()
if name == "" {
return m, nil
}
m.mode = modeSelect
m.nameInput.Blur()
dir := filepath.Join(m.projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
m.nameInput.SetValue(sanitizeName(m.nameInput.Value()))
return m, cmd
}
}
func sanitizeName(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func IsValidProjectName(s string) bool {
if s == "tmp" {
return true
}
return s != "" && s == sanitizeName(s)
}
func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" {
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
}
dir := filepath.Join(projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: name, Path: filepath.Join(dir, "data.db")}, nil
}
func tempDir() string {
b := make([]byte, 4)
_, _ = crypto.Read(b)
return filepath.Join(os.TempDir(), "spilltea", fmt.Sprintf("%08x", b))
}
func initProjectFiles(dir string) {
for _, name := range []string{"data.db", "logs.log"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
f, err := os.Create(p)
if err == nil {
f.Close()
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package home
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
const inputPanelMaxW = 44
func (m Model) View() tea.View {
s := style.S
iw := m.innerW()
var sb strings.Builder
sb.WriteString("\n")
if m.height > teapotMinH {
frames := teapot.TeapotFrames()
frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame])
sb.WriteString(center(iw, frame))
sb.WriteString("\n\n")
} else {
sb.WriteString("\n")
}
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA")))
sb.WriteString("\n")
sb.WriteString(center(iw, s.Faint.Render("choose a project to get started")))
sb.WriteString("\n\n")
if m.mode == modeNaming {
sb.WriteString(m.renderNamingPanel())
} else {
lw := m.listWidth()
leftPad := (iw - lw) / 2
sb.WriteString(padLeft(m.list.View(), leftPad))
sb.WriteString("\n")
sb.WriteString(center(iw, m.renderHelpLine()))
}
box := lipgloss.NewStyle().Padding(1, 1).Render(sb.String())
content := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNamingPanel() string {
s := style.S
iw := m.innerW()
panelW := inputPanelMaxW
if iw < panelW+4 {
panelW = iw - 4
}
if panelW < 10 {
panelW = 10
}
innerW := inputPanelInnerW(iw)
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name")
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary).
Padding(1, 2).
Width(panelW).
Render(label + "\n" + inputLine)
hint := s.Faint.Render("[enter] confirm [esc] cancel")
var sb strings.Builder
sb.WriteString(center(iw, panel))
sb.WriteString("\n")
sb.WriteString(center(iw, hint))
sb.WriteString("\n")
return sb.String()
}
// padLeft prepends n spaces to every non-empty line.
func padLeft(content string, n int) string {
if n <= 0 {
return content
}
pad := strings.Repeat(" ", n)
lines := strings.Split(content, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
func center(width int, s string) string {
return lipgloss.PlaceHorizontal(width, lipgloss.Center, s)
}
+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()
}
+180
View File
@@ -0,0 +1,180 @@
package plugins
import (
"os"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
manager *plugins.Manager
items []plugins.Info
cursor int
editing bool
filter string
filtered []plugins.Info
listViewport viewport.Model
textarea textarea.Model
filterInput textinput.Model
filtering bool
pager paginator.Model
help help.Model
width int
height int
}
func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false)
ta.Placeholder = "plugin configuration..."
ta.Blur()
fi := textinput.New()
fi.Prompt = ""
return Model{
manager: mgr,
listViewport: style.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing || m.filtering }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
inner := m.width - 2
if inner < 0 {
inner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
m.filterInput.SetWidth(inner - 2)
m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6))
m.refreshListViewport()
}
// Refresh reloads the plugin list from the manager.
func (m *Model) Refresh() {
if m.manager == nil {
return
}
pl := m.manager.GetPlugins()
m.items = make([]plugins.Info, len(pl))
for i, p := range pl {
m.items[i] = p.Info()
}
m.applyFilter()
}
func (m *Model) applyFilter() {
if m.filter == "" {
m.filtered = m.items
} else {
f := strings.ToLower(m.filter)
filtered := make([]plugins.Info, 0, len(m.items))
for _, p := range m.items {
if strings.Contains(strings.ToLower(p.Name), f) {
filtered = append(filtered, p)
}
}
m.filtered = filtered
}
m.pager.SetTotalPages(len(m.filtered))
if m.cursor >= len(m.filtered) {
m.cursor = max(0, len(m.filtered)-1)
}
m.refreshListViewport()
m.syncTextarea()
}
func (m *Model) selected() (plugins.Info, bool) {
if len(m.filtered) == 0 {
return plugins.Info{}, false
}
return m.filtered[m.cursor], true
}
func (m *Model) syncTextarea() {
if m.editing {
return
}
info, ok := m.selected()
if !ok {
m.textarea.SetValue("")
return
}
m.textarea.SetValue(info.ConfigText)
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.filtered))
}
m.listViewport.SetContent(m.renderList())
}
func shortenPath(p string) string {
home := os.Getenv("HOME")
if home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins
g := keys.Keys.Global
if k.editing {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc}
}
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
}
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+130
View File
@@ -0,0 +1,130 @@
package plugins
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
// PluginsChangedMsg is sent when the plugin list should be refreshed.
type PluginsChangedMsg struct{}
// RefreshCmd returns a command that triggers a list refresh.
func RefreshCmd() tea.Cmd {
return func() tea.Msg { return PluginsChangedMsg{} }
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case PluginsChangedMsg:
m.Refresh()
return m, nil
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
pk := keys.Keys.Plugins
g := keys.Keys.Global
// Filtering mode: esc clears+closes, enter just closes, rest goes to filterInput.
if m.filtering {
switch {
case key.Matches(msg, g.Escape):
m.filtering = false
m.filter = ""
m.filterInput.SetValue("")
m.filterInput.Blur()
m.applyFilter()
m.recalcSizes()
case msg.String() == "enter":
m.filtering = false
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.filter = m.filterInput.Value()
m.applyFilter()
return m, cmd
}
return m, nil
}
// Editing mode: only esc exits, everything else goes to textarea.
if m.editing {
if key.Matches(msg, g.Escape) {
m.editing = false
m.textarea.Blur()
if info, ok := m.selected(); ok && m.manager != nil {
val := m.textarea.Value()
m.manager.SaveConfig(info.Name, val)
// Update cached info.
m.filtered[m.cursor].ConfigText = val
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].ConfigText = val
break
}
}
}
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, g.Escape):
if m.filter != "" {
m.filter = ""
m.filterInput.SetValue("")
m.applyFilter()
}
case key.Matches(msg, pk.Filter):
m.filtering = true
m.filterInput.Focus()
m.recalcSizes()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 {
m.cursor++
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, pk.Toggle):
if info, ok := m.selected(); ok && m.manager != nil {
m.manager.TogglePlugin(info.Name)
m.filtered[m.cursor].Enabled = !info.Enabled
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].Enabled = !info.Enabled
break
}
}
m.refreshListViewport()
}
case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok {
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
+150
View File
@@ -0,0 +1,150 @@
package plugins
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil {
return tea.NewView(style.S.Faint.Render("\nno plugins loaded"))
}
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderDetailPanel(detailH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h)
}
func (m *Model) renderDetailPanel(h int) string {
s := style.S
info, ok := m.selected()
if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h)
}
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success)
}
status := "disabled"
if info.Enabled {
status = "enabled"
}
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):"))
}
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()),
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()),
)
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h)
}
func (m *Model) renderStatusBar() string {
s := style.S
pad := lipgloss.NewStyle().Padding(0, 1)
filterKey := keys.Keys.Plugins.Filter.Help().Key
if m.filtering {
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
}
if m.filter != "" {
escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})))
}
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
}
func (m *Model) renderList() string {
s := style.S
if len(m.filtered) == 0 {
msg := " (ง •̀_•́)ง\nno plugins"
if m.filter != "" {
msg = " = _ =\nno results"
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
)
}
start, end := m.pager.GetSliceBounds(len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, p := range m.filtered[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
enabledStr := "off"
if p.Enabled {
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
enabledStr = "on "
}
w := m.listViewport.Width()
const fixedW = 2 + 3 + 1
nameW := w - fixedW
if nameW < 0 {
nameW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(nameW).Render(p.Name),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
enabledSt.Width(3).Render(enabledStr),
" ",
s.Bold.Render(p.Name),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
+175
View File
@@ -0,0 +1,175 @@
package replay
import (
"fmt"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"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/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type SendToReplayMsg struct {
Scheme string
Host string
RequestRaw string
}
type Entry struct {
DBID int64
Scheme string
Host string
Path string
Method string
OriginalRaw string
RequestRaw string // current (possibly edited) request
ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet
Sending bool
Err error
}
type Model struct {
entries []Entry
cursor int
editing bool
database *db.DB
listViewport viewport.Model
requestViewport viewport.Model
responseViewport viewport.Model
textarea textarea.Model
pager paginator.Model
help help.Model
width int
height int
}
func New() Model {
ta := style.NewTextarea(false)
ta.Blur()
return Model{
listViewport: style.NewViewport(),
requestViewport: style.NewViewport(),
responseViewport: style.NewViewport(),
textarea: ta,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m *Model) SetDB(d *db.DB) {
m.database = d
if d == nil {
return
}
entries, err := d.ListReplayEntries()
if err != nil {
return
}
for _, dbe := range entries {
m.entries = append(m.entries, entryFromDB(dbe))
}
m.pager.SetTotalPages(len(m.entries))
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
}
m.refreshListViewport()
m.refreshBody()
}
func entryFromDB(dbe db.ReplayEntry) Entry {
var err error
if dbe.ErrorMsg != "" {
err = fmt.Errorf("%s", dbe.ErrorMsg)
}
return Entry{
DBID: dbe.ID,
Scheme: dbe.Scheme,
Host: dbe.Host,
Path: dbe.Path,
Method: dbe.Method,
OriginalRaw: dbe.OriginalRaw,
RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode,
Err: err,
}
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listInner := m.width - 2
if listInner < 0 {
listInner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(listInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
leftW, rightW := m.bodyHalfWidths()
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
bodyVH := style.PanelContentH(bodyH)
m.requestViewport.SetWidth(leftInner)
m.requestViewport.SetHeight(bodyVH)
m.responseViewport.SetWidth(rightInner)
m.responseViewport.SetHeight(bodyVH)
m.textarea.SetWidth(leftInner)
m.textarea.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) bodyHalfWidths() (left, right int) {
left = m.width / 2
right = m.width - left
return
}
type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help}
}
func (m replayKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Replay.Bindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+413
View File
@@ -0,0 +1,413 @@
package replay
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
type sentMsg struct {
index int
responseRaw string
statusCode int
err error
}
func sendCmd(entry Entry, index int) tea.Cmd {
return func() tea.Msg {
raw, code, err := doSend(entry)
return sentMsg{index: index, responseRaw: raw, statusCode: code, err: err}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case SendToReplayMsg:
entry := entryFromMsg(msg)
if m.database != nil {
id, err := m.database.InsertReplayEntry(entryToDB(entry))
if err == nil {
entry.DBID = id
}
}
m.entries = append(m.entries, entry)
m.cursor = len(m.entries) - 1
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) {
e := &m.entries[msg.index]
e.Sending = false
e.StatusCode = msg.statusCode
e.ResponseRaw = msg.responseRaw
if msg.err != nil {
e.Err = msg.err
e.ResponseRaw = "Error: " + msg.err.Error()
}
if m.database != nil && e.DBID != 0 {
m.database.UpdateReplayEntry(entryToDB(*e))
}
}
m.refreshListViewport()
m.refreshBody()
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = msg.Content
m.refreshBody()
}
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
}
}
case tea.KeyPressMsg:
if m.editing {
return m.updateEditMode(msg)
}
return m.updateNormalMode(msg)
}
return m, nil
}
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
r := keys.Keys.Replay
switch {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
m.entries[m.cursor].Sending = true
m.entries[m.cursor].ResponseRaw = ""
m.entries[m.cursor].Err = nil
m.refreshListViewport()
m.refreshBody()
return m, sendCmd(m.entries[m.cursor], m.cursor)
}
case key.Matches(msg, r.Edit):
if len(m.entries) > 0 {
m.textarea.SetValue(m.entries[m.cursor].RequestRaw)
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, r.EditExt):
if len(m.entries) > 0 {
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
}
case key.Matches(msg, r.UndoEdits):
if len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = m.entries[m.cursor].OriginalRaw
m.refreshBody()
}
case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
case key.Matches(msg, g.Right):
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
case key.Matches(msg, r.Delete):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
if m.database != nil && e.DBID != 0 {
m.database.DeleteReplayEntry(e.DBID)
}
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 {
m.cursor--
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, r.DeleteAll):
if m.database != nil {
m.database.DeleteAllReplayEntries()
}
m.entries = nil
m.cursor = 0
m.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
return m, nil
}
func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
if len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = m.textarea.Value()
}
m.editing = false
m.textarea.Blur()
m.refreshBody()
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
return m, nil
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshBody() {
if len(m.entries) == 0 {
m.requestViewport.SetContent("")
m.responseViewport.SetContent("")
return
}
e := m.entries[m.cursor]
m.requestViewport.SetContent(style.HighlightHTTP(e.RequestRaw))
m.requestViewport.SetYOffset(0)
m.requestViewport.SetXOffset(0)
if e.Sending {
m.responseViewport.SetContent(style.HighlightHTTP("Sending..."))
} else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else {
m.responseViewport.SetContent("")
}
m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0)
}
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return "", 0, fmt.Errorf("empty request")
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) < 2 {
return "", 0, fmt.Errorf("invalid request line")
}
method := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])
headers := make(http.Header)
host := entry.Host
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 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if strings.ToLower(k) == "host" {
host = v
} else {
headers.Add(k, v)
}
}
i++
}
var bodyBytes []byte
if i < len(lines) {
b := strings.Join(lines[i:], "\n")
b = strings.TrimRight(b, "\n")
bodyBytes = []byte(b)
}
scheme := entry.Scheme
if scheme == "" {
scheme = "https"
}
urlStr := scheme + "://" + host + path
var bodyReader io.Reader
if len(bodyBytes) > 0 {
bodyReader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return "", 0, err
}
req.Header = headers
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
sortedKeys := make([]string, 0, len(resp.Header))
for k := range resp.Header {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
for _, k := range sortedKeys {
for _, v := range resp.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
sb.Write(respBody)
return sb.String(), resp.StatusCode, nil
}
func entryToDB(e Entry) db.ReplayEntry {
errMsg := ""
if e.Err != nil {
errMsg = e.Err.Error()
}
return db.ReplayEntry{
ID: e.DBID,
Timestamp: time.Now(),
Scheme: e.Scheme,
Host: e.Host,
Path: e.Path,
Method: e.Method,
OriginalRaw: e.OriginalRaw,
RequestRaw: e.RequestRaw,
ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode,
ErrorMsg: errMsg,
}
}
func entryFromMsg(msg SendToReplayMsg) Entry {
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host)
scheme := msg.Scheme
if scheme == "" {
scheme = util.InferScheme(host)
}
return Entry{
Scheme: scheme,
Host: host,
Path: path,
Method: method,
OriginalRaw: msg.RequestRaw,
RequestRaw: msg.RequestRaw,
}
}
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
host = fallbackHost
path = "/"
lines := strings.SplitN(raw, "\n", 2)
if len(lines) == 0 {
return
}
parts := strings.Fields(lines[0])
if len(parts) >= 1 {
method = parts[0]
}
if len(parts) >= 2 {
path = parts[1]
}
if len(lines) > 1 {
for _, line := range strings.Split(lines[1], "\n") {
if strings.HasPrefix(strings.ToLower(line), "host:") {
host = strings.TrimSpace(line[5:])
break
}
}
}
return
}
+137
View File
@@ -0,0 +1,137 @@
package replay
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)
leftW, rightW := m.bodyHalfWidths()
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderRequestPanel(leftW, bodyH),
m.renderResponsePanel(rightW, bodyH),
)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
bodyRow,
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h)
}
func (m *Model) renderRequestPanel(w, h int) string {
s := style.S
var body string
border := s.Panel
if m.editing {
body = m.textarea.View()
border = s.PanelFocused
} else {
body = m.requestViewport.View()
}
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
}
func (m *Model) renderResponsePanel(w, h int) string {
s := style.S
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width}))
}
func (m *Model) renderList() string {
if len(m.entries) == 0 {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"),
)
}
s := style.S
start, end := m.pager.GetSliceBounds(len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, e := range m.entries[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
statusStr, statusSt := entryStatus(e)
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(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
func entryStatus(e Entry) (string, lipgloss.Style) {
base := lipgloss.NewStyle().Bold(true).Width(3)
switch {
case e.Sending:
return "···", base.Foreground(style.S.Subtle)
case e.Err != nil:
return "ERR", base.Foreground(style.S.Error)
case e.StatusCode == 0:
return "---", base.Foreground(style.S.Subtle)
}
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
}
+150
View File
@@ -0,0 +1,150 @@
package scope
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
const (
fieldNone = -1
fieldWhitelist = 0
fieldBlacklist = 1
)
const (
minTaH = 3
maxTaH = 12
fixedH = 8 // (blank + label + desc + blank) x2
)
type ScopeChangedMsg struct {
Whitelist []string
Blacklist []string
}
type Model struct {
focusIdx int
wlTextarea textarea.Model
blTextarea textarea.Model
innerH int
width int
height int
help help.Model
}
func New(name, path string) Model {
wl := style.NewTextarea(true)
wl.Placeholder = "one pattern per line..."
bl := style.NewTextarea(true)
bl.Placeholder = "one pattern per line..."
bl.Blur()
return Model{
focusIdx: fieldNone,
wlTextarea: wl,
blTextarea: bl,
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetScope(whitelist, blacklist []string) {
m.wlTextarea.SetValue(strings.Join(whitelist, "\n"))
m.blTextarea.SetValue(strings.Join(blacklist, "\n"))
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.syncLayout()
}
func (m *Model) syncLayout() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
m.innerH = max(1, style.PanelContentH(panelH))
taH := (m.innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
// width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor)
taW := max(1, m.width-6)
m.wlTextarea.SetWidth(taW)
m.wlTextarea.SetHeight(taH)
m.blTextarea.SetWidth(taW)
m.blTextarea.SetHeight(taH)
}
func (m Model) IsEditing() bool {
return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist
}
func (m *Model) scopeChangedCmd() tea.Cmd {
wl := parseLines(m.wlTextarea.Value())
bl := parseLines(m.blTextarea.Value())
return func() tea.Msg {
return ScopeChangedMsg{Whitelist: wl, Blacklist: bl}
}
}
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
out = append(out, t)
}
}
return out
}
func (m Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(
m.help.View(formKeyMap{focusIdx: m.focusIdx}),
)
}
type formKeyMap struct {
focusIdx int
}
func (k formKeyMap) ShortHelp() []key.Binding {
cycle := keys.Keys.Global.CycleFocus
hlp := keys.Keys.Global.Help
switch k.focusIdx {
case fieldWhitelist, fieldBlacklist:
esc := keys.Keys.Global.Escape
escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus"))
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")),
escBinding,
cycle,
}
}
return []key.Binding{cycle, hlp}
}
func (k formKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+70
View File
@@ -0,0 +1,70 @@
package scope
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
kp, isKey := msg.(tea.KeyPressMsg)
if !isKey {
return m, nil
}
if key.Matches(kp, keys.Keys.Global.CycleFocus) {
return m.cycleFocus()
}
if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
switch m.focusIdx {
case fieldWhitelist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.wlTextarea, cmd = m.wlTextarea.Update(kp)
return m, cmd
case fieldBlacklist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.blTextarea, cmd = m.blTextarea.Update(kp)
return m, cmd
}
return m, nil
}
func (m Model) blurAll() (tea.Model, tea.Cmd) {
m.wlTextarea.Blur()
m.blTextarea.Blur()
m.focusIdx = fieldNone
m.syncLayout()
return m, m.scopeChangedCmd()
}
func (m Model) cycleFocus() (tea.Model, tea.Cmd) {
scopeCmd := m.scopeChangedCmd()
var focusCmd tea.Cmd
switch m.focusIdx {
case fieldNone, fieldBlacklist:
m.blTextarea.Blur()
m.focusIdx = fieldWhitelist
focusCmd = m.wlTextarea.Focus()
case fieldWhitelist:
m.wlTextarea.Blur()
m.focusIdx = fieldBlacklist
focusCmd = m.blTextarea.Focus()
}
m.syncLayout()
return m, tea.Batch(focusCmd, scopeCmd)
}
+84
View File
@@ -0,0 +1,84 @@
package scope
import (
"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("")
}
s := style.S
statusBar := m.renderStatusBar()
statusH := strings.Count(statusBar, "\n") + 1
panelH := m.height - statusH
innerH := max(1, style.PanelContentH(panelH))
taH := (innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
var lines []string
add := func(l string) { lines = append(lines, l) }
add("")
add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist))
add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted."))
add("")
wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1
for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) {
add(" " + l)
}
add("")
add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist))
add(" " + s.Faint.Render("Matching requests are always excluded from history."))
add("")
blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1
for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) {
add(" " + l)
}
for len(lines) < innerH {
lines = append(lines, "")
}
content := strings.Join(lines[:innerH], "\n")
panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH)
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar))
}
func fieldLabel(name string, focused bool) string {
s := style.S
c := s.MutedFg
if focused {
c = s.Primary
}
return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name)
}
func taLines(view string, h int, contentLines int) []string {
raw := strings.Split(strings.TrimRight(view, "\n"), "\n")
tilde := style.S.Faint.Render("~")
for len(raw) < h {
raw = append(raw, tilde)
}
if len(raw) > h {
raw = raw[:h]
}
for i := contentLines; i < len(raw); i++ {
raw[i] = tilde
}
return raw
}