srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-09-14 22:16:00 +0200
committersrdusr <[email protected]>2024-09-14 22:16:00 +0200
commita02d612ba5efb5f1e79a2f9a0778890e8c3e6acf (patch)
tree4d1da9141392237bfa4e132c04191e12c85ef992 /cmd
parent70f675e59ac4bb0e469ca0073f8cc0506b17677e (diff)
downloadmitmux-a02d612ba5efb5f1e79a2f9a0778890e8c3e6acf.tar.gz
mitmux-a02d612ba5efb5f1e79a2f9a0778890e8c3e6acf.zip
Search/filter: FTS5 index over history
Implements build-order step 5. internal/store gains an FTS5 virtual table (history_fts) kept in sync with every Insert in the same transaction, indexing method/host/path plus the full raw request and response text - so search covers headers and bodies, not just metadata. Store.Search ranks by bm25 relevance. internal/ipc's existing "list" request grows an optional query field rather than a new message type. cmd/mitmux gets an inline '/' filter on the history view (bubbles/ textinput), esc to clear; live entries arriving while a filter is active are held back with a "+N new" indicator rather than guessed at, since FTS match can't be evaluated against a bare Summary. Two real bugs found via testing against the actual sqlite3 CLI, not assumed from docs: 1. This SQLite build doesn't support MATCH/bm25() against an aliased FTS5 table ("no such column") - only the literal table name resolves. Fixed by leaving history_fts unaliased in the JOIN. 2. FTS5's query grammar treats a wide range of punctuation as syntax, not literal characters - confirmed '.', '-', '/', '@', '(', ')' all produce parse errors (or worse, silently different results, as hyphens get misparsed as column-filter syntax) in an unquoted bareword. Since that covers the most common things people search proxy history for (domains, paths, hyphenated headers, IPs), this would have made the feature fail by default for its primary use case. Fixed with prepareFTSQuery: quote every plain token as an FTS5 phrase (syntactically valid regardless of content) while still recognizing AND/OR/NOT and column:value filters. Also caught, mid-testing, that a query fix wasn't taking effect - traced to the daemon still running an old `go run` build from before the fix while only the TUI had been restarted; not a code bug, but a reminder to restart both. Verified live end-to-end: plain-text search matching header/body/JSON content, a previously-failing dotted-domain search now returning exactly the right single match, a hyphen/host:-filter case, boolean-free numeric search, filter-clear returning to the unfiltered list, and the pending- count indicator when new traffic arrives mid-filter.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go87
1 files changed, 78 insertions, 9 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 6d7fe8c..f47461c 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textarea"
+ "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -94,6 +95,11 @@ type model struct {
entries []store.Summary
table table.Model
+ searchInput textinput.Model
+ searching bool // actively typing a query
+ query string // applied filter, "" means unfiltered
+ pendingNew int // live entries captured while a filter hides them
+
viewport viewport.Model
detail *ipc.EntryDetail
activeTab detailTab
@@ -135,12 +141,17 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model {
ta.Placeholder = "raw request bytes"
ta.ShowLineNumbers = false
+ si := textinput.New()
+ si.Prompt = "/"
+ si.Placeholder = "search - plain text, or host:example.com / AND / OR / NOT"
+
return &model{
- client: client,
- subCh: subCh,
- mode: viewList,
- table: t,
- reqArea: ta,
+ client: client,
+ subCh: subCh,
+ mode: viewList,
+ table: t,
+ reqArea: ta,
+ searchInput: si,
}
}
@@ -166,7 +177,13 @@ type repeatSentMsg struct {
}
func (m *model) loadList() tea.Msg {
- entries, err := m.client.List(500, 0)
+ var entries []store.Summary
+ var err error
+ if m.query != "" {
+ entries, err = m.client.Search(m.query, 500, 0)
+ } else {
+ entries, err = m.client.List(500, 0)
+ }
return listLoadedMsg{entries: entries, err: err}
}
@@ -220,7 +237,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
m.ready = true
m.table.SetWidth(msg.Width)
- m.table.SetHeight(msg.Height - 4)
+ m.table.SetHeight(msg.Height - 5)
+ m.searchInput.Width = msg.Width - 2
m.viewport = viewport.New(msg.Width, msg.Height-5)
reqHeight := (msg.Height - 6) / 2
@@ -243,6 +261,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "live updates disconnected"
return m, nil
}
+ if m.query != "" {
+ // Whether it matches the active filter isn't knowable without
+ // re-querying; rather than guess, just surface a count and let
+ // the user refresh the search deliberately.
+ m.pendingNew++
+ m.statusMsg = fmt.Sprintf("+%d new captured (re-enter search to refresh)", m.pendingNew)
+ return m, m.waitForEntry
+ }
m.entries = append([]store.Summary{msg.entry}, m.entries...)
m.table.SetRows(rowsFor(m.entries))
return m, m.waitForEntry
@@ -277,6 +303,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch m.mode {
case viewList:
+ if m.searching {
+ switch msg.String() {
+ case "enter":
+ m.searching = false
+ m.query = m.searchInput.Value()
+ m.searchInput.Blur()
+ m.pendingNew = 0
+ m.statusMsg = ""
+ return m, m.loadList
+ case "esc":
+ m.searching = false
+ m.searchInput.Blur()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.searchInput, cmd = m.searchInput.Update(msg)
+ return m, cmd
+ }
+
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
@@ -291,6 +336,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = ""
return m, m.loadDetail(m.entries[row].ID, true)
}
+ case "/":
+ m.searching = true
+ m.searchInput.SetValue(m.query)
+ m.searchInput.CursorEnd()
+ return m, m.searchInput.Focus()
+ case "esc":
+ if m.query != "" {
+ m.query = ""
+ m.pendingNew = 0
+ m.statusMsg = ""
+ return m, m.loadList
+ }
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
@@ -385,15 +442,27 @@ var (
func (m *model) listView() string {
var b strings.Builder
- b.WriteString(titleStyle.Render(fmt.Sprintf(" mitmux - history (%d) ", len(m.entries))))
+ title := fmt.Sprintf(" mitmux - history (%d) ", len(m.entries))
+ if m.query != "" && !m.searching {
+ title = fmt.Sprintf(" mitmux - history (%d matches for %q) ", len(m.entries), m.query)
+ }
+ b.WriteString(titleStyle.Render(title))
b.WriteString("\n")
+ if m.searching {
+ b.WriteString(m.searchInput.View())
+ b.WriteString("\n")
+ }
b.WriteString(m.table.View())
b.WriteString("\n")
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- b.WriteString(helpStyle.Render("↑/↓ navigate · enter view · r repeater · q quit"))
+ help := "↑/↓ navigate · enter view · r repeater · / search · q quit"
+ if m.query != "" {
+ help = "↑/↓ navigate · enter view · r repeater · / search · esc clear filter · q quit"
+ }
+ b.WriteString(helpStyle.Render(help))
return b.String()
}