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