Files
kportal/internal/ui/wizard_handlers.go
T
lukaszraczylo e50f73ec92 chore: add golangci-lint v2 config and fix linter warnings (#46)
- [x] Add golangci-lint v2 configuration with formatters section
- [x] Reorganize linters-settings under linters section
- [x] Replace if-else chains with switch statements for clarity
- [x] Wrap all ignored error returns with `_ = ` pattern
- [x] Add OSC 8 hyperlink helper function for clickable ports
- [x] Add blank line in table styling function
- [x] Remove unnecessary type assertion in test
2026-02-13 18:46:27 +00:00

1464 lines
38 KiB
Go

package ui
import (
"context"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/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
// 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 "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
}
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 confirms the removal (same as pressing Yes)
selectedForwards := wizard.getSelectedForwards()
return m, removeForwardsCmd(m.ui.mutator, selectedForwards)
} else {
// 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()
}