srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/contextmenu.go68
-rw-r--r--cmd/mitmux/main.go57
-rw-r--r--cmd/mitmux/mouse.go233
-rw-r--r--cmd/mitmux/vimode.go19
4 files changed, 376 insertions, 1 deletions
diff --git a/cmd/mitmux/contextmenu.go b/cmd/mitmux/contextmenu.go
new file mode 100644
index 0000000..48df0f3
--- /dev/null
+++ b/cmd/mitmux/contextmenu.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// contextMenuItem is one entry in a right-click context menu.
+type contextMenuItem struct {
+ label string
+ action func() tea.Cmd
+}
+
+// A right-click menu is rendered as a horizontal strip taking over the
+// status/help line, the same "replace the bottom of the screen" pattern
+// confirmPrompt and the export/import prompts already use, rather than
+// a floating popup positioned at the click. That's a deliberate
+// simplification: lipgloss/bubbletea have no compositor for splicing an
+// ANSI-styled overlay into an arbitrary screen position, and building
+// one just for this would be a lot of new, fragile machinery for a
+// nice-to-have. Navigable by j/k/arrows + enter, or by clicking an item
+// directly (each item's on-screen width is fully known here, unlike a
+// table row's - see the table-click limitation noted where mouse
+// handling is wired into Update()), esc or right-click again to
+// dismiss.
+func (m *model) openContextMenu(items []contextMenuItem) {
+ m.contextMenu = items
+ m.contextMenuIndex = 0
+}
+
+func (m *model) closeContextMenu() {
+ m.contextMenu = nil
+ m.contextMenuIndex = 0
+}
+
+// contextMenuAt returns the item index at screen column x within the
+// menu's rendered strip, or -1 if x falls outside every item - used to
+// resolve a mouse click to a specific item.
+func (m *model) contextMenuAt(x int) int {
+ col := 0
+ for i, item := range m.contextMenu {
+ w := lipgloss.Width(contextMenuLabel(item.label))
+ if x >= col && x < col+w {
+ return i
+ }
+ col += w
+ }
+ return -1
+}
+
+func contextMenuLabel(label string) string {
+ return " " + label + " "
+}
+
+func (m *model) contextMenuView() string {
+ var b strings.Builder
+ for i, item := range m.contextMenu {
+ label := contextMenuLabel(item.label)
+ if i == m.contextMenuIndex {
+ b.WriteString(tabActive.Render(label))
+ } else {
+ b.WriteString(tabInactive.Render(label))
+ }
+ }
+ return b.String()
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index c9563fe..f248ef0 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -67,7 +67,18 @@ func main() {
defer subClose()
m := newModel(client, subCh, path)
- p := tea.NewProgram(m, tea.WithAltScreen())
+ // WithMouseCellMotion enables click and wheel events (SGR mouse
+ // mode, the same protocol nvim and most modern TUI apps use) -
+ // see contextmenu.go and the mouse handling in Update() for what's
+ // actually wired up. Coexists with tmux's own mouse mode the same
+ // way it does for any other terminal app: tmux forwards these
+ // events to the focused pane's application when tmux's own `set -g
+ // mouse on` is active, nothing mitmux-specific required. The
+ // standard terminal-wide workaround for selecting on-screen text
+ // with the mouse while an app has mouse mode on (holding Shift
+ // while click-dragging, in most terminal emulators) still works -
+ // mitmux doesn't intercept or need to know about that at all.
+ p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "mitmux: %v\n", err)
os.Exit(1)
@@ -230,6 +241,13 @@ type model struct {
confirmPrompt string
confirmYes func() tea.Cmd
+ // contextMenu, when non-empty, is a right-click menu taking over
+ // the status line - see contextmenu.go. Checked globally, ahead of
+ // every mode's own key handling, the same priority confirmPrompt
+ // gets.
+ contextMenu []contextMenuItem
+ contextMenuIndex int
+
// exportEditing mirrors the grep-match/extract edit-buffer pattern:
// a modal textinput overlay for the destination path, enter writes
// and confirms, esc cancels. Used from two places sharing the same
@@ -897,6 +915,9 @@ func (m *model) Init() tea.Cmd {
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case tea.MouseMsg:
+ return m.handleMouse(msg)
+
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
@@ -1143,6 +1164,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.waitForIntrudeResult
case tea.KeyMsg:
+ if len(m.contextMenu) > 0 {
+ switch msg.String() {
+ case "esc":
+ m.closeContextMenu()
+ return m, nil
+ case "enter", " ":
+ item := m.contextMenu[m.contextMenuIndex]
+ m.closeContextMenu()
+ if item.action != nil {
+ return m, item.action()
+ }
+ return m, nil
+ case "up", "k", "left", "h":
+ m.contextMenuIndex = (m.contextMenuIndex - 1 + len(m.contextMenu)) % len(m.contextMenu)
+ return m, nil
+ case "down", "j", "right", "l", "tab":
+ m.contextMenuIndex = (m.contextMenuIndex + 1) % len(m.contextMenu)
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ return m, nil
+ }
switch m.mode {
case viewList:
if m.confirmPrompt != "" {
@@ -1865,6 +1909,9 @@ func (m *model) View() string {
default:
body = m.listView()
}
+ if len(m.contextMenu) > 0 {
+ return body + "\n" + m.contextMenuView()
+ }
return body + "\n" + m.statusBar()
}
@@ -1905,6 +1952,14 @@ func (m *model) helpView() string {
"? this screen (any key returns)",
"ctrl+c quit",
)
+ section("Mouse",
+ "wheel scroll whatever's focused (table, text, or response pane)",
+ "right-click context menu for the current selection (history/rules/scope lists)",
+ " - always the *selected* row's actions, not whatever's",
+ " literally under the cursor; click a menu item, or",
+ " j/k/arrows + enter, esc or right-click again to dismiss",
+ " (needs tmux's own 'set -g mouse on' if running inside tmux)",
+ )
section("History list",
"↑/↓ or j/k navigate (also g/G top/bottom, ctrl+u/d half-page)",
"enter view request/response detail",
diff --git a/cmd/mitmux/mouse.go b/cmd/mitmux/mouse.go
new file mode 100644
index 0000000..102dc4f
--- /dev/null
+++ b/cmd/mitmux/mouse.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// isWheel reports whether msg is a wheel event. tea.MouseMsg is a
+// distinct named type from tea.MouseEvent (type MouseMsg MouseEvent),
+// so MouseEvent's own IsWheel method isn't in MouseMsg's method set
+// despite the identical underlying struct - converting once here is
+// simpler than repeating the conversion at every call site.
+func isWheel(msg tea.MouseMsg) bool {
+ return tea.MouseEvent(msg).IsWheel()
+}
+
+// handleMouse dispatches a tea.MouseMsg by mode, the mouse counterpart
+// to Update()'s tea.KeyMsg dispatch. Deliberately scoped to what's
+// reliably achievable rather than everything a GUI might do: wheel
+// scroll works everywhere there's something to scroll, and right-click
+// opens a context menu for the *currently selected* row in every
+// list-based view - not the row directly under the cursor, because
+// bubbles/table exposes no way to learn its own scroll offset (no
+// YOffset accessor, and the visible window is computed from unexported
+// fields - confirmed by reading its source rather than guessing), so
+// mapping a click's Y coordinate to a specific row can't be done
+// without reflection into private library state. Left-click-to-select-
+// a-different-row and click-to-switch-pane-focus (Repeater/Intruder)
+// are deferred for the same reason: getting either subtly wrong (silently
+// selecting the wrong row) is worse than not having them yet.
+func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if len(m.contextMenu) > 0 {
+ switch {
+ case msg.Action != tea.MouseActionPress:
+ return m, nil
+ case msg.Button == tea.MouseButtonRight:
+ m.closeContextMenu()
+ return m, nil
+ case msg.Button == tea.MouseButtonLeft:
+ if i := m.contextMenuAt(msg.X); i >= 0 {
+ item := m.contextMenu[i]
+ m.closeContextMenu()
+ if item.action != nil {
+ return m, item.action()
+ }
+ }
+ return m, nil
+ }
+ return m, nil
+ }
+
+ switch m.mode {
+ case viewList:
+ return m.handleListMouse(msg)
+ case viewDetail:
+ if isWheel(msg) {
+ var cmd tea.Cmd
+ m.viewport, cmd = m.viewport.Update(msg)
+ return m, cmd
+ }
+ case viewRepeater:
+ return m.handleRepeaterMouse(msg)
+ case viewIntruder:
+ return m.handleIntruderMouse(msg)
+ case viewCompare:
+ if isWheel(msg) {
+ var cmd tea.Cmd
+ m.compareViewport, cmd = m.compareViewport.Update(msg)
+ return m, cmd
+ }
+ case viewDecoder:
+ if isWheel(msg) {
+ var cmd tea.Cmd
+ m.decoderOutput, cmd = m.decoderOutput.Update(msg)
+ return m, cmd
+ }
+ case viewRules:
+ return m.handleRulesMouse(msg)
+ case viewScope:
+ return m.handleScopeMouse(msg)
+ }
+ return m, nil
+}
+
+func (m *model) handleListMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if m.searching || m.exportEditing || m.importEditing || m.confirmPrompt != "" {
+ return m, nil // an active modal text input owns input right now
+ }
+ switch {
+ case msg.Button == tea.MouseButtonWheelUp:
+ m.table.MoveUp(3)
+ case msg.Button == tea.MouseButtonWheelDown:
+ m.table.MoveDown(3)
+ case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress:
+ row := m.table.Cursor()
+ if row < 0 || row >= len(m.entries) {
+ return m, nil
+ }
+ id := m.entries[row].ID
+ flagLabel := "flag"
+ if m.entries[row].Flagged {
+ flagLabel = "unflag"
+ }
+ m.openContextMenu([]contextMenuItem{
+ {label: "view", action: func() tea.Cmd {
+ m.statusMsg = ""
+ return m.loadDetail(id, "")
+ }},
+ {label: "repeater", action: func() tea.Cmd {
+ m.statusMsg = ""
+ return m.loadDetail(id, "repeater")
+ }},
+ {label: "intruder", action: func() tea.Cmd {
+ m.statusMsg = ""
+ return m.loadDetail(id, "intruder")
+ }},
+ {label: flagLabel, action: func() tea.Cmd {
+ m.entries[row].Flagged = !m.entries[row].Flagged
+ setTableRows(&m.table, rowsFor(m.entries))
+ return m.setFlagged(id, m.entries[row].Flagged)
+ }},
+ {label: "delete", action: func() tea.Cmd {
+ m.confirmPrompt = fmt.Sprintf("delete #%d? y/n", id)
+ m.confirmYes = func() tea.Cmd { return m.deleteEntry(id) }
+ return nil
+ }},
+ })
+ }
+ return m, nil
+}
+
+func (m *model) handleRepeaterMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ t := m.activeRepeaterTab()
+ if t == nil || !isWheel(msg) {
+ return m, nil
+ }
+ up := msg.Button == tea.MouseButtonWheelUp
+ if t.focus == focusRequest {
+ if up {
+ t.reqArea.ScrollUp(3)
+ } else {
+ t.reqArea.ScrollDown(3)
+ }
+ return m, nil
+ }
+ var cmd tea.Cmd
+ t.respView, cmd = t.respView.Update(msg)
+ return m, cmd
+}
+
+func (m *model) handleIntruderMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if !isWheel(msg) {
+ return m, nil
+ }
+ up := msg.Button == tea.MouseButtonWheelUp
+ switch m.intruderFocus {
+ case focusTemplate:
+ if up {
+ m.intruderTemplate.ScrollUp(3)
+ } else {
+ m.intruderTemplate.ScrollDown(3)
+ }
+ case focusPayloads:
+ if up {
+ m.intruderPayloads.ScrollUp(3)
+ } else {
+ m.intruderPayloads.ScrollDown(3)
+ }
+ case focusResults:
+ if up {
+ m.intruderResults.MoveUp(3)
+ } else {
+ m.intruderResults.MoveDown(3)
+ }
+ }
+ return m, nil
+}
+
+func (m *model) handleRulesMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if m.ruleForm {
+ return m, nil
+ }
+ switch {
+ case msg.Button == tea.MouseButtonWheelUp:
+ m.rulesTable.MoveUp(3)
+ case msg.Button == tea.MouseButtonWheelDown:
+ m.rulesTable.MoveDown(3)
+ case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress:
+ row := m.rulesTable.Cursor()
+ if row < 0 || row >= len(m.ruleRows) {
+ return m, nil
+ }
+ sel := m.ruleRows[row]
+ enableLabel := "enable"
+ if sel.Enabled {
+ enableLabel = "disable"
+ }
+ m.openContextMenu([]contextMenuItem{
+ {label: "edit", action: func() tea.Cmd { m.enterRuleForm(&sel); return nil }},
+ {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedRule() }},
+ {label: "delete", action: func() tea.Cmd { return m.deleteSelectedRule() }},
+ })
+ }
+ return m, nil
+}
+
+func (m *model) handleScopeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if m.scopeForm {
+ return m, nil
+ }
+ switch {
+ case msg.Button == tea.MouseButtonWheelUp:
+ m.scopeTable.MoveUp(3)
+ case msg.Button == tea.MouseButtonWheelDown:
+ m.scopeTable.MoveDown(3)
+ case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress:
+ row := m.scopeTable.Cursor()
+ if row < 0 || row >= len(m.scopeRows) {
+ return m, nil
+ }
+ sel := m.scopeRows[row]
+ enableLabel := "enable"
+ if sel.Enabled {
+ enableLabel = "disable"
+ }
+ m.openContextMenu([]contextMenuItem{
+ {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedScopeRule() }},
+ {label: "delete", action: func() tea.Cmd { return m.deleteSelectedScopeRule() }},
+ })
+ }
+ return m, nil
+}
diff --git a/cmd/mitmux/vimode.go b/cmd/mitmux/vimode.go
index 5da82a3..1af1fb9 100644
--- a/cmd/mitmux/vimode.go
+++ b/cmd/mitmux/vimode.go
@@ -65,6 +65,25 @@ func (v *viTextarea) Focus() tea.Cmd {
// itself.
func (v *viTextarea) InsertRune(r rune) { v.ta.InsertRune(r) }
+// ScrollUp/ScrollDown move the cursor n lines up/down - used for mouse
+// wheel scrolling. bubbles/textarea has no native mouse handling at all
+// (confirmed in its source: no case for tea.MouseMsg anywhere), so
+// there's nothing to forward a wheel event to; feeding it as n
+// up/down keypresses reuses the same tested movement path vi's own
+// h/j/k/l already go through rather than reaching into textarea's
+// internal line offset directly.
+func (v *viTextarea) ScrollUp(n int) {
+ for i := 0; i < n; i++ {
+ v.feed("up")
+ }
+}
+
+func (v *viTextarea) ScrollDown(n int) {
+ for i := 0; i < n; i++ {
+ v.feed("down")
+ }
+}
+
// Update processes one message. Callers should check Mode()=="normal"
// before treating "esc" as "leave this view" - in insert mode, esc
// belongs to viTextarea (it drops back to normal mode without