Fix scroll & copy buttons

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-19 20:25:50 +02:00
parent 69d5d0ffec
commit 2c3e19258f
11 changed files with 195 additions and 35 deletions
+2
View File
@@ -17,6 +17,8 @@ type Finding struct {
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does // UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted. // not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) { func (d *DB) UpsertFinding(f Finding) (bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at) `INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`, VALUES (?, ?, ?, ?, ?, 0, ?)`,
+10 -8
View File
@@ -187,21 +187,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, keys.Keys.Global.CopyAs): case key.Matches(msg, keys.Keys.Global.CopyAs):
var raw, scheme string var raw, scheme string
var responseFocused bool
switch m.page { switch m.page {
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageIntercept: case pageIntercept:
raw = m.intercept.CurrentRaw() raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme() scheme = m.intercept.CurrentScheme()
responseFocused = m.intercept.IsResponseFocused()
case pageHistory: case pageHistory:
raw = m.history.CurrentRaw() raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme() scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay: case pageReplay:
raw = m.replay.CurrentRaw() raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme() scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
} }
if raw != "" { if raw != "" && !responseFocused {
m.copyAs.SetSize(m.width, m.height) m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme}) 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): case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string var raw, scheme string
var responseFocused bool
switch m.page { switch m.page {
case pageIntercept: case pageIntercept:
raw = m.intercept.CurrentRaw() raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme() scheme = m.intercept.CurrentScheme()
case pageDiff: responseFocused = m.intercept.IsResponseFocused()
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory: case pageHistory:
raw = m.history.CurrentRaw() raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme() scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay: case pageReplay:
raw = m.replay.CurrentRaw() raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme() scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
} }
if raw != "" { if raw != "" {
m.copy.SetSize(m.width, m.height) 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 return m, nil
+12
View File
@@ -25,6 +25,7 @@ func writeClipboard(text string) {
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
Scheme string Scheme string
ShowURL bool
} }
type copyItem struct { type copyItem struct {
@@ -90,6 +91,17 @@ func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme m.scheme = msg.Scheme
m.open = true 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.ResetFilter()
m.list.Select(0) m.list.Select(0)
m.list.SetSize(m.popupInnerWidth(), m.listHeight()) m.list.SetSize(m.popupInnerWidth(), m.listHeight())
+1 -1
View File
@@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding {
func (m diffKeyMap) FullHelp() [][]key.Binding { func (m diffKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global 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(keys.Keys.Diff.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+10
View File
@@ -113,6 +113,14 @@ type FindingsLoadedMsg struct {
} }
func (m *Model) refreshBody() { 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 { if len(m.findings) == 0 {
m.bodyViewport.SetContent("") m.bodyViewport.SetContent("")
return return
@@ -120,8 +128,10 @@ func (m *Model) refreshBody() {
f := m.findings[m.cursor] f := m.findings[m.cursor]
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width()) rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered) m.bodyViewport.SetContent(rendered)
if reset {
m.bodyViewport.GotoTop() m.bodyViewport.GotoTop()
} }
}
func (m *Model) renderMarkdownCached(src string, width int) string { func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" { if src == "" {
+12
View File
@@ -15,6 +15,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("findings load error: %v", msg.Err) log.Printf("findings load error: %v", msg.Err)
return m, nil 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 m.findings = msg.Findings
if m.cursor >= len(m.findings) { if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1) 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.pager.SetTotalPages(len(m.findings))
} }
m.refreshListViewport() m.refreshListViewport()
var newID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
newID = m.findings[m.cursor].ID
}
if newID != prevID {
m.refreshBody() m.refreshBody()
} else {
m.refreshBodyKeepScroll()
}
return m, nil return m, nil
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
+7
View File
@@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentScheme() string { func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https" return "https"
+4
View File
@@ -78,6 +78,10 @@ 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) IsResponseFocused() bool {
return m.captureResponse && m.focusedPanel == panelResponses
}
func (m Model) CurrentScheme() string { func (m Model) CurrentScheme() string {
if len(m.queue) == 0 { if len(m.queue) == 0 {
return "https" return "https"
+18 -2
View File
@@ -34,10 +34,19 @@ type Entry struct {
Err error Err error
} }
type panel int
const (
panelList panel = iota
panelRequest
panelResponse
)
type Model struct { type Model struct {
entries []Entry entries []Entry
cursor int cursor int
editing bool editing bool
focusedPanel panel
database *db.DB database *db.DB
listViewport viewport.Model listViewport 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) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentRaw() string { func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
@@ -183,12 +199,12 @@ type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding { func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay 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 { func (m replayKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global 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(keys.Keys.Replay.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+94 -6
View File
@@ -1,6 +1,9 @@
package replay package replay
import ( import (
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
@@ -9,8 +12,11 @@ import (
"time" "time"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/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/config"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "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.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6) m.responseViewport.ScrollLeft(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1) m.scrollFocusedViewportVertical(-1)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) { if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6) m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6) m.responseViewport.ScrollRight(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1) m.scrollFocusedViewportVertical(1)
} }
case tea.MouseWheelLeft: case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -124,18 +130,36 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
r := keys.Keys.Replay r := keys.Keys.Replay
switch { switch {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.focusedPanel == panelList {
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
} else {
m.scrollFocusedViewportVertical(-1)
}
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.focusedPanel == panelList {
if m.cursor < len(m.entries)-1 { if m.cursor < len(m.entries)-1 {
m.cursor++ m.cursor++
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() 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): case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending { if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
@@ -166,18 +190,22 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
step := vp.Height() / 2
if step < 1 { if step < 1 {
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): case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
step := vp.Height() / 2
if step < 1 { if step < 1 {
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): case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -280,6 +308,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil 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() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.entries) == 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 limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit)) 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 var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
for _, line := range util.SortedHeaderLines(resp.Header) { 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 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 { func entryToDB(e Entry) db.ReplayEntry {
errMsg := "" errMsg := ""
if e.Err != nil { if e.Err != nil {
+11 -4
View File
@@ -34,9 +34,9 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused panelStyle := s.Panel
if m.editing { if !m.editing && m.focusedPanel == panelList {
panelStyle = s.Panel panelStyle = s.PanelFocused
} }
var dots string var dots string
if len(m.entries) > 0 { if len(m.entries) > 0 {
@@ -58,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string {
border = s.PanelFocused border = s.PanelFocused
} else { } else {
body = m.requestViewport.View() body = m.requestViewport.View()
if m.focusedPanel == panelRequest {
border = s.PanelFocused
}
} }
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h) return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
} }
func (m *Model) renderResponsePanel(w, h int) string { func (m *Model) renderResponsePanel(w, h int) string {
s := style.S 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 { func (m *Model) renderStatusBar() string {