Files
kportal/internal/ui/wizard_handlers.go
T
lukaszraczylo bfe541565b feat(ui): toggle httpLog per-forward in add/edit wizard
Adds an HTTP-log enable toggle to the wizard's confirmation step so
users can flip httpLog on a forward without editing YAML by hand.

Behaviour:
- 'h' on the confirmation step toggles HTTPLog when not focused on
  the alias text input. When focus is on alias, 'h' is treated as
  text so users can still type aliases like 'host' or 'http-proxy'.
- The confirmation summary shows '[x] enabled' or '[ ] disabled'.
- New forwards: toggle on -> &HTTPLogSpec{Enabled: true}; off -> nil.
- Edit mode: pre-populates the toggle from the existing forward and
  preserves any advanced HTTPLog fields the user had configured in
  YAML (logFile, includeHeaders, maxBodySize, filterPath) by copying
  the original spec on save. Toggling off discards the advanced
  fields (consistent with 'absent in YAML = disabled').

State changes:
- ForwardStatus gains *config.HTTPLogSpec so the wizard can see the
  full original spec on edit.
- AddWizardState gains httpLog bool + httpLogOriginal *HTTPLogSpec.

Three new tests:
- TestHandleAddWizardKeys_HToggleHTTPLog
- TestHandleAddWizardKeys_HOnAliasFocusIsTextInput
- TestEditPrefill_PreservesHTTPLog
2026-05-06 12:41:36 +01:00

1504 lines
40 KiB
Go

package ui
import (
"context"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
// isFilterableStep returns true if the step supports search/filter
func isFilterableStep(step AddWizardStep) bool {
switch step {
case StepSelectContext, StepSelectNamespace:
return true
case StepEnterResource:
// Only service selection is filterable (pod prefix and selector are text input)
return true // We'll check resource type in the handler
default:
return false
}
}
// handleMainViewKeys handles keyboard input in the main view
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// If delete confirmation is showing, handle it separately
if m.ui.deleteConfirming {
return m.handleDeleteConfirmation(msg)
}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
m.ui.moveSelection(-1)
case "down", "j":
m.ui.moveSelection(1)
case "pgup", "ctrl+u":
m.ui.moveSelection(-10)
case "pgdown", "ctrl+d":
m.ui.moveSelection(10)
case " ", "enter":
m.ui.toggleSelected()
case "n": // Enter add wizard
m.ui.mu.Lock()
// Don't create a new wizard if one is already active
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
m.ui.mu.Unlock()
return m, nil
}
if m.ui.discovery == nil || m.ui.mutator == nil {
// Dependencies not set up
m.ui.mu.Unlock()
return m, nil
}
m.ui.viewMode = ViewModeAddWizard
m.ui.addWizard = newAddWizardState()
m.ui.addWizard.loading = true
m.ui.mu.Unlock()
// Load contexts
return m, loadContextsCmd(m.ui.discovery)
case "e": // Edit selected forward
m.ui.mu.Lock()
// Don't create a new wizard if one is already active
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
m.ui.mu.Unlock()
return m, nil
}
if len(m.ui.forwardOrder) == 0 {
// No forwards to edit
m.ui.mu.Unlock()
return m, nil
}
if m.ui.discovery == nil || m.ui.mutator == nil {
// Dependencies not set up
m.ui.mu.Unlock()
return m, nil
}
// Get the currently selected forward
currentSelectedIndex := m.ui.selectedIndex
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
m.ui.mu.Unlock()
return m, nil
}
selectedID := m.ui.forwardOrder[currentSelectedIndex]
selectedForward, ok := m.ui.forwards[selectedID]
if !ok {
m.ui.mu.Unlock()
return m, nil
}
// Create an add wizard pre-filled with the current forward's values
m.ui.viewMode = ViewModeAddWizard
m.ui.addWizard = newAddWizardState()
// Pre-fill the wizard with current values
m.ui.addWizard.selectedContext = selectedForward.Context
m.ui.addWizard.selectedNamespace = selectedForward.Namespace
m.ui.addWizard.resourceValue = selectedForward.Resource
m.ui.addWizard.remotePort = selectedForward.RemotePort
m.ui.addWizard.localPort = selectedForward.LocalPort
m.ui.addWizard.alias = selectedForward.Alias
m.ui.addWizard.httpLogOriginal = selectedForward.HTTPLog
m.ui.addWizard.httpLog = selectedForward.HTTPLog != nil && selectedForward.HTTPLog.Enabled
// Determine resource type from the resource string
if strings.HasPrefix(selectedForward.Type, "service") {
m.ui.addWizard.selectedResourceType = ResourceTypeService
} else {
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
}
// Mark as edit mode and store original ID
m.ui.addWizard.isEditing = true
m.ui.addWizard.originalID = selectedID
// Start at the remote port step (skip context/namespace/resource selection)
m.ui.addWizard.step = StepEnterRemotePort
// Load resources to detect ports
m.ui.addWizard.loading = true
m.ui.mu.Unlock()
// Load pods or services to detect available ports
if m.ui.addWizard.selectedResourceType == ResourceTypeService {
return m, loadServicesCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace)
}
return m, loadPodsCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace)
case "d": // Delete currently selected forward - show confirmation
m.ui.mu.Lock()
// Don't overwrite existing confirmation dialog
if m.ui.deleteConfirming {
m.ui.mu.Unlock()
return m, nil
}
if len(m.ui.forwardOrder) == 0 {
// No forwards to delete
m.ui.mu.Unlock()
return m, nil
}
if m.ui.mutator == nil {
// Dependencies not set up
m.ui.mu.Unlock()
return m, nil
}
// Get the currently selected forward
currentSelectedIndex := m.ui.selectedIndex
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
m.ui.mu.Unlock()
return m, nil
}
selectedID := m.ui.forwardOrder[currentSelectedIndex]
selectedForward, ok := m.ui.forwards[selectedID]
if !ok {
m.ui.mu.Unlock()
return m, nil
}
// Show confirmation dialog
m.ui.deleteConfirming = true
m.ui.deleteConfirmID = selectedID
m.ui.deleteConfirmAlias = selectedForward.Alias
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
m.ui.mu.Unlock()
return m, nil
case "b": // Benchmark selected forward
m.ui.mu.Lock()
// Don't create benchmark view if another modal is active
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
m.ui.mu.Unlock()
return m, nil
}
if len(m.ui.forwardOrder) == 0 {
m.ui.mu.Unlock()
return m, nil
}
currentSelectedIndex := m.ui.selectedIndex
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
m.ui.mu.Unlock()
return m, nil
}
selectedID := m.ui.forwardOrder[currentSelectedIndex]
selectedForward, ok := m.ui.forwards[selectedID]
if !ok {
m.ui.mu.Unlock()
return m, nil
}
// Create benchmark state
m.ui.viewMode = ViewModeBenchmark
m.ui.benchmarkState = newBenchmarkState(selectedID, selectedForward.Alias, selectedForward.LocalPort)
// Initialize textInput with the first field's value
m.ui.benchmarkState.textInput = m.ui.benchmarkState.urlPath
m.ui.mu.Unlock()
return m, nil
case "l": // View HTTP logs for selected forward
m.ui.mu.Lock()
// Don't create log view if another modal is active
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
m.ui.mu.Unlock()
return m, nil
}
if len(m.ui.forwardOrder) == 0 {
m.ui.mu.Unlock()
return m, nil
}
currentSelectedIndex := m.ui.selectedIndex
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
m.ui.mu.Unlock()
return m, nil
}
selectedID := m.ui.forwardOrder[currentSelectedIndex]
selectedForward, ok := m.ui.forwards[selectedID]
if !ok {
m.ui.mu.Unlock()
return m, nil
}
// Create HTTP log state
m.ui.viewMode = ViewModeHTTPLog
m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias)
// Capture subscriber and UI reference for the callback
subscriber := m.ui.httpLogSubscriber
ui := m.ui
m.ui.mu.Unlock()
// Subscribe to HTTP logs if subscriber is available
// This is done outside the lock to prevent deadlocks in the callback
if subscriber != nil {
cleanup := subscriber(selectedID, func(entry HTTPLogEntry) {
// Recover from panics in the callback
defer safeRecover("HTTPLogSubscriber callback")
// Use RLock to safely access program
ui.mu.RLock()
program := ui.program
ui.mu.RUnlock()
// Send entry to program (thread-safe via Send)
if program != nil {
program.Send(HTTPLogEntryMsg{Entry: entry})
}
})
ui.mu.Lock()
ui.httpLogCleanup = cleanup
ui.mu.Unlock()
}
return m, nil
}
return m, nil
}
// handleDeleteConfirmation handles keyboard input for delete confirmation dialog
func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
switch msg.String() {
case "ctrl+c", "esc":
// Cancel deletion
m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock()
return m, tea.ClearScreen
case "left", "h", "right", "l":
// Toggle between Yes/No
m.ui.deleteConfirmCursor = 1 - m.ui.deleteConfirmCursor
m.ui.mu.Unlock()
return m, nil
case "enter", "y":
// Confirm deletion (either Enter on Yes or pressing 'y')
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
id := m.ui.deleteConfirmID
m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock()
return m, removeForwardByIDCmd(m.ui.mutator, id)
}
// Enter on No = cancel
m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock()
return m, tea.ClearScreen
case "n":
// Quick 'n' for no
m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock()
return m, tea.ClearScreen
}
m.ui.mu.Unlock()
return m, nil
}
// handleAddWizardKeys handles keyboard input in the add wizard
func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
wizard := m.ui.addWizard
if wizard == nil {
return m, nil
}
switch msg.String() {
case "ctrl+c":
// Hard cancel
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
return m, tea.ClearScreen
case "esc":
// If there's an active search filter, clear it instead of going back
if wizard.searchFilter != "" && isFilterableStep(wizard.step) {
wizard.clearSearchFilter()
return m, nil
}
// In edit mode, Esc always cancels (don't navigate back through skipped steps)
if wizard.isEditing {
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
return m, tea.ClearScreen
}
// In add mode, go back or cancel
if wizard.step == StepSelectContext {
// On first step, cancel entirely
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
return m, tea.ClearScreen
} else {
// Go back one step
wizard.step--
wizard.resetInput()
// Reset input mode based on the step we're going back to
switch wizard.step {
case StepSelectContext, StepSelectNamespace, StepSelectResourceType:
wizard.inputMode = InputModeList
case StepEnterResource:
if wizard.selectedResourceType == ResourceTypeService {
wizard.inputMode = InputModeList
} else {
wizard.inputMode = InputModeText
}
case StepEnterRemotePort, StepEnterLocalPort:
wizard.inputMode = InputModeText
case StepConfirmation:
wizard.inputMode = InputModeList
}
}
return m, nil
case "up", "k":
// In confirmation step, toggle between alias and buttons
if wizard.step == StepConfirmation {
if wizard.confirmationFocus == FocusButtons {
wizard.confirmationFocus = FocusAlias
}
} else {
wizard.moveCursor(-1)
}
case "down", "j":
// In confirmation step, toggle between alias and buttons
if wizard.step == StepConfirmation {
if wizard.confirmationFocus == FocusAlias {
wizard.confirmationFocus = FocusButtons
wizard.cursor = 0
} else {
wizard.moveCursor(1) // Navigate between buttons
}
} else {
wizard.moveCursor(1)
}
case "pgup", "ctrl+u":
// Page up - move 10 items
wizard.moveCursor(-10)
case "pgdown", "ctrl+d":
// Page down - move 10 items
wizard.moveCursor(10)
case "tab":
// Tab moves between alias field and buttons in confirmation
if wizard.step == StepConfirmation {
if wizard.confirmationFocus == FocusAlias {
wizard.confirmationFocus = FocusButtons
wizard.cursor = 0
} else {
wizard.confirmationFocus = FocusAlias
}
}
case "h":
// In confirmation step (when not typing into the alias field), 'h'
// toggles whether this forward has HTTP traffic logging enabled.
// When the alias field is focused, fall through to text input below.
if wizard.step == StepConfirmation && wizard.confirmationFocus != FocusAlias {
wizard.httpLog = !wizard.httpLog
return m, nil
}
// Otherwise treat as text input (filter or alias).
canTypeText := wizard.inputMode == InputModeText ||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
if canTypeText {
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
wizard.searchFilter += "h"
wizard.cursor = 0
wizard.scrollOffset = 0
} else {
wizard.handleTextInput('h')
}
}
return m, nil
case "enter":
return m.handleAddWizardEnter()
case "backspace":
// Allow backspace in text input mode OR when focused on alias in confirmation OR when filtering
canBackspace := wizard.inputMode == InputModeText ||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step) && len(wizard.searchFilter) > 0)
if canBackspace {
if isFilterableStep(wizard.step) && wizard.inputMode == InputModeList && len(wizard.searchFilter) > 0 {
// Backspace in search filter
wizard.searchFilter = wizard.searchFilter[:len(wizard.searchFilter)-1]
wizard.cursor = 0
wizard.scrollOffset = 0
} else if len(wizard.textInput) > 0 {
wizard.textInput = wizard.textInput[:len(wizard.textInput)-1]
}
}
default:
// Handle text input
canTypeText := wizard.inputMode == InputModeText ||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
if canTypeText && len(msg.String()) == 1 {
// If in list mode on filterable step, add to search filter instead of textInput
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
char := rune(msg.String()[0])
// Only allow printable characters
if char >= 32 && char < 127 {
wizard.searchFilter += string(char)
wizard.cursor = 0
wizard.scrollOffset = 0
}
} else {
wizard.handleTextInput(rune(msg.String()[0]))
// Trigger validation for selector
if wizard.step == StepEnterResource && wizard.selectedResourceType == ResourceTypePodSelector {
if len(wizard.textInput) > 0 {
wizard.loading = true
wizard.error = nil
return m, validateSelectorCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace, wizard.textInput)
}
}
}
}
}
return m, nil
}
// handleAddWizardEnter handles Enter key in the add wizard
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
wizard := m.ui.addWizard
// Don't process Enter if we're currently loading
if wizard.loading {
return m, nil
}
switch wizard.step {
case StepSelectContext:
filteredContexts := wizard.getFilteredContexts()
if wizard.cursor >= 0 && wizard.cursor < len(filteredContexts) {
wizard.selectedContext = filteredContexts[wizard.cursor]
wizard.step = StepSelectNamespace
wizard.cursor = 0
wizard.clearSearchFilter()
wizard.loading = true
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
}
case StepSelectNamespace:
filteredNamespaces := wizard.getFilteredNamespaces()
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
wizard.step = StepSelectResourceType
wizard.cursor = 0
wizard.clearSearchFilter()
wizard.inputMode = InputModeList
}
case StepSelectResourceType:
if wizard.cursor >= 0 && wizard.cursor < 3 {
wizard.selectedResourceType = ResourceType(wizard.cursor)
wizard.step = StepEnterResource
wizard.cursor = 0
if wizard.selectedResourceType == ResourceTypeService {
wizard.inputMode = InputModeList
wizard.loading = true
return m, loadServicesCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace)
} else {
wizard.inputMode = InputModeText
wizard.loading = true
return m, loadPodsCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace)
}
}
case StepEnterResource:
switch wizard.selectedResourceType {
case ResourceTypePodPrefix:
if wizard.textInput != "" {
wizard.resourceValue = wizard.textInput
wizard.step = StepEnterRemotePort
wizard.clearTextInput()
// Detect ports from matching pods
wizard.detectedPorts = k8s.GetUniquePorts(wizard.pods)
if len(wizard.detectedPorts) > 0 {
wizard.inputMode = InputModeList
wizard.cursor = 0
} else {
wizard.inputMode = InputModeText
}
}
case ResourceTypePodSelector:
if wizard.textInput != "" && len(wizard.matchingPods) > 0 {
wizard.resourceValue = "pod"
wizard.selector = wizard.textInput
wizard.step = StepEnterRemotePort
wizard.clearTextInput()
// Detect ports from matching pods
wizard.detectedPorts = k8s.GetUniquePorts(wizard.matchingPods)
if len(wizard.detectedPorts) > 0 {
wizard.inputMode = InputModeList
wizard.cursor = 0
} else {
wizard.inputMode = InputModeText
}
}
case ResourceTypeService:
filteredServices := wizard.getFilteredServices()
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
wizard.resourceValue = filteredServices[wizard.cursor].Name
// Get ports from selected service (must do this BEFORE clearing search filter)
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
wizard.step = StepEnterRemotePort
wizard.clearTextInput()
wizard.clearSearchFilter()
if len(wizard.detectedPorts) > 0 {
wizard.inputMode = InputModeList
wizard.cursor = 0
} else {
wizard.inputMode = InputModeText
}
}
}
case StepEnterRemotePort:
if wizard.inputMode == InputModeList && len(wizard.detectedPorts) > 0 {
// List mode - user selected from detected ports
if wizard.cursor == len(wizard.detectedPorts) {
// Selected "Manual entry" option
wizard.inputMode = InputModeText
wizard.clearTextInput()
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
// Selected a detected port
// For services, use TargetPort (actual pod port) if available
// For pods, TargetPort is 0, so use Port (container port)
selectedPort := wizard.detectedPorts[wizard.cursor]
if selectedPort.TargetPort > 0 {
wizard.remotePort = int(selectedPort.TargetPort)
} else {
wizard.remotePort = int(selectedPort.Port)
}
wizard.step = StepEnterLocalPort
wizard.clearTextInput()
wizard.inputMode = InputModeText
wizard.error = nil
}
} else {
// Text mode - manual entry
port, err := strconv.Atoi(wizard.textInput)
if err != nil || !config.IsValidPort(port) {
wizard.error = fmt.Errorf("invalid port number")
} else {
wizard.remotePort = port
wizard.step = StepEnterLocalPort
wizard.clearTextInput()
wizard.error = nil
}
}
case StepEnterLocalPort:
port, err := strconv.Atoi(wizard.textInput)
if err != nil || !config.IsValidPort(port) {
wizard.error = fmt.Errorf("invalid port number")
} else {
// Check port availability before proceeding
wizard.localPort = port
wizard.loading = true
wizard.error = nil
return m, checkPortCmd(port, m.ui.configPath)
}
case StepConfirmation:
// If focused on alias field, move to buttons
if wizard.confirmationFocus == FocusAlias {
wizard.confirmationFocus = FocusButtons
wizard.cursor = 0
return m, nil
}
// Handle button selection
if wizard.cursor == 0 {
// Check if port is available before saving
if !wizard.portAvailable {
wizard.error = fmt.Errorf("port %d is not available. Please choose a different port", wizard.localPort)
return m, nil
}
// Confirmed - save the forward
wizard.alias = wizard.textInput
// Build the forward config
fwd := config.Forward{
Protocol: "tcp",
Port: wizard.remotePort,
LocalPort: wizard.localPort,
Alias: wizard.alias,
}
switch wizard.selectedResourceType {
case ResourceTypePodPrefix:
fwd.Resource = "pod/" + wizard.resourceValue
case ResourceTypePodSelector:
fwd.Resource = wizard.resourceValue
fwd.Selector = wizard.selector
case ResourceTypeService:
fwd.Resource = "service/" + wizard.resourceValue
}
// HTTPLog: when toggled on, preserve any advanced fields the
// user had configured in YAML (logFile, includeHeaders, etc.)
// so the wizard does not silently strip them. When toggled
// off, leave HTTPLog nil (= absent in YAML = disabled).
if wizard.httpLog {
if wizard.httpLogOriginal != nil {
spec := *wizard.httpLogOriginal
spec.Enabled = true
fwd.HTTPLog = &spec
} else {
fwd.HTTPLog = &config.HTTPLogSpec{Enabled: true}
}
}
wizard.loading = true
// If editing, use atomic update operation
if wizard.isEditing {
return m, updateForwardCmd(m.ui.mutator, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
}
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
} else {
// Cancelled - return to main view with screen clear
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
return m, tea.ClearScreen
}
case StepSuccess:
if wizard.cursor == 0 {
// Add another
m.ui.addWizard = newAddWizardState()
m.ui.addWizard.loading = true
return m, loadContextsCmd(m.ui.discovery)
} else {
// Return to main view with screen clear
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
return m, tea.ClearScreen
}
}
return m, nil
}
// handleRemoveWizardKeys handles keyboard input in the remove wizard
func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
wizard := m.ui.removeWizard
if wizard == nil {
return m, nil
}
switch msg.String() {
case "ctrl+c":
// Hard cancel - always exit
m.ui.viewMode = ViewModeMain
m.ui.removeWizard = nil
return m, tea.ClearScreen
case "esc":
if wizard.confirming {
// In confirmation mode, Esc cancels the confirmation (matches help text "Esc: Cancel")
// Returns to selection state without dispatching removal.
wizard.confirming = false
wizard.confirmCursor = 0
return m, nil
}
// Not confirming yet - cancel entirely
m.ui.viewMode = ViewModeMain
m.ui.removeWizard = nil
return m, tea.ClearScreen
case "up", "k":
wizard.moveCursor(-1)
case "down", "j":
wizard.moveCursor(1)
case "pgup", "ctrl+u":
wizard.moveCursor(-10)
case "pgdown", "ctrl+d":
wizard.moveCursor(10)
case " ":
if !wizard.confirming {
wizard.toggleSelection()
}
case "a":
wizard.selectAll()
case "n":
wizard.selectNone()
case "enter":
if !wizard.confirming {
if wizard.getSelectedCount() == 0 {
// Nothing selected
return m, nil
}
// Show confirmation
wizard.confirming = true
wizard.confirmCursor = 0
} else {
// Confirmed
if wizard.confirmCursor == 0 {
// Yes, remove
selectedForwards := wizard.getSelectedForwards()
return m, removeForwardsCmd(m.ui.mutator, selectedForwards)
} else {
// No, cancel
wizard.confirming = false
}
}
}
return m, nil
}
// Message handlers
func (m model) handleContextsLoaded(msg ContextsLoadedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
if msg.err == nil {
// Get current context and move it to the top (if discovery is available)
if m.ui.discovery != nil {
currentCtx, err := m.ui.discovery.GetCurrentContext()
if err == nil && currentCtx != "" {
// Reorder contexts with current first
reordered := []string{currentCtx}
for _, ctx := range msg.contexts {
if ctx != currentCtx {
reordered = append(reordered, ctx)
}
}
m.ui.addWizard.contexts = reordered
} else {
m.ui.addWizard.contexts = msg.contexts
}
} else {
m.ui.addWizard.contexts = msg.contexts
}
}
}
return m, nil
}
func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
if msg.err == nil {
m.ui.addWizard.namespaces = msg.namespaces
}
}
return m, nil
}
func (m model) handlePodsLoaded(msg PodsLoadedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
if msg.err == nil {
m.ui.addWizard.pods = msg.pods
// If we're at the remote port step (edit mode), detect ports now
if m.ui.addWizard.step == StepEnterRemotePort {
m.ui.addWizard.detectedPorts = k8s.GetUniquePorts(msg.pods)
if len(m.ui.addWizard.detectedPorts) > 0 {
m.ui.addWizard.inputMode = InputModeList
m.ui.addWizard.cursor = 0
} else {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort)
}
}
}
}
return m, nil
}
func (m model) handleServicesLoaded(msg ServicesLoadedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
if msg.err == nil {
m.ui.addWizard.services = msg.services
// If we're at the remote port step (edit mode), detect ports now
if m.ui.addWizard.step == StepEnterRemotePort {
// Find the service by name
for _, svc := range msg.services {
if svc.Name == m.ui.addWizard.resourceValue {
m.ui.addWizard.detectedPorts = svc.Ports
if len(m.ui.addWizard.detectedPorts) > 0 {
m.ui.addWizard.inputMode = InputModeList
m.ui.addWizard.cursor = 0
} else {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort)
}
break
}
}
}
}
}
return m, nil
}
func (m model) handleSelectorValidated(msg SelectorValidatedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
if msg.valid {
m.ui.addWizard.matchingPods = msg.pods
} else {
m.ui.addWizard.matchingPods = nil
}
}
return m, nil
}
func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.portAvailable = msg.available
m.ui.addWizard.portCheckMsg = msg.message
// Only proceed to confirmation if port is available
if msg.available {
m.ui.addWizard.step = StepConfirmation
m.ui.addWizard.clearTextInput()
m.ui.addWizard.cursor = 0
m.ui.addWizard.inputMode = InputModeList
} else {
// Port is not available - show error and stay on local port step
m.ui.addWizard.error = fmt.Errorf("port %d is in use, please choose another port", msg.port)
}
}
return m, nil
}
func (m model) handleForwardSaved(msg ForwardSavedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
if msg.success {
// Move to success step
m.ui.addWizard.step = StepSuccess
m.ui.addWizard.cursor = 0
m.ui.addWizard.inputMode = InputModeList
} else {
m.ui.addWizard.error = msg.err
}
}
return m, nil
}
func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
// Delete now happens directly without wizard
// Just ensure we're back in main view
m.ui.viewMode = ViewModeMain
m.ui.removeWizard = nil
// If there was an error, it will be logged but we don't show it in UI for now
// The config watcher will either reload (success) or keep old config (failure)
return m, tea.ClearScreen
}
// handleBenchmarkKeys handles keyboard input in the benchmark view
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
state := m.ui.benchmarkState
if state == nil {
return m, nil
}
switch msg.String() {
case "ctrl+c", "esc":
// Cancel the running benchmark if active
if state.cancelFunc != nil {
state.cancelFunc()
}
// Return to main view
m.ui.viewMode = ViewModeMain
m.ui.benchmarkState = nil
return m, tea.ClearScreen
case "up", "k":
if state.step == BenchmarkStepConfig && state.cursor > 0 {
state.cursor--
// Load current field value into textInput
state.textInput = m.getBenchmarkFieldValue(state.cursor)
}
case "down", "j":
if state.step == BenchmarkStepConfig && state.cursor < 3 {
state.cursor++
// Load current field value into textInput
state.textInput = m.getBenchmarkFieldValue(state.cursor)
}
case "tab":
// Tab also cycles through fields
if state.step == BenchmarkStepConfig {
state.cursor = (state.cursor + 1) % 4
state.textInput = m.getBenchmarkFieldValue(state.cursor)
}
case "enter":
switch state.step {
case BenchmarkStepConfig:
// Start running the benchmark
state.step = BenchmarkStepRunning
state.running = true
state.progress = 0
state.total = state.requests
// Create progress channel with buffer for non-blocking sends
state.progressCh = make(chan BenchmarkProgressMsg, 10)
// Create cancellable context for the benchmark
ctx, cancel := context.WithCancel(context.Background())
state.cancelFunc = cancel
// Return batch command to run benchmark and listen for progress
return m, tea.Batch(
runBenchmarkCmd(ctx, state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
listenBenchmarkProgressCmd(state.progressCh),
)
case BenchmarkStepResults:
// Return to main view
m.ui.viewMode = ViewModeMain
m.ui.benchmarkState = nil
return m, tea.ClearScreen
}
case "backspace":
if state.step == BenchmarkStepConfig {
if len(state.textInput) > 0 {
state.textInput = state.textInput[:len(state.textInput)-1]
m.applyBenchmarkTextInput()
}
}
default:
// Handle text input in config step
if state.step == BenchmarkStepConfig && len(msg.String()) == 1 {
char := rune(msg.String()[0])
if char >= 32 && char < 127 {
state.textInput += string(char)
m.applyBenchmarkTextInput()
}
}
}
return m, nil
}
// getBenchmarkFieldValue returns the current value of the selected benchmark field
func (m model) getBenchmarkFieldValue(cursor int) string {
state := m.ui.benchmarkState
if state == nil {
return ""
}
switch cursor {
case 0:
return state.urlPath
case 1:
return state.method
case 2:
return fmt.Sprintf("%d", state.concurrency)
case 3:
return fmt.Sprintf("%d", state.requests)
default:
return ""
}
}
// applyBenchmarkTextInput applies the current text input to the selected field
func (m model) applyBenchmarkTextInput() {
state := m.ui.benchmarkState
if state == nil {
return
}
switch state.cursor {
case 0: // URL path
state.urlPath = state.textInput
case 1: // Method
state.method = strings.ToUpper(state.textInput)
case 2: // Concurrency
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
state.concurrency = val
// Cap concurrency at requests
if state.concurrency > state.requests {
state.concurrency = state.requests
}
}
case 3: // Requests
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
state.requests = val
// Cap concurrency at requests
if state.concurrency > state.requests {
state.concurrency = state.requests
}
}
}
}
// handleHTTPLogKeys handles keyboard input in the HTTP log view
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
state := m.ui.httpLogState
if state == nil {
return m, nil
}
// If filter input is active, handle text input
if state.filterActive {
switch msg.String() {
case "esc":
// Cancel filter input, clear text
state.filterActive = false
state.filterText = ""
state.cursor = 0
state.scrollOffset = 0
return m, nil
case "enter":
// Confirm filter
state.filterActive = false
state.cursor = 0
state.scrollOffset = 0
return m, nil
case "backspace":
if len(state.filterText) > 0 {
state.filterText = state.filterText[:len(state.filterText)-1]
}
return m, nil
default:
// Add character to filter
if len(msg.String()) == 1 {
char := rune(msg.String()[0])
if char >= 32 && char < 127 {
state.filterText += string(char)
state.cursor = 0
state.scrollOffset = 0
}
}
return m, nil
}
}
filteredEntries := state.getFilteredEntries()
// If viewing detail, handle detail view keys
if state.showingDetail {
switch msg.String() {
case "esc", "q", "enter":
// Return to list view
state.showingDetail = false
state.detailScroll = 0
state.copyMessage = ""
return m, nil
case "up", "k":
if state.detailScroll > 0 {
state.detailScroll--
}
return m, nil
case "down", "j":
state.detailScroll++
return m, nil
case "pgup", "ctrl+u":
state.detailScroll -= 20
if state.detailScroll < 0 {
state.detailScroll = 0
}
return m, nil
case "pgdown", "ctrl+d":
state.detailScroll += 20
return m, nil
case "g":
state.detailScroll = 0
return m, nil
case "c":
// Copy response body to clipboard
if state.cursor >= 0 && state.cursor < len(filteredEntries) {
entry := filteredEntries[state.cursor]
if entry.ResponseBody != "" {
// Decompress if needed before copying
body := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
if err := copyToClipboard(body); err == nil {
state.copyMessage = "Copied!"
} else {
state.copyMessage = "Clipboard unavailable"
}
// Clear the message after 2 seconds
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return clearCopyMessageMsg{}
})
}
}
return m, nil
}
return m, nil
}
switch msg.String() {
case "ctrl+c", "esc", "q":
// Cleanup subscription before closing
if m.ui.httpLogCleanup != nil {
m.ui.httpLogCleanup()
m.ui.httpLogCleanup = nil
}
// Return to main view
m.ui.viewMode = ViewModeMain
m.ui.httpLogState = nil
return m, tea.ClearScreen
case "enter":
// Show detail view for selected entry
if len(filteredEntries) > 0 && state.cursor >= 0 && state.cursor < len(filteredEntries) {
state.showingDetail = true
state.detailScroll = 0
}
return m, nil
case "up", "k":
if state.cursor > 0 {
state.cursor--
state.autoScroll = false
}
case "down", "j":
if state.cursor < len(filteredEntries)-1 {
state.cursor++
}
// If at bottom, enable auto-scroll
if state.cursor >= len(filteredEntries)-1 {
state.autoScroll = true
}
case "pgup", "ctrl+u":
// Page up - move 20 entries
state.cursor -= 20
if state.cursor < 0 {
state.cursor = 0
}
state.autoScroll = false
case "pgdown", "ctrl+d":
// Page down - move 20 entries
state.cursor += 20
if state.cursor >= len(filteredEntries) {
state.cursor = len(filteredEntries) - 1
}
if state.cursor < 0 {
state.cursor = 0
}
// If at bottom, enable auto-scroll
if state.cursor >= len(filteredEntries)-1 {
state.autoScroll = true
}
case "g":
// Go to top
state.cursor = 0
state.scrollOffset = 0
state.autoScroll = false
case "G":
// Go to bottom
if len(filteredEntries) > 0 {
state.cursor = len(filteredEntries) - 1
state.autoScroll = true
}
case "a":
// Toggle auto-scroll
state.autoScroll = !state.autoScroll
case "f":
// Cycle filter mode (skip Text mode when cycling - use '/' for text filter)
state.filterMode = (state.filterMode + 1) % 4
if state.filterMode == HTTPLogFilterText {
// Skip Text mode when using 'f' - it's only accessible via '/'
state.filterMode = HTTPLogFilterNon200
}
state.cursor = 0
state.scrollOffset = 0
case "/":
// Enter text filter mode
state.filterActive = true
state.filterText = ""
case "c":
// Clear all filters
state.filterMode = HTTPLogFilterNone
state.filterText = ""
state.cursor = 0
state.scrollOffset = 0
}
return m, nil
}
// handleHTTPLogEntry handles incoming HTTP log entries
func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.httpLogState == nil {
return m, nil
}
state := m.ui.httpLogState
entry := msg.Entry
// If this is a response, try to find and merge with the matching request
if entry.Direction == "response" && entry.RequestID != "" {
// Search backwards (responses follow requests closely)
for i := len(state.entries) - 1; i >= 0 && i >= len(state.entries)-100; i-- {
if state.entries[i].RequestID == entry.RequestID && state.entries[i].Direction == "request" {
// Merge response data into the existing request entry
state.entries[i].Direction = "response"
state.entries[i].StatusCode = entry.StatusCode
state.entries[i].LatencyMs = entry.LatencyMs
state.entries[i].BodySize = entry.BodySize
state.entries[i].ResponseHeaders = entry.ResponseHeaders
state.entries[i].ResponseBody = entry.ResponseBody
state.entries[i].Error = entry.Error
return m, nil
}
}
}
// For requests or unmatched responses, append as new entry
state.entries = append(state.entries, entry)
// Cap entries to prevent memory growth (keep last 10000 entries)
const maxEntries = 10000
if len(state.entries) > maxEntries {
// Remove oldest entries
state.entries = state.entries[len(state.entries)-maxEntries:]
// Adjust cursor if needed
if state.cursor >= len(state.entries) {
state.cursor = len(state.entries) - 1
}
}
// Auto-scroll to bottom if enabled
if state.autoScroll && len(state.entries) > 0 {
filteredEntries := state.getFilteredEntries()
state.cursor = len(filteredEntries) - 1
if state.cursor < 0 {
state.cursor = 0
}
}
return m, nil
}
// handleBenchmarkProgress handles progress updates during benchmark execution
func (m model) handleBenchmarkProgress(msg BenchmarkProgressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.benchmarkState == nil || !m.ui.benchmarkState.running {
return m, nil
}
state := m.ui.benchmarkState
state.progress = msg.Completed
state.total = msg.Total
// Continue listening for more progress updates
if state.progressCh != nil {
return m, listenBenchmarkProgressCmd(state.progressCh)
}
return m, nil
}
// handleBenchmarkComplete handles the benchmark completion message
func (m model) handleBenchmarkComplete(msg BenchmarkCompleteMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
if m.ui.benchmarkState == nil {
return m, nil
}
state := m.ui.benchmarkState
state.running = false
state.step = BenchmarkStepResults
state.progressCh = nil // Clear progress channel since benchmark is complete
if msg.Error != nil {
state.error = msg.Error
state.results = nil
} else if msg.Results != nil {
stats := msg.Results.CalculateStats()
state.results = &BenchmarkResults{
TotalRequests: msg.Results.TotalRequests,
Successful: msg.Results.Successful,
Failed: msg.Results.Failed,
MinLatency: float64(stats.MinLatency.Milliseconds()),
MaxLatency: float64(stats.MaxLatency.Milliseconds()),
AvgLatency: float64(stats.AvgLatency.Milliseconds()),
P50Latency: float64(stats.P50Latency.Milliseconds()),
P95Latency: float64(stats.P95Latency.Milliseconds()),
P99Latency: float64(stats.P99Latency.Milliseconds()),
Throughput: stats.Throughput,
BytesRead: msg.Results.BytesRead,
StatusCodes: msg.Results.StatusCodes,
}
}
return m, nil
}
// copyToClipboard copies text to the system clipboard using OS-specific commands.
// This avoids CGO dependencies that cause issues in CI environments.
func copyToClipboard(text string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("pbcopy")
case "linux":
// Try xclip first, fall back to xsel
if _, err := exec.LookPath("xclip"); err == nil {
cmd = exec.Command("xclip", "-selection", "clipboard")
} else if _, err := exec.LookPath("xsel"); err == nil {
cmd = exec.Command("xsel", "--clipboard", "--input")
} else {
return fmt.Errorf("no clipboard tool found (install xclip or xsel)")
}
case "windows":
cmd = exec.Command("clip")
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
if _, err := stdin.Write([]byte(text)); err != nil {
return err
}
if err := stdin.Close(); err != nil {
return err
}
return cmd.Wait()
}