diff options
| -rw-r--r-- | cmd/mitmux/main.go | 77 | ||||
| -rw-r--r-- | cmd/mitmux/vimode.go | 268 |
2 files changed, 324 insertions, 21 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index c53f975..d9e1685 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -12,7 +12,6 @@ import ( "time" "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" @@ -126,7 +125,7 @@ type model struct { detail *ipc.EntryDetail activeTab detailTab - reqArea textarea.Model + reqArea viTextarea respView viewport.Model repeaterFocus repeaterFocus repeaterScheme string @@ -149,8 +148,8 @@ type model struct { intruderScheme string intruderHost string - intruderTemplate textarea.Model - intruderPayloads textarea.Model + intruderTemplate viTextarea + intruderPayloads viTextarea intruderResults table.Model intruderRows []ipc.IntrudeResultMsg intruderFocus intruderFocus @@ -184,9 +183,9 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true) t.SetStyles(st) - ta := textarea.New() - ta.Placeholder = "raw request bytes" - ta.ShowLineNumbers = false + ta := newViTextarea() + ta.ta.Placeholder = "raw request bytes" + ta.ta.ShowLineNumbers = false si := textinput.New() si.Prompt = "/" @@ -210,13 +209,13 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) replaceIn := textinput.New() replaceIn.Placeholder = "replacement" - itmpl := textarea.New() - itmpl.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" - itmpl.ShowLineNumbers = false + itmpl := newViTextarea() + itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" + itmpl.ta.ShowLineNumbers = false - ipayloads := textarea.New() - ipayloads.Placeholder = "payloads, one per line" - ipayloads.ShowLineNumbers = false + ipayloads := newViTextarea() + ipayloads.ta.Placeholder = "payloads, one per line" + ipayloads.ta.ShowLineNumbers = false iresultsCols := []table.Column{ {Title: "Pos", Width: 4}, @@ -325,7 +324,7 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) { // enterIntruder seeds the Intruder view from an already-loaded entry. // The template starts with no § markers - the user adds them by hand -// (or ctrl+p at the cursor) around whatever they want to fuzz. +// (or ctrl+g at the cursor) around whatever they want to fuzz. func (m *model) enterIntruder(d *ipc.EntryDetail) { m.intruderScheme = d.Scheme m.intruderHost = d.Host @@ -338,7 +337,7 @@ func (m *model) enterIntruder(d *ipc.EntryDetail) { m.intruderRunning = false m.intruderCount = 0 m.mode = viewIntruder - m.statusMsg = "wrap positions to fuzz in § (ctrl+p), fill payloads, ctrl+r to start" + m.statusMsg = "wrap positions to fuzz in § (ctrl+g), fill payloads, ctrl+r to start" } type intrudeStartedMsg struct { @@ -707,6 +706,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case viewRepeater: switch msg.String() { case "esc": + // In insert mode, esc belongs to the textarea - it drops + // to normal mode without leaving the view, matching vi. + // Only back out when already in normal mode (or focus is + // elsewhere entirely). + if m.repeaterFocus == focusRequest && m.reqArea.Mode() == viInsert { + break + } m.mode = viewList m.reqArea.Blur() return m, nil @@ -731,7 +737,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd if m.repeaterFocus == focusRequest { - m.reqArea, cmd = m.reqArea.Update(msg) + cmd = m.reqArea.Update(msg) } else { m.respView, cmd = m.respView.Update(msg) } @@ -810,14 +816,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case viewIntruder: + editing := (m.intruderFocus == focusTemplate && m.intruderTemplate.Mode() == viInsert) || + (m.intruderFocus == focusPayloads && m.intruderPayloads.Mode() == viInsert) switch msg.String() { case "esc": + // Same rule as Repeater: esc in insert mode belongs to + // the textarea (drop to normal mode), not the view. + if editing { + break + } if m.intruderRunning && m.intruderClose != nil { m.intruderClose() m.intruderRunning = false } m.mode = viewList m.intruderTemplate.Blur() + m.intruderPayloads.Blur() return m, nil case "ctrl+c": return m, tea.Quit @@ -827,7 +841,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.startIntrude() } return m, nil - case "ctrl+p": + case "ctrl+g": if m.intruderFocus == focusTemplate { m.intruderTemplate.InsertRune('§') } @@ -860,9 +874,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.intruderFocus { case focusTemplate: - m.intruderTemplate, cmd = m.intruderTemplate.Update(msg) + cmd = m.intruderTemplate.Update(msg) case focusPayloads: - m.intruderPayloads, cmd = m.intruderPayloads.Update(msg) + cmd = m.intruderPayloads.Update(msg) case focusResults: m.intruderResults, cmd = m.intruderResults.Update(msg) } @@ -976,14 +990,28 @@ func (m *model) repeaterView() string { b.WriteString("\n") b.WriteString(m.respView.View()) b.WriteString("\n") + if m.repeaterFocus == focusRequest { + b.WriteString(viModeLabel(&m.reqArea)) + b.WriteString("\n") + } if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("ctrl+r send · tab switch pane · esc back · ctrl+c quit")) + b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · esc back · ? help · ctrl+c quit")) return b.String() } +// viModeLabel renders a vim-style mode indicator (e.g. "-- INSERT --") +// for a focused viTextarea, so the user has some feedback about which +// mode they're in - without it, vi-modal editing is unusable. +func viModeLabel(v *viTextarea) string { + if v.Mode() == viInsert { + return statusStyle.Render("-- INSERT --") + } + return helpStyle.Render("-- NORMAL --") +} + func (m *model) rulesView() string { var b strings.Builder b.WriteString(titleStyle.Render(fmt.Sprintf(" match/replace rules (%d) - headers only for now ", len(m.ruleRows)))) @@ -1080,11 +1108,18 @@ func (m *model) intruderView() string { b.WriteString(m.intruderResults.View()) b.WriteString("\n") + if m.intruderFocus == focusTemplate { + b.WriteString(viModeLabel(&m.intruderTemplate)) + b.WriteString("\n") + } else if m.intruderFocus == focusPayloads { + b.WriteString(viModeLabel(&m.intruderPayloads)) + b.WriteString("\n") + } if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("tab switch pane · ctrl+p insert § · ctrl+r start · enter (results) view · esc back/stop · ctrl+c quit")) + b.WriteString(helpStyle.Render("i to edit (vi keys) · tab switch pane · ctrl+g insert § · ctrl+r start · enter (results) view · esc back/stop · ? help · ctrl+c quit")) return b.String() } diff --git a/cmd/mitmux/vimode.go b/cmd/mitmux/vimode.go new file mode 100644 index 0000000..5da82a3 --- /dev/null +++ b/cmd/mitmux/vimode.go @@ -0,0 +1,268 @@ +package main + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" +) + +// viTextarea wraps textarea.Model with a lightweight vi-style modal +// layer: normal mode for navigation/editing commands, insert mode for +// free typing. Vi commands are translated into the underlying +// textarea's own existing keybindings (see feed) and reuse its tested +// text-manipulation logic rather than reimplementing cursor/line +// handling from scratch - this only exists to give a familiar command +// vocabulary a different front end. +// +// Covers what people actually reach for constantly: h/j/k/l, 0/$, w/b, +// x, i/a/I/A/o/O, dd/yy/p/P, dw/d$/d0, gg/G, esc back to normal. Not +// attempted: registers beyond one yank slot, visual mode, ex commands, +// macros, counts (3dd, 5j). textarea has no undo, so neither does this. +// +// Starts in normal mode on Focus, matching real vi - not insert mode. +type viTextarea struct { + ta textarea.Model + mode viMode + yank string + pending string // first key of a two-key sequence (d, y, g) awaiting its second +} + +type viMode int + +const ( + viNormal viMode = iota + viInsert +) + +func newViTextarea() viTextarea { + return viTextarea{ta: textarea.New(), mode: viNormal} +} + +func (v *viTextarea) SetValue(s string) { v.ta.SetValue(s) } +func (v *viTextarea) Value() string { return v.ta.Value() } +func (v *viTextarea) SetWidth(w int) { v.ta.SetWidth(w) } +func (v *viTextarea) SetHeight(h int) { v.ta.SetHeight(h) } +func (v *viTextarea) View() string { return v.ta.View() } +func (v *viTextarea) Mode() viMode { return v.mode } + +func (v *viTextarea) Blur() { + v.ta.Blur() + v.mode = viNormal + v.pending = "" +} + +// Focus focuses the textarea in normal mode, per vi convention. +func (v *viTextarea) Focus() tea.Cmd { + v.mode = viNormal + v.pending = "" + return v.ta.Focus() +} + +// InsertRune inserts r at the cursor regardless of mode - used by +// callers for application-specific shortcuts layered on top of vi mode +// (e.g. Intruder's marker insertion), not part of the vi command set +// itself. +func (v *viTextarea) InsertRune(r rune) { v.ta.InsertRune(r) } + +// 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 +// propagating further), matching vi's own "esc never closes the +// buffer" behavior. +func (v *viTextarea) Update(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + var cmd tea.Cmd + v.ta, cmd = v.ta.Update(msg) + return cmd + } + + if v.mode == viInsert { + if keyMsg.String() == "esc" { + v.mode = viNormal + return nil + } + var cmd tea.Cmd + v.ta, cmd = v.ta.Update(msg) + return cmd + } + + return v.handleNormal(keyMsg) +} + +func (v *viTextarea) handleNormal(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + if v.pending != "" { + seq := v.pending + key + v.pending = "" + switch seq { + case "dd": + v.deleteLine() + case "dw": + v.feed("alt+d") + case "d$": + v.feed("ctrl+k") + case "d0": + v.feed("ctrl+u") + case "yy": + v.yankLine() + case "gg": + v.feed("ctrl+home") + } + return nil + } + + switch key { + case "h": + v.feed("left") + case "l": + v.feed("right") + case "j": + v.feed("down") + case "k": + v.feed("up") + case "0": + v.feed("home") + case "$": + v.feed("end") + case "w": + v.feed("alt+right") + case "b": + v.feed("alt+left") + case "x": + v.feed("delete") + case "d", "y", "g": + v.pending = key + case "i": + v.mode = viInsert + case "a": + v.feed("right") + v.mode = viInsert + case "I": + v.feed("home") + v.mode = viInsert + case "A": + v.feed("end") + v.mode = viInsert + case "o": + v.feed("end") + v.feed("enter") + v.mode = viInsert + case "O": + v.feed("home") + v.feed("enter") + v.feed("up") + v.mode = viInsert + case "p": + v.pasteBelow() + case "P": + v.pasteAbove() + case "G": + v.feed("ctrl+end") + default: + // Arrow keys and the like still work in normal mode even though + // they're not vi commands - no reason to block them. + var cmd tea.Cmd + v.ta, cmd = v.ta.Update(msg) + return cmd + } + return nil +} + +// feed synthesizes a key event by its bubbletea string form and runs it +// through the underlying textarea, so vi commands are implemented in +// terms of textarea's own tested key bindings rather than reaching into +// its (mostly unexported) internal state. +func (v *viTextarea) feed(key string) { + v.ta, _ = v.ta.Update(keyMsgFor(key)) +} + +func keyMsgFor(key string) tea.KeyMsg { + switch key { + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "home": + return tea.KeyMsg{Type: tea.KeyHome} + case "end": + return tea.KeyMsg{Type: tea.KeyEnd} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "delete": + return tea.KeyMsg{Type: tea.KeyDelete} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + case "ctrl+k": + return tea.KeyMsg{Type: tea.KeyCtrlK} + case "ctrl+u": + return tea.KeyMsg{Type: tea.KeyCtrlU} + case "ctrl+home": + return tea.KeyMsg{Type: tea.KeyCtrlHome} + case "ctrl+end": + return tea.KeyMsg{Type: tea.KeyCtrlEnd} + case "alt+right": + return tea.KeyMsg{Type: tea.KeyRight, Alt: true} + case "alt+left": + return tea.KeyMsg{Type: tea.KeyLeft, Alt: true} + case "alt+d": + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}, Alt: true} + } + return tea.KeyMsg{} +} + +func (v *viTextarea) currentLineText() string { + lines := strings.Split(v.ta.Value(), "\n") + idx := v.ta.Line() + if idx < 0 || idx >= len(lines) { + return "" + } + return lines[idx] +} + +func (v *viTextarea) yankLine() { + v.yank = v.currentLineText() +} + +// deleteLine implements dd: clear the current line's text, then merge +// it away entirely (forward-delete pulls the next line up in the common +// case; on the last line, backspace merges into the previous one +// instead, since there's nothing after it to pull up). +func (v *viTextarea) deleteLine() { + v.yank = v.currentLineText() + isLast := v.ta.Line() == v.ta.LineCount()-1 + v.feed("home") + v.feed("ctrl+k") + if v.ta.LineCount() > 1 { + if isLast { + v.feed("backspace") + } else { + v.feed("delete") + } + } +} + +func (v *viTextarea) pasteBelow() { + if v.yank == "" { + return + } + v.feed("end") + v.feed("enter") + v.ta.InsertString(v.yank) +} + +func (v *viTextarea) pasteAbove() { + if v.yank == "" { + return + } + v.feed("home") + v.ta.InsertString(v.yank) + v.feed("enter") + v.feed("up") +} |