diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index d0a7f80..d14966d 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -51,7 +51,8 @@ keybindings: left: "left,h" right: "right,l" cycle_focus: "tab" - copy_request: "ctrl+y" + copy_as: "ctrl+y" + copy: "y" send_to_replay: "ctrl+r" scroll_up: "pgup" scroll_down: "pgdown" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 796312a..de05aea 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -10,7 +10,8 @@ type GlobalKeys struct { Left string `mapstructure:"left"` Right string `mapstructure:"right"` 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"` ScrollUp string `mapstructure:"scroll_up"` ScrollDown string `mapstructure:"scroll_down"` diff --git a/internal/keys/global.go b/internal/keys/global.go index a20b96c..b7ce6f0 100644 --- a/internal/keys/global.go +++ b/internal/keys/global.go @@ -15,7 +15,8 @@ type GlobalKeyMap struct { Left key.Binding Right key.Binding CycleFocus key.Binding - CopyRequest key.Binding + CopyAs key.Binding + Copy key.Binding Escape key.Binding SendToReplay key.Binding ScrollUp key.Binding @@ -34,7 +35,8 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { Left: binding(cfg.Left, "scroll left"), Right: binding(cfg.Right, "scroll right"), 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")), SendToReplay: binding(cfg.SendToReplay, "send to replay"), ScrollUp: binding(cfg.ScrollUp, "scroll up"), @@ -47,7 +49,7 @@ func (g GlobalKeyMap) Bindings() []key.Binding { return []key.Binding{ g.Up, g.Down, g.Left, g.Right, g.CycleFocus, 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.ScrollUp, g.ScrollDown, } diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 4c4bcf2..1751d19 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -13,6 +13,7 @@ import ( "github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/plugins" 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" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" @@ -66,6 +67,7 @@ type Model struct { pluginsPage pluginsUI.Model findingsPage findingsUI.Model copyAs copyasUI.Model + copy copyUI.Model notifications notificationsUI.Model } @@ -87,6 +89,7 @@ func New(broker *intercept.Broker, name, path string) Model { pluginsPage: pluginsUI.New(mgr), findingsPage: findingsUI.New(), copyAs: copyasUI.New(), + copy: copyUI.New(), notifications: notificationsUI.New(), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), } diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index f96e993..d98b934 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -13,6 +13,7 @@ import ( "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/plugins" 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" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" 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 } + 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) { case tea.WindowSizeMsg: m.width = msg.Width @@ -161,7 +175,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.activeIsEditing() { switch { - case key.Matches(msg, keys.Keys.Global.CopyRequest): + case key.Matches(msg, keys.Keys.Global.CopyAs): if m.page == pageDiff { if raw := m.diff.CurrentRaw(); raw != "" { 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 + 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): m.cycleSidebarState() m.resizeChildren() diff --git a/internal/ui/app/view.go b/internal/ui/app/view.go index f8fa9e2..48a44cd 100644 --- a/internal/ui/app/view.go +++ b/internal/ui/app/view.go @@ -22,6 +22,13 @@ func (m Model) View() tea.View { return v } + if m.copy.IsOpen() { + v := tea.NewView(m.copy.View(normal)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v + } + rendered := normal if m.notifications.HasNotifications() { rendered = m.notifications.View(normal) diff --git a/internal/ui/components/copy/model.go b/internal/ui/components/copy/model.go new file mode 100644 index 0000000..519e18e --- /dev/null +++ b/internal/ui/components/copy/model.go @@ -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 +} diff --git a/internal/ui/components/copy/update.go b/internal/ui/components/copy/update.go new file mode 100644 index 0000000..c7fa952 --- /dev/null +++ b/internal/ui/components/copy/update.go @@ -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 +} diff --git a/internal/ui/components/copy/view.go b/internal/ui/components/copy/view.go new file mode 100644 index 0000000..8781bc6 --- /dev/null +++ b/internal/ui/components/copy/view.go @@ -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) +} diff --git a/internal/ui/components/copyas/formats.go b/internal/ui/components/copyas/formats.go index 490996c..fd608ce 100644 --- a/internal/ui/components/copyas/formats.go +++ b/internal/ui/components/copyas/formats.go @@ -66,6 +66,8 @@ func (pr parsedRequest) fullURL() string { func formatAs(id, raw, scheme string) string { pr := parseRaw(raw, scheme) switch id { + case "raw": + return raw case "curl": return toCurl(pr) case "python": diff --git a/internal/ui/components/copyas/model.go b/internal/ui/components/copyas/model.go index 583942a..0d9cc94 100644 --- a/internal/ui/components/copyas/model.go +++ b/internal/ui/components/copyas/model.go @@ -36,6 +36,7 @@ func (f formatItem) Description() string { return f.desc } func (f formatItem) FilterValue() string { return f.title } var allFormats = []list.Item{ + formatItem{"raw", "Raw", "raw HTTP request"}, formatItem{"curl", "cURL", "command line HTTP request"}, formatItem{"python", "Python", "requests library"}, formatItem{"go", "Go", "net/http package"}, diff --git a/internal/ui/components/copyas/view.go b/internal/ui/components/copyas/view.go index 702dd48..ad13ab3 100644 --- a/internal/ui/components/copyas/view.go +++ b/internal/ui/components/copyas/view.go @@ -26,10 +26,10 @@ func (m *Model) View(background string) string { popupH := m.popupHeight() 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 stripped := ansi.Strip(bg) diff --git a/internal/ui/history/model.go b/internal/ui/history/model.go index 8b6252c..fb924ea 100644 --- a/internal/ui/history/model.go +++ b/internal/ui/history/model.go @@ -55,6 +55,15 @@ func (m Model) IsEditing() bool { 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. // The app model should call this instead of LoadEntriesCmd directly so that // background refreshes re-run the active search rather than resetting it. diff --git a/internal/ui/plugins/view.go b/internal/ui/plugins/view.go index 63895ec..364cf54 100644 --- a/internal/ui/plugins/view.go +++ b/internal/ui/plugins/view.go @@ -50,7 +50,7 @@ func (m *Model) renderDetailPanel(h int) string { } info, ok := m.selected() 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) @@ -84,7 +84,7 @@ func (m *Model) renderDetailPanel(h int) string { } 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 { diff --git a/internal/ui/replay/model.go b/internal/ui/replay/model.go index 895364d..21cbaef 100644 --- a/internal/ui/replay/model.go +++ b/internal/ui/replay/model.go @@ -68,6 +68,23 @@ func (m Model) Init() tea.Cmd { return nil } 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) { m.database = d if d == nil {