diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/contextmenu.go | 68 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 57 | ||||
| -rw-r--r-- | cmd/mitmux/mouse.go | 233 | ||||
| -rw-r--r-- | cmd/mitmux/vimode.go | 19 |
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 |