Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"log"
"os"
"path/filepath"
"strconv"
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
"github.com/sirupsen/logrus"
)
const tickInterval = 2 * time.Second
type tickMsg struct{}
func tickCmd() tea.Cmd {
return func() tea.Msg {
time.Sleep(tickInterval)
return tickMsg{}
}
}
var sidebarEntries = pageRegistry
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(sidebarEntries))
for i, e := range sidebarEntries {
m[strconv.Itoa(i+1)] = e.id
}
return m
}()
type Model struct {
broker *intercept.Broker
page page
projectName string
projectPath string
database *db.DB
logFile *os.File
pluginManager *plugins.Manager
width int
height int
sidebarState sidebarState
intercept interceptUI.Model
history historyUI.Model
replay replayUI.Model
diff diffUI.Model
docs docsUI.Model
scope scopeUI.Model
pluginsPage pluginsUI.Model
findingsPage findingsUI.Model
copyAs copyasUI.Model
notifications notificationsUI.Model
}
func New(broker *intercept.Broker, name, path string) Model {
cfg := config.Global
mgr := plugins.NewManager(broker)
m := Model{
broker: broker,
page: pageIntercept,
projectName: name,
projectPath: path,
pluginManager: mgr,
intercept: interceptUI.New(broker),
history: historyUI.New(),
replay: replayUI.New(),
diff: diffUI.New(),
docs: docsUI.New(),
scope: scopeUI.New(name, path),
pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(),
copyAs: copyasUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
}
if d, err := db.Open(path); err == nil {
m.database = d
broker.SetDB(d)
m.history.SetDB(d)
m.replay.SetDB(d)
m.findingsPage.SetDB(d)
mgr.SetDB(d)
if wl, bl, err := d.LoadScope(); err == nil {
broker.SetScope(wl, bl)
m.scope.SetScope(wl, bl)
}
}
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err)
}
m.pluginsPage.Refresh()
if lf, err := os.OpenFile(filepath.Join(filepath.Dir(path), "logs.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
m.logFile = lf
log.SetOutput(lf)
logrus.SetOutput(lf)
}
return m
}
func (m Model) Init() tea.Cmd {
mgr := m.pluginManager
return tea.Batch(
intercept.WaitForRequest(m.broker),
intercept.WaitForResponse(m.broker),
tickCmd(),
proxyPkg.StartCmd(m.broker, mgr),
plugins.WaitForNotif(mgr),
plugins.WaitForQuit(mgr),
findingsUI.RefreshCmd(m.database),
func() tea.Msg { mgr.RunOnStart(); return nil },
)
}
+146
View File
@@ -0,0 +1,146 @@
package app
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/icons"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
type page string
const (
pageIntercept page = "Intercept"
pageHistory page = "History"
pageReplay page = "Replay"
pageDiff page = "Diff"
pageScopes page = "Scopes"
pagePlugins page = "Plugins"
pageFindings page = "Findings"
pageDocs page = "Docs"
)
// pageEntry describes a page and all its integration hooks.
type pageEntry struct {
id page
icon func() string
// render returns the page's view content. nil = show "empty".
render func(m *Model) string
// update is called when this page is active. nil = no-op.
update func(m *Model, msg tea.Msg) tea.Cmd
// isEditing reports whether the page is in text-editing mode.
isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int)
}
var pageRegistry = []pageEntry{
{
id: pageIntercept,
icon: func() string { return icons.I.Intercept },
render: func(m *Model) string { return m.intercept.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
},
{
id: pageHistory,
icon: func() string { return icons.I.History },
render: func(m *Model) string { return m.history.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.history.IsEditing() },
resize: func(m *Model, w, h int) { m.history.SetSize(w, h) },
},
{
id: pageReplay,
icon: func() string { return icons.I.Replay },
render: func(m *Model) string { return m.replay.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.replay.IsEditing() },
resize: func(m *Model, w, h int) { m.replay.SetSize(w, h) },
},
{
id: pageDiff,
icon: func() string { return icons.I.Diff },
render: func(m *Model) string { return m.diff.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) },
},
{
id: pageScopes,
icon: func() string { return icons.I.Scope },
render: func(m *Model) string { return m.scope.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.scope.Update(msg)
m.scope = updated.(scopeUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.scope.IsEditing() },
resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) },
},
{
id: pagePlugins,
icon: func() string { return icons.I.Plugin },
render: func(m *Model) string { return m.pluginsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.pluginsPage.Update(msg)
m.pluginsPage = updated.(pluginsUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.pluginsPage.IsEditing() },
resize: func(m *Model, w, h int) { m.pluginsPage.SetSize(w, h) },
},
{
id: pageFindings,
icon: func() string { return icons.I.Findings },
render: func(m *Model) string { return m.findingsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
},
{
id: pageDocs,
icon: func() string { return icons.I.Docs },
render: func(m *Model) string { return m.docs.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.docs.Update(msg)
m.docs = updated.(docsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
},
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
type sidebarState string
const (
sidebarHidden sidebarState = "hidden"
sidebarCollapsed sidebarState = "collapsed"
sidebarExpanded sidebarState = "expanded"
)
func (m *Model) cycleSidebarState() {
switch m.sidebarState {
case sidebarHidden:
m.sidebarState = sidebarCollapsed
case sidebarCollapsed:
m.sidebarState = sidebarExpanded
default:
m.sidebarState = sidebarHidden
}
}
func (m Model) getSidebarWidth() int {
switch m.sidebarState {
case sidebarHidden:
return 0
case sidebarCollapsed:
return 8
default:
return 18
}
}
func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarHidden {
return ""
}
s := style.S
// content width inside bordered panel
inner := m.getSidebarWidth() - 2
titleText := "SPILLTEA"
if m.sidebarState == sidebarCollapsed {
titleText = "SPLT"
}
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
divider := strings.Repeat("─", inner)
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
textNormal := lipgloss.NewStyle().Foreground(s.Text)
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
for i, entry := range sidebarEntries {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
badgeStyle, textStyle = badgeSelected, textSelected
}
icon := ""
if entry.icon != nil {
icon = entry.icon()
}
label := " " + icon
if m.sidebarState != sidebarCollapsed {
label += string(entry.id)
}
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
items.WriteString(line + "\n")
}
body := lipgloss.JoinVertical(lipgloss.Left,
title,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
items.String(),
)
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
}
+238
View File
@@ -0,0 +1,238 @@
package app
import (
"log"
"os"
"os/exec"
"path/filepath"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Broker messages must always re-register their watchers
switch msg := msg.(type) {
case notificationsUI.NotificationMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case notificationsUI.DismissMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case intercept.RequestArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
case intercept.ResponseArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForResponse(m.broker))
case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager)
notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: msg.Title,
Body: msg.Body,
Kind: notificationsUI.KindInfo,
}
}
return m, tea.Batch(cmd, notifCmd)
case plugins.PluginQuitMsg:
log.Printf("plugin quit: %s", msg.Reason)
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if m.copyAs.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copyAs.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copyAs, cmd = m.copyAs.Update(msg)
return m, cmd
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeChildren()
case scopeUI.ScopeChangedMsg:
m.broker.SetScope(msg.Whitelist, msg.Blacklist)
if m.database != nil {
if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil {
log.Printf("failed to persist scope: %v", err)
}
}
return m, nil
case proxyPkg.ErrMsg:
if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err)
}
return m, nil
case tickMsg:
var cmds []tea.Cmd
cmds = append(cmds, tickCmd())
if m.page == pageHistory {
cmds = append(cmds, m.history.RefreshCmd())
}
cmds = append(cmds, findingsUI.RefreshCmd(m.database))
return m, tea.Batch(cmds...)
case findingsUI.FindingsLoadedMsg:
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return m, cmd
case replayUI.SendToReplayMsg:
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
if config.Global.Replay.SwitchToPageOnSend {
m.page = pageReplay
m.resizeChildren()
} else {
return m, tea.Batch(cmd, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Replay",
Body: "Request queued in replay",
Kind: notificationsUI.KindInfo,
}
})
}
return m, cmd
case diffUI.SendToDiffMsg:
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return m, cmd
case diffUI.DiffReadyMsg:
m.page = pageDiff
m.resizeChildren()
return m, nil
case historyUI.EntriesLoadedMsg:
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return m, cmd
case tea.KeyPressMsg:
// ctrl+c always quits, even when a textarea is focused.
if msg.String() == "ctrl+c" {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
}
if !m.activeIsEditing() {
switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest):
if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: "https",
})
}
} else if m.page == pageIntercept {
if raw := m.intercept.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: m.intercept.CurrentScheme(),
})
}
}
return m, nil
case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState()
m.resizeChildren()
default:
if p, ok := pageShortcuts[msg.String()]; ok {
prev := m.page
m.page = p
if p == pageHistory && prev != pageHistory {
return m, m.history.RefreshCmd()
}
if p == pageFindings {
return m, findingsUI.RefreshCmd(m.database)
}
}
}
}
}
var cmd tea.Cmd
m, cmd = m.updateActivePage(msg)
return m, cmd
}
func (m Model) activeIsEditing() bool {
for _, e := range pageRegistry {
if e.id == m.page && e.isEditing != nil {
return e.isEditing(&m)
}
}
return false
}
func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) {
for _, e := range pageRegistry {
if e.id == m.page && e.update != nil {
cmd := e.update(&m, msg)
return m, cmd
}
}
return m, nil
}
func (m *Model) resizeChildren() {
sidebarW := m.getSidebarWidth()
h := m.height
for _, e := range pageRegistry {
if e.resize == nil {
continue
}
e.resize(m, m.width-sidebarW, h)
}
m.notifications.SetSize(m.width, m.height)
}
+49
View File
@@ -0,0 +1,49 @@
package app
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
v := tea.NewView("")
v.AltScreen = true
return v
}
normal := m.renderNormal()
if m.copyAs.IsOpen() {
v := tea.NewView(m.copyAs.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal
if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal)
}
v := tea.NewView(rendered)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNormal() string {
sidebar := m.renderSidebar()
content := m.renderActivePage()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
}
func (m *Model) renderActivePage() string {
for _, e := range pageRegistry {
if e.id == m.page && e.render != nil {
return e.render(m)
}
}
return style.S.Faint.Render("Work in progress")
}