mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
e50f73ec92
- [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
1464 lines
38 KiB
Go
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()
|
|
}
|