diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index cdbe9fe..06b0657 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -83,12 +83,15 @@ keybindings: delete_entry: "x" delete_all: "X" filter: "/" + flag: "m" diff: clear: "x" findings: dismiss: "x" + dismiss_all: "X" + flag: "m" plugins: toggle: "space" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 03fd8ad..2bd4f5e 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -57,6 +57,7 @@ type ReplayKeys struct { Delete string `mapstructure:"delete_entry"` DeleteAll string `mapstructure:"delete_all"` Filter string `mapstructure:"filter"` + Flag string `mapstructure:"flag"` } type DiffKeys struct { @@ -64,7 +65,9 @@ type DiffKeys struct { } type FindingsKeys struct { - Dismiss string `mapstructure:"dismiss"` + Dismiss string `mapstructure:"dismiss"` + DismissAll string `mapstructure:"dismiss_all"` + Flag string `mapstructure:"flag"` } type PluginsKeys struct { diff --git a/internal/db/db.go b/internal/db/db.go index 984517c..a0cdc30 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -79,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries ( request_raw TEXT NOT NULL, response_raw TEXT NOT NULL, status_code INTEGER NOT NULL, - error_msg TEXT NOT NULL + error_msg TEXT NOT NULL, + flagged INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS findings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -89,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings ( description TEXT NOT NULL DEFAULT '', severity TEXT NOT NULL DEFAULT 'info', dismissed INTEGER NOT NULL DEFAULT 0, + flagged INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, UNIQUE(plugin_name, dedup_key) ); diff --git a/internal/db/findings.go b/internal/db/findings.go index 1ead406..ac05dec 100644 --- a/internal/db/findings.go +++ b/internal/db/findings.go @@ -11,6 +11,7 @@ type Finding struct { Title string Description string Severity string + Flagged bool CreatedAt time.Time } @@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) { func (d *DB) LoadFindings() ([]Finding, error) { rows, err := d.conn.Query( - `SELECT id, plugin_name, dedup_key, title, description, severity, created_at + `SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at FROM findings WHERE dismissed = 0 ORDER BY id ASC`, ) if err != nil { @@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) { for rows.Next() { var f Finding var ts string - if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil { + var flagged int + if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil { return nil, err } + f.Flagged = flagged != 0 for _, layout := range findingTimeFormats { if t, err := time.Parse(layout, ts); err == nil { f.CreatedAt = t.Local() @@ -63,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error { _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id) return err } + +func (d *DB) ToggleFindingFlag(id int64) error { + _, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id) + return err +} + +// DismissAllFindings dismisses unflagged findings first. If none are unflagged +// (only flagged ones remain), it dismisses everything. +func (d *DB) DismissAllFindings() error { + var count int + if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil { + return err + } + if count > 0 { + _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`) + return err + } + _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`) + return err +} diff --git a/internal/db/replay.go b/internal/db/replay.go index acc5786..0b2ebd1 100644 --- a/internal/db/replay.go +++ b/internal/db/replay.go @@ -17,16 +17,17 @@ type ReplayEntry struct { ResponseRaw string StatusCode int ErrorMsg string + Flagged bool } func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) { res, err := d.conn.Exec( - `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.Timestamp.UTC().Format(time.RFC3339), e.Scheme, e.Host, e.Path, e.Method, e.OriginalRaw, e.RequestRaw, e.ResponseRaw, - e.StatusCode, e.ErrorMsg, + e.StatusCode, e.ErrorMsg, e.Flagged, ) if err != nil { return 0, err @@ -36,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) { func (d *DB) UpdateReplayEntry(e ReplayEntry) error { _, err := d.conn.Exec( - `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`, - e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID, + `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`, + e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID, ) return err } func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { rows, err := d.conn.Query( - `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg + `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged FROM replay_entries ORDER BY id ASC`, ) if err != nil { @@ -56,8 +57,9 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { for rows.Next() { var e ReplayEntry var ts string + var flagged int if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method, - &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil { + &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil { return nil, err } var tsErr error @@ -65,11 +67,17 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { if tsErr != nil { log.Printf("db: parse replay timestamp %q: %v", ts, tsErr) } + e.Flagged = flagged != 0 entries = append(entries, e) } return entries, rows.Err() } +func (d *DB) ToggleReplayFlag(id int64) error { + _, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id) + return err +} + func (d *DB) DeleteReplayEntry(id int64) error { _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id) return err @@ -79,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error { _, err := d.conn.Exec(`DELETE FROM replay_entries`) return err } + +// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If +// none are unflagged (only flagged ones remain), it deletes everything. +func (d *DB) DeleteAllReplayEntriesExceptFlagged() error { + var count int + if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil { + return err + } + if count > 0 { + _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`) + return err + } + _, err := d.conn.Exec(`DELETE FROM replay_entries`) + return err +} diff --git a/internal/icons/icons.go b/internal/icons/icons.go index c71ad8f..2700d4b 100644 --- a/internal/icons/icons.go +++ b/internal/icons/icons.go @@ -48,6 +48,8 @@ func Init(cfg *config.Config) { Flag: "󰈻 ", } } else { - I = &Icons{} + I = &Icons{ + Flag: "*", + } } } diff --git a/internal/keys/findings.go b/internal/keys/findings.go index b769667..bacf34c 100644 --- a/internal/keys/findings.go +++ b/internal/keys/findings.go @@ -6,15 +6,19 @@ import ( ) type FindingsKeyMap struct { - Dismiss key.Binding + Dismiss key.Binding + DismissAll key.Binding + Flag key.Binding } func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap { return FindingsKeyMap{ - Dismiss: binding(cfg.Dismiss, "dismiss"), + Dismiss: binding(cfg.Dismiss, "dismiss"), + DismissAll: binding(cfg.DismissAll, "dismiss all"), + Flag: binding(cfg.Flag, "flag"), } } func (f FindingsKeyMap) Bindings() []key.Binding { - return []key.Binding{f.Dismiss} + return []key.Binding{f.Flag, f.Dismiss, f.DismissAll} } diff --git a/internal/keys/replay.go b/internal/keys/replay.go index f88c520..e74c186 100644 --- a/internal/keys/replay.go +++ b/internal/keys/replay.go @@ -13,6 +13,7 @@ type ReplayKeyMap struct { Delete key.Binding DeleteAll key.Binding Filter key.Binding + Flag key.Binding } func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { @@ -24,9 +25,10 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { Delete: binding(cfg.Delete, "delete"), DeleteAll: binding(cfg.DeleteAll, "delete all"), Filter: binding(cfg.Filter, "filter"), + Flag: binding(cfg.Flag, "flag"), } } func (r ReplayKeyMap) Bindings() []key.Binding { - return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll, r.Filter} + return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter} } diff --git a/internal/ui/findings/model.go b/internal/ui/findings/model.go index a968253..eb97ff0 100644 --- a/internal/ui/findings/model.go +++ b/internal/ui/findings/model.go @@ -186,7 +186,7 @@ type findingsKeyMap struct{ width int } func (findingsKeyMap) ShortHelp() []key.Binding { g := keys.Keys.Global f := keys.Keys.Findings - return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help} + return []key.Binding{g.Up, g.Down, f.Flag, f.Dismiss, f.DismissAll, g.Copy, g.Help} } func (m findingsKeyMap) FullHelp() [][]key.Binding { diff --git a/internal/ui/findings/update.go b/internal/ui/findings/update.go index fdadbbf..5c82fac 100644 --- a/internal/ui/findings/update.go +++ b/internal/ui/findings/update.go @@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshListViewport() m.refreshBody() } + case key.Matches(msg, f.Flag): + if len(m.findings) > 0 && m.database != nil { + if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil { + log.Printf("findings: toggle flag: %v", err) + return m, nil + } + return m, RefreshCmd(m.database) + } case key.Matches(msg, f.Dismiss): if len(m.findings) > 0 && m.database != nil { if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil { @@ -80,6 +88,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, RefreshCmd(m.database) } + case key.Matches(msg, f.DismissAll): + if m.database != nil { + if err := m.database.DismissAllFindings(); err != nil { + log.Printf("findings: dismiss all: %v", err) + return m, nil + } + return m, RefreshCmd(m.database) + } case key.Matches(msg, g.ScrollUp): util.ScrollViewport(&m.bodyViewport, -1) case key.Matches(msg, g.ScrollDown): diff --git a/internal/ui/findings/view.go b/internal/ui/findings/view.go index 8767475..b6db10b 100644 --- a/internal/ui/findings/view.go +++ b/internal/ui/findings/view.go @@ -66,9 +66,10 @@ func (m *Model) renderList() string { sevStyle := style.SeverityStyle(f.Severity) sevLabel := sevStyle.Width(8).Render(f.Severity) ts := f.CreatedAt.Format("15:04:05") + flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) w := m.listViewport.Width() - const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1 + const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1 titleW := w - fixedW if titleW < 0 { titleW = 0 @@ -79,8 +80,13 @@ func (m *Model) renderList() string { var line string if selected { bg := lipgloss.NewStyle().Background(ilovetui.S.Selection) + flagStr := " " + if f.Flagged { + flagStr = icons.I.Flag + " " + } line = lipgloss.JoinHorizontal(lipgloss.Top, bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), + bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr), sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity), bg.Width(1).Render(""), bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)), @@ -90,8 +96,13 @@ func (m *Model) renderList() string { bg.Bold(true).Width(titleW).Render(f.Title), ) } else { + flagStr := " " + if f.Flagged { + flagStr = icons.I.Flag + " " + } line = lipgloss.JoinHorizontal(lipgloss.Top, " ", + flagSt.Width(2).Render(flagStr), sevLabel, " ", pluginStr, diff --git a/internal/ui/history/view.go b/internal/ui/history/view.go index 3b1e399..483a110 100644 --- a/internal/ui/history/view.go +++ b/internal/ui/history/view.go @@ -120,9 +120,6 @@ func (m *Model) renderList() string { flagStr := " " if e.Flagged { flagStr = icons.I.Flag + " " - if icons.I.Flag == "" { - flagStr = "★ " - } } line = lipgloss.JoinHorizontal(lipgloss.Top, bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), @@ -139,9 +136,6 @@ func (m *Model) renderList() string { flagStr := " " if e.Flagged { flagStr = icons.I.Flag + " " - if icons.I.Flag == "" { - flagStr = "★ " - } } line = lipgloss.JoinHorizontal(lipgloss.Top, " ", diff --git a/internal/ui/replay/model.go b/internal/ui/replay/model.go index 35d76ec..7743ac0 100644 --- a/internal/ui/replay/model.go +++ b/internal/ui/replay/model.go @@ -33,6 +33,7 @@ type Entry struct { ResponseRaw string // filled after send StatusCode int // 0 = not sent yet Sending bool + Flagged bool Err error } @@ -148,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry { RequestRaw: dbe.RequestRaw, ResponseRaw: dbe.ResponseRaw, StatusCode: dbe.StatusCode, + Flagged: dbe.Flagged, Err: err, } } @@ -277,7 +279,7 @@ 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, g.CycleFocus, r.Send, r.Edit, r.Filter, g.Help} + return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help} } func (m replayKeyMap) FullHelp() [][]key.Binding { diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go index 847a825..ece92a3 100644 --- a/internal/ui/replay/update.go +++ b/internal/ui/replay/update.go @@ -241,6 +241,18 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.requestViewport.ScrollRight(6) m.responseViewport.ScrollRight(6) + case key.Matches(msg, r.Flag): + if len(m.entries) > 0 { + allIdx := m.currentAllIdx() + if m.database != nil && m.allEntries[allIdx].DBID != 0 { + if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil { + log.Printf("replay: toggle flag: %v", err) + } + } + m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged + m.applyFilter(allIdx) + } + case key.Matches(msg, r.Delete): if len(m.entries) > 0 { allIdx := m.currentAllIdx() @@ -260,11 +272,29 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, r.DeleteAll): if m.database != nil { - if err := m.database.DeleteAllReplayEntries(); err != nil { + if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil { log.Printf("replay: delete all entries: %v", err) } } - m.allEntries = nil + // Mirror the DB logic in memory: delete unflagged first; if none, delete all. + hasUnflagged := false + for _, e := range m.allEntries { + if !e.Flagged { + hasUnflagged = true + break + } + } + if hasUnflagged { + filtered := m.allEntries[:0] + for _, e := range m.allEntries { + if e.Flagged { + filtered = append(filtered, e) + } + } + m.allEntries = filtered + } else { + m.allEntries = nil + } m.cursor = 0 m.filterActive = false m.filterAccepted = false @@ -551,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry { ResponseRaw: e.ResponseRaw, StatusCode: e.StatusCode, ErrorMsg: errMsg, + Flagged: e.Flagged, } } diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go index 7d32e8b..84ad033 100644 --- a/internal/ui/replay/view.go +++ b/internal/ui/replay/view.go @@ -106,19 +106,25 @@ func (m *Model) renderList() string { selBg := ilovetui.S.Selection w := m.listViewport.Width() - const fixedW = 2 + 7 + 1 + 3 + 1 + const fixedW = 2 + 2 + 7 + 1 + 3 + 1 hostPathW := w - fixedW if hostPathW < 0 { hostPathW = 0 } statusStr, statusSt := entryStatus(e) + flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) var line string if selected { bg := lipgloss.NewStyle().Background(selBg) + flagStr := " " + if e.Flagged { + flagStr = icons.I.Flag + " " + } line = lipgloss.JoinHorizontal(lipgloss.Top, bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), + bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr), style.S.Method(e.Method).Background(selBg).Render(e.Method), bg.Width(1).Render(""), statusSt.Background(selBg).Render(statusStr), @@ -126,8 +132,13 @@ func (m *Model) renderList() string { bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), ) } else { + flagStr := " " + if e.Flagged { + flagStr = icons.I.Flag + " " + } line = lipgloss.JoinHorizontal(lipgloss.Top, " ", + flagSt.Width(2).Render(flagStr), style.S.Method(e.Method).Render(e.Method), " ", statusSt.Render(statusStr),