CopyRequest -> Copy & CopyAs

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-13 18:07:23 +02:00
parent de254b4e52
commit c7392474b7
15 changed files with 312 additions and 10 deletions
+2 -1
View File
@@ -51,7 +51,8 @@ keybindings:
left: "left,h" left: "left,h"
right: "right,l" right: "right,l"
cycle_focus: "tab" cycle_focus: "tab"
copy_request: "ctrl+y" copy_as: "ctrl+y"
copy: "y"
send_to_replay: "ctrl+r" send_to_replay: "ctrl+r"
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
+2 -1
View File
@@ -10,7 +10,8 @@ type GlobalKeys struct {
Left string `mapstructure:"left"` Left string `mapstructure:"left"`
Right string `mapstructure:"right"` Right string `mapstructure:"right"`
CycleFocus string `mapstructure:"cycle_focus"` CycleFocus string `mapstructure:"cycle_focus"`
CopyRequest string `mapstructure:"copy_request"` CopyAs string `mapstructure:"copy_as"`
Copy string `mapstructure:"copy"`
SendToReplay string `mapstructure:"send_to_replay"` SendToReplay string `mapstructure:"send_to_replay"`
ScrollUp string `mapstructure:"scroll_up"` ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"` ScrollDown string `mapstructure:"scroll_down"`
+5 -3
View File
@@ -15,7 +15,8 @@ type GlobalKeyMap struct {
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
CycleFocus key.Binding CycleFocus key.Binding
CopyRequest key.Binding CopyAs key.Binding
Copy key.Binding
Escape key.Binding Escape key.Binding
SendToReplay key.Binding SendToReplay key.Binding
ScrollUp key.Binding ScrollUp key.Binding
@@ -34,7 +35,8 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
Left: binding(cfg.Left, "scroll left"), Left: binding(cfg.Left, "scroll left"),
Right: binding(cfg.Right, "scroll right"), Right: binding(cfg.Right, "scroll right"),
CycleFocus: binding(cfg.CycleFocus, "cycle focus"), CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyRequest: binding(cfg.CopyRequest, "copy as..."), CopyAs: binding(cfg.CopyAs, "copy as..."),
Copy: binding(cfg.Copy, "copy..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
SendToReplay: binding(cfg.SendToReplay, "send to replay"), SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
@@ -47,7 +49,7 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
return []key.Binding{ return []key.Binding{
g.Up, g.Down, g.Left, g.Right, g.CycleFocus, g.Up, g.Down, g.Left, g.Right, g.CycleFocus,
g.Quit, g.Escape, g.Help, g.Quit, g.Escape, g.Help,
g.OpenLogs, g.ToggleSidebar, g.CopyRequest, g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
g.SendToReplay, g.SendToDiff, g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown, g.ScrollUp, g.ScrollDown,
} }
+3
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -66,6 +67,7 @@ type Model struct {
pluginsPage pluginsUI.Model pluginsPage pluginsUI.Model
findingsPage findingsUI.Model findingsPage findingsUI.Model
copyAs copyasUI.Model copyAs copyasUI.Model
copy copyUI.Model
notifications notificationsUI.Model notifications notificationsUI.Model
} }
@@ -87,6 +89,7 @@ func New(broker *intercept.Broker, name, path string) Model {
pluginsPage: pluginsUI.New(mgr), pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(), findingsPage: findingsUI.New(),
copyAs: copyasUI.New(), copyAs: copyasUI.New(),
copy: copyUI.New(),
notifications: notificationsUI.New(), notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
} }
+37 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -81,6 +82,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
if m.copy.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copy.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copy, cmd = m.copy.Update(msg)
return m, cmd
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@@ -161,7 +175,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.activeIsEditing() { if !m.activeIsEditing() {
switch { switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest): case key.Matches(msg, keys.Keys.Global.CopyAs):
if m.page == pageDiff { if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" { if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height) m.copyAs.SetSize(m.width, m.height)
@@ -181,6 +195,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
}
if raw != "" {
m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
}
return m, nil
case key.Matches(msg, keys.Keys.Global.ToggleSidebar): case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState() m.cycleSidebarState()
m.resizeChildren() m.resizeChildren()
+7
View File
@@ -22,6 +22,13 @@ func (m Model) View() tea.View {
return v return v
} }
if m.copy.IsOpen() {
v := tea.NewView(m.copy.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal rendered := normal
if m.notifications.HasNotifications() { if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal) rendered = m.notifications.View(normal)
+165
View File
@@ -0,0 +1,165 @@
package copy
import (
"encoding/base64"
"fmt"
"os"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
const popupInnerW = 40
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 copyItem struct {
id string
title string
desc string
}
func (c copyItem) Title() string { return c.title }
func (c copyItem) Description() string { return c.desc }
func (c copyItem) FilterValue() string { return c.title }
var allItems = []list.Item{
copyItem{"raw", "Raw", "full HTTP request"},
copyItem{"headers", "Headers", "request headers only"},
copyItem{"body", "Body", "request body only"},
copyItem{"url", "URL", "request URL"},
}
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(allItems, 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 := 12
if m.height > 0 && m.height-4 < h {
h = m.height - 4
}
if h < 6 {
h = 6
}
return h
}
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
}
func (m Model) extract(id string) string {
raw := m.rawRequest
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
switch id {
case "raw":
return raw
case "headers":
var sb strings.Builder
for _, l := range lines[1:] {
if l == "" {
break
}
sb.WriteString(l + "\n")
}
return strings.TrimRight(sb.String(), "\n")
case "body":
for i, l := range lines {
if l == "" && i > 0 {
return strings.TrimRight(strings.Join(lines[i+1:], "\n"), "\n")
}
}
return ""
case "url":
scheme := m.scheme
if scheme == "" {
scheme = "https"
}
var host, path string
if len(lines) > 0 {
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 2 {
path = parts[1]
}
}
for _, l := range lines[1:] {
if l == "" {
break
}
if kv := strings.SplitN(l, ": ", 2); len(kv) == 2 && strings.EqualFold(kv[0], "host") {
host = strings.TrimSpace(kv[1])
}
}
return scheme + "://" + host + path
}
return raw
}
+30
View File
@@ -0,0 +1,30 @@
package copy
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().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
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
}
+28
View File
@@ -0,0 +1,28 @@
package copy
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
)
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", inner, popupInnerW+2, popupH)
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
}
+2
View File
@@ -66,6 +66,8 @@ func (pr parsedRequest) fullURL() string {
func formatAs(id, raw, scheme string) string { func formatAs(id, raw, scheme string) string {
pr := parseRaw(raw, scheme) pr := parseRaw(raw, scheme)
switch id { switch id {
case "raw":
return raw
case "curl": case "curl":
return toCurl(pr) return toCurl(pr)
case "python": case "python":
+1
View File
@@ -36,6 +36,7 @@ func (f formatItem) Description() string { return f.desc }
func (f formatItem) FilterValue() string { return f.title } func (f formatItem) FilterValue() string { return f.title }
var allFormats = []list.Item{ var allFormats = []list.Item{
formatItem{"raw", "Raw", "raw HTTP request"},
formatItem{"curl", "cURL", "command line HTTP request"}, formatItem{"curl", "cURL", "command line HTTP request"},
formatItem{"python", "Python", "requests library"}, formatItem{"python", "Python", "requests library"},
formatItem{"go", "Go", "net/http package"}, formatItem{"go", "Go", "net/http package"},
+2 -2
View File
@@ -26,10 +26,10 @@ func (m *Model) View(background string) string {
popupH := m.popupHeight() popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH) popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH)
return overlayCenter(background, popup, m.width, m.height) return OverlayCenter(background, popup, m.width, m.height)
} }
func overlayCenter(bg, popup string, w, h int) string { func OverlayCenter(bg, popup string, w, h int) string {
s := style.S s := style.S
stripped := ansi.Strip(bg) stripped := ansi.Strip(bg)
+9
View File
@@ -55,6 +55,15 @@ func (m Model) IsEditing() bool {
return m.searchKind != searchKindOff && !m.searchAccepted return m.searchKind != searchKindOff && !m.searchAccepted
} }
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string { return "https" }
// RefreshCmd returns the appropriate load command given the current search state. // RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that // The app model should call this instead of LoadEntriesCmd directly so that
// background refreshes re-run the active search rather than resetting it. // background refreshes re-run the active search rather than resetting it.
+2 -2
View File
@@ -50,7 +50,7 @@ func (m *Model) renderDetailPanel(h int) string {
} }
info, ok := m.selected() info, ok := m.selected()
if !ok { if !ok {
return style.RenderWithTitle(panelStyle, "Detail", "", m.width, h) return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
} }
statusSt := lipgloss.NewStyle().Foreground(s.Error) statusSt := lipgloss.NewStyle().Foreground(s.Error)
@@ -84,7 +84,7 @@ func (m *Model) renderDetailPanel(h int) string {
} }
inner := lipgloss.JoinVertical(lipgloss.Left, parts...) inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
return style.RenderWithTitle(panelStyle, "Detail", inner, m.width, h) return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
} }
func renderPluginDescription(desc string, width int) string { func renderPluginDescription(desc string, width int) string {
+17
View File
@@ -68,6 +68,23 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
}
if s := m.entries[m.cursor].Scheme; s != "" {
return s
}
return "https"
}
func (m *Model) SetDB(d *db.DB) { func (m *Model) SetDB(d *db.DB) {
m.database = d m.database = d
if d == nil { if d == nil {