diff --git a/internal/db/findings.go b/internal/db/findings.go index bf04ff1..8247e41 100644 --- a/internal/db/findings.go +++ b/internal/db/findings.go @@ -17,6 +17,8 @@ type Finding struct { // UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does // not already exist. Returns true when the row was actually inserted. func (d *DB) UpsertFinding(f Finding) (bool, error) { + d.dedupMu.Lock() + defer d.dedupMu.Unlock() res, err := d.conn.Exec( `INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)`, diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 6570d60..f75cee8 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -187,21 +187,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Keys.Global.CopyAs): var raw, scheme string + var responseFocused bool switch m.page { - case pageDiff: - raw = m.diff.CurrentRaw() - scheme = "https" case pageIntercept: raw = m.intercept.CurrentRaw() scheme = m.intercept.CurrentScheme() + responseFocused = m.intercept.IsResponseFocused() case pageHistory: raw = m.history.CurrentRaw() scheme = m.history.CurrentScheme() + responseFocused = m.history.IsResponseFocused() case pageReplay: raw = m.replay.CurrentRaw() scheme = m.replay.CurrentScheme() + responseFocused = m.replay.IsResponseFocused() } - if raw != "" { + if raw != "" && !responseFocused { m.copyAs.SetSize(m.width, m.height) m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme}) } @@ -209,23 +210,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Keys.Global.Copy): var raw, scheme string + var responseFocused bool switch m.page { case pageIntercept: raw = m.intercept.CurrentRaw() scheme = m.intercept.CurrentScheme() - case pageDiff: - raw = m.diff.CurrentRaw() - scheme = "https" + responseFocused = m.intercept.IsResponseFocused() case pageHistory: raw = m.history.CurrentRaw() scheme = m.history.CurrentScheme() + responseFocused = m.history.IsResponseFocused() case pageReplay: raw = m.replay.CurrentRaw() scheme = m.replay.CurrentScheme() + responseFocused = m.replay.IsResponseFocused() } if raw != "" { m.copy.SetSize(m.width, m.height) - m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme}) + m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused}) } return m, nil diff --git a/internal/ui/components/copy/model.go b/internal/ui/components/copy/model.go index 39eeaf8..48e57ba 100644 --- a/internal/ui/components/copy/model.go +++ b/internal/ui/components/copy/model.go @@ -25,6 +25,7 @@ func writeClipboard(text string) { type OpenMsg struct { RawRequest string Scheme string + ShowURL bool } type copyItem struct { @@ -90,6 +91,17 @@ func (m *Model) Open(msg OpenMsg) { m.rawRequest = msg.RawRequest m.scheme = msg.Scheme m.open = true + items := allItems + if !msg.ShowURL { + filtered := make([]list.Item, 0, len(allItems)) + for _, it := range allItems { + if it.(copyItem).id != "url" { + filtered = append(filtered, it) + } + } + items = filtered + } + m.list.SetItems(items) m.list.ResetFilter() m.list.Select(0) m.list.SetSize(m.popupInnerWidth(), m.listHeight()) diff --git a/internal/ui/diff/model.go b/internal/ui/diff/model.go index 5ad3590..a684fa2 100644 --- a/internal/ui/diff/model.go +++ b/internal/ui/diff/model.go @@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding { func (m diffKeyMap) FullHelp() [][]key.Binding { g := keys.Keys.Global - pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs} + pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right} all := append(keys.Keys.Diff.Bindings(), pageGlobals...) all = append(all, g.CommonBindings()...) return keys.ChunkByWidth(all, m.width) diff --git a/internal/ui/findings/model.go b/internal/ui/findings/model.go index 8ea778e..fe96889 100644 --- a/internal/ui/findings/model.go +++ b/internal/ui/findings/model.go @@ -113,6 +113,14 @@ type FindingsLoadedMsg struct { } func (m *Model) refreshBody() { + m.refreshBodyScroll(true) +} + +func (m *Model) refreshBodyKeepScroll() { + m.refreshBodyScroll(false) +} + +func (m *Model) refreshBodyScroll(reset bool) { if len(m.findings) == 0 { m.bodyViewport.SetContent("") return @@ -120,7 +128,9 @@ func (m *Model) refreshBody() { f := m.findings[m.cursor] rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width()) m.bodyViewport.SetContent(rendered) - m.bodyViewport.GotoTop() + if reset { + m.bodyViewport.GotoTop() + } } func (m *Model) renderMarkdownCached(src string, width int) string { diff --git a/internal/ui/findings/update.go b/internal/ui/findings/update.go index 74471b2..84aaff9 100644 --- a/internal/ui/findings/update.go +++ b/internal/ui/findings/update.go @@ -15,6 +15,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("findings load error: %v", msg.Err) return m, nil } + var prevID int64 + if len(m.findings) > 0 && m.cursor < len(m.findings) { + prevID = m.findings[m.cursor].ID + } m.findings = msg.Findings if m.cursor >= len(m.findings) { m.cursor = max(0, len(m.findings)-1) @@ -26,7 +30,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pager.SetTotalPages(len(m.findings)) } m.refreshListViewport() - m.refreshBody() + var newID int64 + if len(m.findings) > 0 && m.cursor < len(m.findings) { + newID = m.findings[m.cursor].ID + } + if newID != prevID { + m.refreshBody() + } else { + m.refreshBodyKeepScroll() + } return m, nil case tea.MouseWheelMsg: diff --git a/internal/ui/history/model.go b/internal/ui/history/model.go index 58e68f3..d32088d 100644 --- a/internal/ui/history/model.go +++ b/internal/ui/history/model.go @@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string { if len(m.entries) == 0 || m.cursor >= len(m.entries) { return "" } + if m.focusedPanel == panelResponse { + return m.entries[m.cursor].ResponseRaw + } return m.entries[m.cursor].RequestRaw } +func (m Model) IsResponseFocused() bool { + return m.focusedPanel == panelResponse +} + func (m Model) CurrentScheme() string { if len(m.entries) == 0 || m.cursor >= len(m.entries) { return "https" diff --git a/internal/ui/intercept/model.go b/internal/ui/intercept/model.go index 22e26a3..5cde5fd 100644 --- a/internal/ui/intercept/model.go +++ b/internal/ui/intercept/model.go @@ -78,6 +78,10 @@ func (m Model) Init() tea.Cmd { return nil } func (m Model) IsEditing() bool { return m.editing } +func (m Model) IsResponseFocused() bool { + return m.captureResponse && m.focusedPanel == panelResponses +} + func (m Model) CurrentScheme() string { if len(m.queue) == 0 { return "https" diff --git a/internal/ui/replay/model.go b/internal/ui/replay/model.go index 62ec9a8..a413dfd 100644 --- a/internal/ui/replay/model.go +++ b/internal/ui/replay/model.go @@ -34,11 +34,20 @@ type Entry struct { Err error } +type panel int + +const ( + panelList panel = iota + panelRequest + panelResponse +) + type Model struct { - entries []Entry - cursor int - editing bool - database *db.DB + entries []Entry + cursor int + editing bool + focusedPanel panel + database *db.DB listViewport viewport.Model requestViewport viewport.Model @@ -68,10 +77,17 @@ func (m Model) Init() tea.Cmd { return nil } func (m Model) IsEditing() bool { return m.editing } +func (m Model) IsResponseFocused() bool { + return m.focusedPanel == panelResponse +} + func (m Model) CurrentRaw() string { if len(m.entries) == 0 || m.cursor >= len(m.entries) { return "" } + if m.focusedPanel == panelResponse { + return m.entries[m.cursor].ResponseRaw + } return m.entries[m.cursor].RequestRaw } @@ -183,12 +199,12 @@ 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} + return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help} } func (m replayKeyMap) FullHelp() [][]key.Binding { g := keys.Keys.Global - pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs} + pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs} all := append(keys.Keys.Replay.Bindings(), pageGlobals...) all = append(all, g.CommonBindings()...) return keys.ChunkByWidth(all, m.width) diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go index b4b60e2..b7f91aa 100644 --- a/internal/ui/replay/update.go +++ b/internal/ui/replay/update.go @@ -1,6 +1,9 @@ package replay import ( + "bytes" + "compress/gzip" + "compress/zlib" "crypto/tls" "fmt" "io" @@ -9,8 +12,11 @@ import ( "time" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" "github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/keys" @@ -91,14 +97,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.requestViewport.ScrollLeft(6) m.responseViewport.ScrollLeft(6) } else { - m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1) + m.scrollFocusedViewportVertical(-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) + m.scrollFocusedViewportVertical(1) } case tea.MouseWheelLeft: m.requestViewport.ScrollLeft(6) @@ -124,17 +130,35 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { r := keys.Keys.Replay switch { case key.Matches(msg, g.Up): - if m.cursor > 0 { - m.cursor-- - m.refreshListViewport() - m.refreshBody() + if m.focusedPanel == panelList { + if m.cursor > 0 { + m.cursor-- + m.refreshListViewport() + m.refreshBody() + } + } else { + m.scrollFocusedViewportVertical(-1) } case key.Matches(msg, g.Down): - if m.cursor < len(m.entries)-1 { - m.cursor++ - m.refreshListViewport() - m.refreshBody() + if m.focusedPanel == panelList { + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.refreshListViewport() + m.refreshBody() + } + } else { + m.scrollFocusedViewportVertical(1) + } + + case key.Matches(msg, g.CycleFocus): + switch m.focusedPanel { + case panelList: + m.focusedPanel = panelRequest + case panelRequest: + m.focusedPanel = panelResponse + default: + m.focusedPanel = panelList } case key.Matches(msg, r.Send): @@ -166,18 +190,22 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, g.ScrollUp): - step := m.responseViewport.Height() / 2 + vp := m.focusedViewport() + step := vp.Height() / 2 if step < 1 { step = 1 } - m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step) + vp.SetYOffset(vp.YOffset() - step) + m.setFocusedViewport(vp) case key.Matches(msg, g.ScrollDown): - step := m.responseViewport.Height() / 2 + vp := m.focusedViewport() + step := vp.Height() / 2 if step < 1 { step = 1 } - m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step) + vp.SetYOffset(vp.YOffset() + step) + m.setFocusedViewport(vp) case key.Matches(msg, g.Left): m.requestViewport.ScrollLeft(6) @@ -280,6 +308,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +// focusedViewport returns the viewport that should receive scroll events. +// When the list is focused, scroll targets the request panel. +func (m *Model) focusedViewport() viewport.Model { + if m.focusedPanel == panelResponse { + return m.responseViewport + } + return m.requestViewport +} + +func (m *Model) setFocusedViewport(vp viewport.Model) { + if m.focusedPanel == panelResponse { + m.responseViewport = vp + } else { + m.requestViewport = vp + } +} + +func (m *Model) scrollFocusedViewportVertical(delta int) { + vp := m.focusedViewport() + vp.SetYOffset(vp.YOffset() + delta) + m.setFocusedViewport(vp) +} + func (m *Model) refreshListViewport() { if m.pager.PerPage > 0 { if len(m.entries) == 0 { @@ -369,6 +420,14 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) { limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit)) + if enc := resp.Header.Get("Content-Encoding"); enc != "" { + if decoded, decErr := decodeBody(enc, respBody); decErr == nil { + respBody = decoded + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") + } + } + var sb strings.Builder fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) for _, line := range util.SortedHeaderLines(resp.Header) { @@ -380,6 +439,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) { return sb.String(), resp.StatusCode, nil } +func decodeBody(encoding string, body []byte) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "gzip": + r, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + case "br": + return io.ReadAll(brotli.NewReader(bytes.NewReader(body))) + case "deflate": + r, err := zlib.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + case "zstd": + r, err := zstd.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + } + return nil, fmt.Errorf("unsupported encoding: %s", encoding) +} + func entryToDB(e Entry) db.ReplayEntry { errMsg := "" if e.Err != nil { diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go index 8dddab7..3fba86a 100644 --- a/internal/ui/replay/view.go +++ b/internal/ui/replay/view.go @@ -34,9 +34,9 @@ func (m Model) View() tea.View { func (m *Model) renderListPanel(w, h int) string { s := style.S - panelStyle := s.PanelFocused - if m.editing { - panelStyle = s.Panel + panelStyle := s.Panel + if !m.editing && m.focusedPanel == panelList { + panelStyle = s.PanelFocused } var dots string if len(m.entries) > 0 { @@ -58,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string { border = s.PanelFocused } else { body = m.requestViewport.View() + if m.focusedPanel == panelRequest { + border = s.PanelFocused + } } 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) + border := s.Panel + if !m.editing && m.focusedPanel == panelResponse { + border = s.PanelFocused + } + return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h) } func (m *Model) renderStatusBar() string {