mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-04 06:04:42 +00:00
style: Extract UI constants and refactor main view rendering (#30)
- [x] Add golangci-lint configuration with gocritic ifElseChain disabled
- [x] Rename error variables to avoid shadowing (createErr, watcherErr, watchErr, etc.)
- [x] Replace `interface{}` with `any` type alias throughout codebase
- [x] Add package-level documentation comments to all internal packages
- [x] Reorder struct fields alphabetically for consistency
- [x] Extract UI constants (terminal dimensions, column widths, colors) to constants.go
- [x] Refactor BubbleTeaUI main view rendering into smaller helper functions
- [x] Simplify nested conditionals and improve code clarity
- [x] Add `isForwardDisabled()` helper method to BubbleTeaUI
- [x] Update file permissions from 0644 to 0600 in config tests
- [x] Add `#nosec` comments and error suppression where appropriate
- [x] Improve test table struct field ordering for readability
- [x] Fix resource parsing in AddForward using strings.SplitN
- [x] Add comprehensive tests for new UI helper functions and constants
This commit is contained in:
+349
-236
@@ -1,3 +1,22 @@
|
||||
// Package ui provides the terminal user interface for kportal using bubbletea.
|
||||
// It displays port-forward status in an interactive table and provides wizards
|
||||
// for adding, editing, and removing forwards.
|
||||
//
|
||||
// The main components are:
|
||||
// - BubbleTeaUI: The interactive TUI with table display and modal dialogs
|
||||
// - TableUI: A simpler non-interactive status display for verbose mode
|
||||
// - Wizards: Step-by-step interfaces for configuration changes
|
||||
// - Controller: Coordinates UI with the forward manager
|
||||
//
|
||||
// Key bindings in the main view:
|
||||
// - ↑↓/jk: Navigate forwards
|
||||
// - Space: Toggle forward enabled/disabled
|
||||
// - n: New forward wizard
|
||||
// - e: Edit forward wizard
|
||||
// - d: Delete forward
|
||||
// - b: Benchmark forward
|
||||
// - l: View HTTP logs
|
||||
// - q: Quit
|
||||
package ui
|
||||
|
||||
import (
|
||||
@@ -35,8 +54,8 @@ type ForwardErrorMsg struct {
|
||||
|
||||
// ForwardAddMsg is sent when a new forward is added
|
||||
type ForwardAddMsg struct {
|
||||
ID string
|
||||
Forward *ForwardStatus
|
||||
ID string
|
||||
}
|
||||
|
||||
// ForwardRemoveMsg is sent when a forward is removed
|
||||
@@ -50,48 +69,32 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
|
||||
|
||||
// BubbleTeaUI is a bubbletea-based terminal UI
|
||||
type BubbleTeaUI struct {
|
||||
mu sync.RWMutex
|
||||
program *tea.Program
|
||||
forwards map[string]*ForwardStatus
|
||||
forwardOrder []string
|
||||
selectedIndex int
|
||||
disabledMap map[string]bool
|
||||
toggleCallback func(id string, enable bool)
|
||||
version string
|
||||
errors map[string]string // Track error messages by forward ID
|
||||
|
||||
// Update notification
|
||||
updateAvailable bool
|
||||
updateVersion string
|
||||
updateURL string
|
||||
|
||||
// Modal wizard state
|
||||
viewMode ViewMode
|
||||
addWizard *AddWizardState
|
||||
removeWizard *RemoveWizardState
|
||||
|
||||
// Delete confirmation state
|
||||
deleteConfirming bool
|
||||
discovery *k8s.Discovery
|
||||
program *tea.Program
|
||||
forwards map[string]*ForwardStatus
|
||||
benchmarkState *BenchmarkState
|
||||
httpLogSubscriber HTTPLogSubscriber
|
||||
disabledMap map[string]bool
|
||||
toggleCallback func(id string, enable bool)
|
||||
httpLogCleanup func()
|
||||
httpLogState *HTTPLogState
|
||||
errors map[string]string
|
||||
mutator *config.Mutator
|
||||
removeWizard *RemoveWizardState
|
||||
addWizard *AddWizardState
|
||||
updateVersion string
|
||||
updateURL string
|
||||
configPath string
|
||||
deleteConfirmID string
|
||||
deleteConfirmAlias string
|
||||
deleteConfirmCursor int // 0 = Yes, 1 = No
|
||||
|
||||
// Benchmark state
|
||||
benchmarkState *BenchmarkState
|
||||
|
||||
// HTTP log viewing state
|
||||
httpLogState *HTTPLogState
|
||||
|
||||
// Log callback cleanup function
|
||||
httpLogCleanup func()
|
||||
|
||||
// Dependencies for wizards
|
||||
discovery *k8s.Discovery
|
||||
mutator *config.Mutator
|
||||
configPath string
|
||||
|
||||
// Manager for accessing workers
|
||||
httpLogSubscriber HTTPLogSubscriber
|
||||
version string
|
||||
forwardOrder []string
|
||||
viewMode ViewMode
|
||||
deleteConfirmCursor int
|
||||
selectedIndex int
|
||||
mu sync.RWMutex
|
||||
deleteConfirming bool
|
||||
updateAvailable bool
|
||||
}
|
||||
|
||||
// bubbletea model
|
||||
@@ -168,6 +171,8 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
|
||||
if existing, ok := ui.forwards[id]; ok {
|
||||
existing.Status = "Starting"
|
||||
ui.disabledMap[id] = false
|
||||
// Clear any previous error when re-enabling
|
||||
delete(ui.errors, id)
|
||||
ui.mu.Unlock()
|
||||
|
||||
if ui.program != nil {
|
||||
@@ -176,15 +181,12 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse resource
|
||||
// Parse resource (e.g., "pod/my-app" -> type="pod", name="my-app")
|
||||
resourceType := "pod"
|
||||
resourceName := fwd.Resource
|
||||
for idx := 0; idx < len(fwd.Resource); idx++ {
|
||||
if fwd.Resource[idx] == '/' {
|
||||
resourceType = fwd.Resource[:idx]
|
||||
resourceName = fwd.Resource[idx+1:]
|
||||
break
|
||||
}
|
||||
if parts := strings.SplitN(fwd.Resource, "/", 2); len(parts) == 2 {
|
||||
resourceType = parts[0]
|
||||
resourceName = parts[1]
|
||||
}
|
||||
|
||||
alias := fwd.Alias
|
||||
@@ -380,10 +382,10 @@ func (m model) View() string {
|
||||
|
||||
// Fallback to reasonable defaults if dimensions not yet received
|
||||
if termWidth == 0 {
|
||||
termWidth = 120
|
||||
termWidth = DefaultTermWidth
|
||||
}
|
||||
if termHeight == 0 {
|
||||
termHeight = 40
|
||||
termHeight = DefaultTermHeight
|
||||
}
|
||||
|
||||
// Overlay delete confirmation if active
|
||||
@@ -411,28 +413,98 @@ func (m model) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
// mainViewColors holds the color palette for the main view
|
||||
type mainViewColors struct {
|
||||
header lipgloss.Color
|
||||
active lipgloss.Color
|
||||
warning lipgloss.Color
|
||||
errorColor lipgloss.Color
|
||||
muted lipgloss.Color
|
||||
selectedBg lipgloss.Color
|
||||
selectedFg lipgloss.Color
|
||||
}
|
||||
|
||||
// defaultMainViewColors returns the default color palette
|
||||
func defaultMainViewColors() mainViewColors {
|
||||
return mainViewColors{
|
||||
header: lipgloss.Color("220"), // Yellow
|
||||
active: lipgloss.Color("46"), // Green
|
||||
warning: lipgloss.Color("220"), // Yellow
|
||||
errorColor: lipgloss.Color("196"), // Red
|
||||
muted: lipgloss.Color("240"), // Gray
|
||||
selectedBg: lipgloss.Color("240"), // Gray background
|
||||
selectedFg: lipgloss.Color("230"), // Light foreground
|
||||
}
|
||||
}
|
||||
|
||||
// keyBinding represents a keyboard shortcut and its description
|
||||
type keyBinding struct {
|
||||
key string
|
||||
desc string
|
||||
}
|
||||
|
||||
// mainViewKeyBindings returns the key bindings for the main view
|
||||
func mainViewKeyBindings() []keyBinding {
|
||||
return []keyBinding{
|
||||
{"↑↓/jk", "Navigate"},
|
||||
{"Space", "Toggle"},
|
||||
{"n", "New"},
|
||||
{"e", "Edit"},
|
||||
{"d", "Delete"},
|
||||
{"b", "Bench"},
|
||||
{"l", "Logs"},
|
||||
{"q", "Quit"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) renderMainView() string {
|
||||
m.ui.mu.RLock()
|
||||
defer m.ui.mu.RUnlock()
|
||||
|
||||
var b strings.Builder
|
||||
colors := defaultMainViewColors()
|
||||
|
||||
// Get terminal dimensions for proper sizing
|
||||
termHeight := m.termHeight
|
||||
if termHeight == 0 {
|
||||
termHeight = 40 // Fallback
|
||||
termWidth, termHeight := m.getTermDimensions()
|
||||
|
||||
// Render title header
|
||||
b.WriteString(m.renderTitle(colors.header))
|
||||
|
||||
// Render forwards table or empty message
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
b.WriteString(m.renderEmptyMessage(colors.muted))
|
||||
} else {
|
||||
b.WriteString(m.renderForwardsTable(colors))
|
||||
}
|
||||
|
||||
// Color palette
|
||||
headerColor := lipgloss.Color("220") // Yellow
|
||||
activeColor := lipgloss.Color("46") // Green
|
||||
warningColor := lipgloss.Color("220") // Yellow
|
||||
errorColor := lipgloss.Color("196") // Red
|
||||
mutedColor := lipgloss.Color("240") // Gray
|
||||
selectedBg := lipgloss.Color("240") // Gray background
|
||||
selectedFg := lipgloss.Color("230") // Light foreground
|
||||
// Render error section if any errors exist
|
||||
if len(m.ui.errors) > 0 {
|
||||
b.WriteString(m.renderErrorSection())
|
||||
}
|
||||
|
||||
// Render footer with proper spacing
|
||||
b.WriteString(m.renderFooterWithSpacing(termWidth, termHeight, &b))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getTermDimensions returns terminal dimensions with fallback defaults
|
||||
func (m model) getTermDimensions() (width, height int) {
|
||||
width = m.termWidth
|
||||
height = m.termHeight
|
||||
if width == 0 {
|
||||
width = DefaultTermWidth
|
||||
}
|
||||
if height == 0 {
|
||||
height = DefaultTermHeight
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// renderTitle renders the title bar with version and optional update notification
|
||||
func (m model) renderTitle(headerColor lipgloss.Color) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Title with version
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(headerColor).
|
||||
@@ -451,180 +523,222 @@ func (m model) renderMainView() string {
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// No forwards
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||
b.WriteString(disabledStyle.Render("No forwards configured\n"))
|
||||
} else {
|
||||
// Build table rows
|
||||
var rows [][]string
|
||||
for _, id := range m.ui.forwardOrder {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderEmptyMessage renders the message shown when no forwards are configured
|
||||
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
|
||||
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||
return disabledStyle.Render("No forwards configured\n")
|
||||
}
|
||||
|
||||
// renderForwardsTable renders the forwards table with all styling
|
||||
func (m model) renderForwardsTable(colors mainViewColors) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Build table rows
|
||||
rows := m.buildTableRows()
|
||||
|
||||
// Create table with styling (no borders for cleaner look)
|
||||
t := table.New().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
||||
Rows(rows...).
|
||||
StyleFunc(m.createTableStyleFunc(colors))
|
||||
|
||||
b.WriteString(t.Render())
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildTableRows builds the data rows for the forwards table
|
||||
func (m model) buildTableRows() [][]string {
|
||||
var rows [][]string
|
||||
|
||||
for _, id := range m.ui.forwardOrder {
|
||||
fwd, ok := m.ui.forwards[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
|
||||
|
||||
rows = append(rows, []string{
|
||||
truncate(fwd.Context, ColumnWidthContext),
|
||||
truncate(fwd.Namespace, ColumnWidthNamespace),
|
||||
truncate(fwd.Alias, ColumnWidthAlias),
|
||||
truncate(fwd.Type, ColumnWidthType),
|
||||
truncate(fwd.Resource, ColumnWidthResource),
|
||||
fmt.Sprintf("%d", fwd.RemotePort),
|
||||
fmt.Sprintf("%d", fwd.LocalPort),
|
||||
statusIcon + " " + statusText,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// getStatusIconAndText returns the appropriate status icon and text for a forward
|
||||
func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text string) {
|
||||
icon = "●"
|
||||
text = fwd.Status
|
||||
|
||||
if m.ui.isForwardDisabled(id) {
|
||||
return "○", "Disabled"
|
||||
}
|
||||
|
||||
switch fwd.Status {
|
||||
case "Starting":
|
||||
icon = "○"
|
||||
case "Reconnecting":
|
||||
icon = "◐"
|
||||
case "Error":
|
||||
icon = "✗"
|
||||
}
|
||||
|
||||
return icon, text
|
||||
}
|
||||
|
||||
// createTableStyleFunc creates the style function for the forwards table
|
||||
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
|
||||
return func(row, col int) lipgloss.Style {
|
||||
// Header row
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colors.header).
|
||||
Padding(0, 1)
|
||||
}
|
||||
|
||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
if row >= 0 && row < len(m.ui.forwardOrder) {
|
||||
id := m.ui.forwardOrder[row]
|
||||
fwd, ok := m.ui.forwards[id]
|
||||
if !ok {
|
||||
continue
|
||||
isSelected := row == m.ui.selectedIndex
|
||||
isDisabled := m.ui.isForwardDisabled(id)
|
||||
|
||||
// Selected row gets background highlight
|
||||
if isSelected {
|
||||
return baseStyle.
|
||||
Background(colors.selectedBg).
|
||||
Foreground(colors.selectedFg)
|
||||
}
|
||||
|
||||
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
||||
|
||||
// Status icon and text
|
||||
statusIcon := "●"
|
||||
statusText := fwd.Status
|
||||
|
||||
// Disabled rows are muted
|
||||
if isDisabled {
|
||||
statusIcon = "○"
|
||||
statusText = "Disabled"
|
||||
} else {
|
||||
return baseStyle.Foreground(colors.muted)
|
||||
}
|
||||
|
||||
// Status column gets colored based on status
|
||||
if col == ColumnStatus && ok {
|
||||
switch fwd.Status {
|
||||
case "Starting":
|
||||
statusIcon = "○"
|
||||
case "Reconnecting":
|
||||
statusIcon = "◐"
|
||||
case "Active":
|
||||
return baseStyle.Foreground(colors.active)
|
||||
case "Starting", "Reconnecting":
|
||||
return baseStyle.Foreground(colors.warning)
|
||||
case "Error":
|
||||
statusIcon = "✗"
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
truncate(fwd.Context, 14),
|
||||
truncate(fwd.Namespace, 16),
|
||||
truncate(fwd.Alias, 18),
|
||||
truncate(fwd.Type, 8),
|
||||
truncate(fwd.Resource, 20),
|
||||
fmt.Sprintf("%d", fwd.RemotePort),
|
||||
fmt.Sprintf("%d", fwd.LocalPort),
|
||||
statusIcon + " " + statusText,
|
||||
})
|
||||
}
|
||||
|
||||
// Create table with styling (no borders for cleaner look)
|
||||
t := table.New().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
||||
Rows(rows...).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
// Header row
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(headerColor).
|
||||
Padding(0, 1)
|
||||
}
|
||||
|
||||
// Get the forward for this row to check its status
|
||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
if row >= 0 && row < len(m.ui.forwardOrder) {
|
||||
id := m.ui.forwardOrder[row]
|
||||
fwd, ok := m.ui.forwards[id]
|
||||
isSelected := row == m.ui.selectedIndex
|
||||
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
|
||||
|
||||
// Selected row gets background highlight
|
||||
if isSelected {
|
||||
return baseStyle.
|
||||
Background(selectedBg).
|
||||
Foreground(selectedFg)
|
||||
}
|
||||
|
||||
// Disabled rows are muted
|
||||
if isDisabled {
|
||||
return baseStyle.Foreground(mutedColor)
|
||||
}
|
||||
|
||||
// Status column gets colored based on status
|
||||
if col == 7 && ok { // STATUS column
|
||||
switch fwd.Status {
|
||||
case "Active":
|
||||
return baseStyle.Foreground(activeColor)
|
||||
case "Starting", "Reconnecting":
|
||||
return baseStyle.Foreground(warningColor)
|
||||
case "Error":
|
||||
return baseStyle.Foreground(errorColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
b.WriteString(t.Render())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Display errors if any (before footer)
|
||||
if len(m.ui.errors) > 0 {
|
||||
b.WriteString("\n\n")
|
||||
errorHeaderStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("196"))
|
||||
|
||||
b.WriteString(errorHeaderStyle.Render("Errors:"))
|
||||
b.WriteString("\n")
|
||||
|
||||
errorLineStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Width(118). // Slightly less than table width (120) for padding
|
||||
MaxWidth(118)
|
||||
|
||||
for id, errMsg := range m.ui.errors {
|
||||
// Find the forward to display its alias
|
||||
if fwd, ok := m.ui.forwards[id]; ok {
|
||||
// Format: " • alias: error message"
|
||||
prefix := fmt.Sprintf(" • %s: ", fwd.Alias)
|
||||
|
||||
// Wrap the error message if it's too long
|
||||
// Max line length is 118, subtract prefix length
|
||||
maxErrLen := 118 - len(prefix)
|
||||
wrappedMsg := wrapText(errMsg, maxErrLen)
|
||||
|
||||
// Render first line with prefix
|
||||
lines := strings.Split(wrappedMsg, "\n")
|
||||
if len(lines) > 0 {
|
||||
b.WriteString(errorLineStyle.Render(prefix + lines[0]))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Render subsequent lines with indentation
|
||||
indent := strings.Repeat(" ", len(prefix))
|
||||
for i := 1; i < len(lines); i++ {
|
||||
b.WriteString(errorLineStyle.Render(indent + lines[i]))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return baseStyle.Foreground(colors.errorColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
}
|
||||
}
|
||||
|
||||
// renderErrorSection renders the error display section
|
||||
func (m model) renderErrorSection() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\n\n")
|
||||
errorHeaderStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("196"))
|
||||
|
||||
b.WriteString(errorHeaderStyle.Render("Errors:"))
|
||||
b.WriteString("\n")
|
||||
|
||||
errorLineStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Width(ErrorDisplayWidth).
|
||||
MaxWidth(ErrorDisplayWidth)
|
||||
|
||||
for id, errMsg := range m.ui.errors {
|
||||
// Find the forward to display its alias
|
||||
if fwd, ok := m.ui.forwards[id]; ok {
|
||||
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, errorLineStyle))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderErrorLine renders a single error line with proper wrapping
|
||||
func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Format: " • alias: error message"
|
||||
prefix := fmt.Sprintf(" • %s: ", alias)
|
||||
|
||||
// Wrap the error message if it's too long
|
||||
maxErrLen := ErrorDisplayWidth - len(prefix)
|
||||
wrappedMsg := wrapText(errMsg, maxErrLen)
|
||||
|
||||
// Render first line with prefix
|
||||
lines := strings.Split(wrappedMsg, "\n")
|
||||
if len(lines) > 0 {
|
||||
b.WriteString(style.Render(prefix + lines[0]))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Render subsequent lines with indentation
|
||||
indent := strings.Repeat(" ", len(prefix))
|
||||
for i := 1; i < len(lines); i++ {
|
||||
b.WriteString(style.Render(indent + lines[i]))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderFooterWithSpacing renders the footer with proper vertical spacing
|
||||
func (m model) renderFooterWithSpacing(termWidth, termHeight int, content *strings.Builder) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Calculate current content height
|
||||
currentContent := b.String()
|
||||
currentContent := content.String()
|
||||
currentLines := strings.Count(currentContent, "\n") + 1
|
||||
|
||||
// Footer styles
|
||||
// Build footer content
|
||||
footerLines := m.buildFooterLines(termWidth)
|
||||
|
||||
// Calculate footer height and add spacing
|
||||
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
|
||||
remainingLines := termHeight - currentLines - footerHeight
|
||||
if remainingLines > 0 {
|
||||
b.WriteString(strings.Repeat("\n", remainingLines))
|
||||
}
|
||||
|
||||
// Add footer at bottom
|
||||
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
b.WriteString("\n")
|
||||
for i, line := range footerLines {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(footerStyle.Render(line))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildFooterLines builds the footer lines that fit within terminal width
|
||||
func (m model) buildFooterLines(termWidth int) []string {
|
||||
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||
bindings := mainViewKeyBindings()
|
||||
|
||||
// Get terminal width for footer wrapping
|
||||
termWidth := m.termWidth
|
||||
if termWidth == 0 {
|
||||
termWidth = 120
|
||||
}
|
||||
|
||||
// Define key bindings as structured data for flexible rendering
|
||||
type keyBinding struct {
|
||||
key string
|
||||
desc string
|
||||
}
|
||||
bindings := []keyBinding{
|
||||
{"↑↓/jk", "Navigate"},
|
||||
{"Space", "Toggle"},
|
||||
{"n", "New"},
|
||||
{"e", "Edit"},
|
||||
{"d", "Delete"},
|
||||
{"b", "Bench"},
|
||||
{"l", "Logs"},
|
||||
{"q", "Quit"},
|
||||
}
|
||||
|
||||
// Build footer lines that fit within terminal width
|
||||
var footerLines []string
|
||||
var currentLine strings.Builder
|
||||
currentLineVisualLen := 0
|
||||
@@ -676,23 +790,7 @@ func (m model) renderMainView() string {
|
||||
currentLine.WriteString(totalSuffix)
|
||||
footerLines = append(footerLines, currentLine.String())
|
||||
|
||||
// Calculate footer height
|
||||
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
|
||||
remainingLines := termHeight - currentLines - footerHeight
|
||||
if remainingLines > 0 {
|
||||
b.WriteString(strings.Repeat("\n", remainingLines))
|
||||
}
|
||||
|
||||
// Add footer at bottom
|
||||
b.WriteString("\n")
|
||||
for i, line := range footerLines {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(footerStyle.Render(line))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
return footerLines
|
||||
}
|
||||
|
||||
// wrapText wraps text to the specified width, breaking at word boundaries
|
||||
@@ -835,3 +933,18 @@ func (ui *BubbleTeaUI) toggleSelected() {
|
||||
go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled
|
||||
}
|
||||
}
|
||||
|
||||
// isForwardDisabled checks if a forward is disabled.
|
||||
// A forward is considered disabled if either:
|
||||
// 1. The user has disabled it via the UI (tracked in disabledMap)
|
||||
// 2. The forward's status is "Disabled" (from the manager)
|
||||
// Caller must hold ui.mu.RLock or ui.mu.Lock.
|
||||
func (ui *BubbleTeaUI) isForwardDisabled(id string) bool {
|
||||
if ui.disabledMap[id] {
|
||||
return true
|
||||
}
|
||||
if fwd, ok := ui.forwards[id]; ok && fwd.Status == "Disabled" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -243,9 +243,9 @@ func TestBubbleTeaUI_Remove_ClearsErrors(t *testing.T) {
|
||||
func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
removeID string
|
||||
forwards []string
|
||||
selectedIndex int
|
||||
removeID string
|
||||
expectedIndex int
|
||||
expectedRemaining int
|
||||
}{
|
||||
@@ -527,3 +527,256 @@ func TestBubbleTeaUI_ResetDeleteConfirmation(t *testing.T) {
|
||||
assert.Empty(t, ui.deleteConfirmAlias)
|
||||
assert.Equal(t, 0, ui.deleteConfirmCursor)
|
||||
}
|
||||
|
||||
// TestBubbleTeaUI_IsForwardDisabled tests the disabled state helper
|
||||
func TestBubbleTeaUI_IsForwardDisabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwardStatus string
|
||||
disabledMap bool
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
name: "not disabled in map, Active status",
|
||||
disabledMap: false,
|
||||
forwardStatus: "Active",
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "disabled in map, Active status",
|
||||
disabledMap: true,
|
||||
forwardStatus: "Active",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "not disabled in map, Disabled status",
|
||||
disabledMap: false,
|
||||
forwardStatus: "Disabled",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "both disabled in map and Disabled status",
|
||||
disabledMap: true,
|
||||
forwardStatus: "Disabled",
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "not disabled in map, Error status",
|
||||
disabledMap: false,
|
||||
forwardStatus: "Error",
|
||||
expectedResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
ui.mu.Lock()
|
||||
ui.disabledMap["test-id"] = tt.disabledMap
|
||||
ui.forwards["test-id"].Status = tt.forwardStatus
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.mu.RLock()
|
||||
result := ui.isForwardDisabled("test-id")
|
||||
ui.mu.RUnlock()
|
||||
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBubbleTeaUI_IsForwardDisabled_NonExistent tests disabled check for non-existent forward
|
||||
func TestBubbleTeaUI_IsForwardDisabled_NonExistent(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
ui.mu.RLock()
|
||||
result := ui.isForwardDisabled("non-existent")
|
||||
ui.mu.RUnlock()
|
||||
|
||||
assert.False(t, result, "Non-existent forward should not be disabled")
|
||||
}
|
||||
|
||||
// TestBubbleTeaUI_AddForward_ReEnableClearsError tests that re-enabling clears previous errors
|
||||
func TestBubbleTeaUI_AddForward_ReEnableClearsError(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
|
||||
// Add forward
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
// Set error and disable
|
||||
ui.SetError("test-id", "connection refused")
|
||||
ui.mu.Lock()
|
||||
ui.disabledMap["test-id"] = true
|
||||
ui.forwards["test-id"].Status = "Disabled"
|
||||
ui.mu.Unlock()
|
||||
|
||||
// Verify error exists
|
||||
ui.mu.RLock()
|
||||
_, hasError := ui.errors["test-id"]
|
||||
ui.mu.RUnlock()
|
||||
assert.True(t, hasError, "Error should exist before re-enable")
|
||||
|
||||
// Re-enable (re-add)
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
// Verify error is cleared
|
||||
ui.mu.RLock()
|
||||
_, hasError = ui.errors["test-id"]
|
||||
ui.mu.RUnlock()
|
||||
assert.False(t, hasError, "Error should be cleared after re-enable")
|
||||
}
|
||||
|
||||
// TestWrapText tests the text wrapping function
|
||||
func TestWrapText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
expected string
|
||||
width int
|
||||
}{
|
||||
{
|
||||
name: "short text fits",
|
||||
text: "hello world",
|
||||
width: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "single long word",
|
||||
text: "superlongwordthatexceedswidth",
|
||||
width: 10,
|
||||
expected: "superlongwordthatexceedswidth",
|
||||
},
|
||||
{
|
||||
name: "wraps at word boundary",
|
||||
text: "hello world this is a test",
|
||||
width: 15,
|
||||
expected: "hello world\nthis is a test",
|
||||
},
|
||||
{
|
||||
name: "multiple wraps",
|
||||
text: "one two three four five six",
|
||||
width: 10,
|
||||
expected: "one two\nthree four\nfive six",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
text: "",
|
||||
width: 10,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single word",
|
||||
text: "hello",
|
||||
width: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "exact width",
|
||||
text: "hello wor",
|
||||
width: 9,
|
||||
expected: "hello wor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := wrapText(tt.text, tt.width)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBubbleTeaUI_AddForward_ResourceParsing tests various resource format parsing
|
||||
func TestBubbleTeaUI_AddForward_ResourceParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
expectedType string
|
||||
expectedName string
|
||||
}{
|
||||
{
|
||||
name: "pod with prefix",
|
||||
resource: "pod/my-app",
|
||||
expectedType: "pod",
|
||||
expectedName: "my-app",
|
||||
},
|
||||
{
|
||||
name: "service resource",
|
||||
resource: "service/postgres",
|
||||
expectedType: "service",
|
||||
expectedName: "postgres",
|
||||
},
|
||||
{
|
||||
name: "deployment resource",
|
||||
resource: "deployment/api-server",
|
||||
expectedType: "deployment",
|
||||
expectedName: "api-server",
|
||||
},
|
||||
{
|
||||
name: "no type prefix (pod default)",
|
||||
resource: "my-pod",
|
||||
expectedType: "pod",
|
||||
expectedName: "my-pod",
|
||||
},
|
||||
{
|
||||
name: "resource with multiple slashes",
|
||||
resource: "custom/type/resource",
|
||||
expectedType: "custom",
|
||||
expectedName: "type/resource",
|
||||
},
|
||||
{
|
||||
name: "empty resource",
|
||||
resource: "",
|
||||
expectedType: "pod",
|
||||
expectedName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
fwd := &config.Forward{
|
||||
Resource: tt.resource,
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
ui.mu.RLock()
|
||||
status := ui.forwards["test-id"]
|
||||
ui.mu.RUnlock()
|
||||
|
||||
assert.Equal(t, tt.expectedType, status.Type)
|
||||
assert.Equal(t, tt.expectedName, status.Resource)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConstants tests that UI constants are properly defined
|
||||
func TestConstants(t *testing.T) {
|
||||
assert.Equal(t, 120, DefaultTermWidth)
|
||||
assert.Equal(t, 40, DefaultTermHeight)
|
||||
assert.Equal(t, 7, ColumnStatus)
|
||||
assert.Equal(t, 14, ColumnWidthContext)
|
||||
assert.Equal(t, 16, ColumnWidthNamespace)
|
||||
assert.Equal(t, 18, ColumnWidthAlias)
|
||||
assert.Equal(t, 8, ColumnWidthType)
|
||||
assert.Equal(t, 20, ColumnWidthResource)
|
||||
assert.Equal(t, 118, ErrorDisplayWidth)
|
||||
assert.Equal(t, 20, ViewportHeight)
|
||||
}
|
||||
|
||||
@@ -82,13 +82,16 @@ func TestMessageTypes(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, 8080, availableMsg.port)
|
||||
assert.True(t, availableMsg.available)
|
||||
assert.Equal(t, "Port 8080 available", availableMsg.message)
|
||||
|
||||
unavailableMsg := PortCheckedMsg{
|
||||
port: 8080,
|
||||
available: false,
|
||||
message: "Port 8080 in use by process",
|
||||
}
|
||||
assert.Equal(t, 8080, unavailableMsg.port)
|
||||
assert.False(t, unavailableMsg.available)
|
||||
assert.Equal(t, "Port 8080 in use by process", unavailableMsg.message)
|
||||
})
|
||||
|
||||
t.Run("ForwardSavedMsg", func(t *testing.T) {
|
||||
@@ -117,10 +120,10 @@ func TestMessageTypes(t *testing.T) {
|
||||
t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
|
||||
msg := BenchmarkCompleteMsg{
|
||||
ForwardID: "fwd-123",
|
||||
Results: nil,
|
||||
Error: nil,
|
||||
}
|
||||
assert.Equal(t, "fwd-123", msg.ForwardID)
|
||||
assert.Nil(t, msg.Results)
|
||||
assert.Nil(t, msg.Error)
|
||||
})
|
||||
|
||||
t.Run("BenchmarkProgressMsg", func(t *testing.T) {
|
||||
@@ -256,7 +259,7 @@ func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
|
||||
|
||||
// Run with timeout to prevent hanging
|
||||
done := make(chan bool, 1)
|
||||
var msg interface{}
|
||||
var msg any
|
||||
go func() {
|
||||
msg = cmd()
|
||||
done <- true
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package ui
|
||||
|
||||
// Terminal dimension constants
|
||||
const (
|
||||
// DefaultTermWidth is the fallback terminal width when not detected
|
||||
DefaultTermWidth = 120
|
||||
|
||||
// DefaultTermHeight is the fallback terminal height when not detected
|
||||
DefaultTermHeight = 40
|
||||
)
|
||||
|
||||
// Table column constants
|
||||
const (
|
||||
// Column indices in the forwards table
|
||||
ColumnContext = 0
|
||||
ColumnNamespace = 1
|
||||
ColumnAlias = 2
|
||||
ColumnType = 3
|
||||
ColumnResource = 4
|
||||
ColumnRemote = 5
|
||||
ColumnLocal = 6
|
||||
ColumnStatus = 7
|
||||
|
||||
// Column widths for truncation
|
||||
ColumnWidthContext = 14
|
||||
ColumnWidthNamespace = 16
|
||||
ColumnWidthAlias = 18
|
||||
ColumnWidthType = 8
|
||||
ColumnWidthResource = 20
|
||||
|
||||
// Error display widths
|
||||
ErrorDisplayWidth = 118 // Slightly less than table width (120) for padding
|
||||
)
|
||||
|
||||
// Viewport constants
|
||||
const (
|
||||
// ViewportHeight is the number of items visible in list views
|
||||
ViewportHeight = 20
|
||||
)
|
||||
|
||||
// Path display constants
|
||||
const (
|
||||
// MaxPathWidth is the maximum width for displaying file paths
|
||||
MaxPathWidth = 48
|
||||
)
|
||||
@@ -695,12 +695,12 @@ func TestHandleSelectorValidated(t *testing.T) {
|
||||
func TestHandlePortChecked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
available bool
|
||||
expectStep AddWizardStep
|
||||
available bool
|
||||
expectError bool
|
||||
}{
|
||||
{"port available", true, StepConfirmation, false},
|
||||
{"port in use", false, StepEnterLocalPort, true},
|
||||
{name: "port available", available: true, expectStep: StepConfirmation, expectError: false},
|
||||
{name: "port in use", available: false, expectStep: StepEnterLocalPort, expectError: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -180,13 +180,13 @@ func TestHTTPLogState_GetFilterModeLabel(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
|
||||
tests := []struct {
|
||||
mode HTTPLogFilterMode
|
||||
expected string
|
||||
mode HTTPLogFilterMode
|
||||
}{
|
||||
{HTTPLogFilterNone, "All"},
|
||||
{HTTPLogFilterText, "Text"},
|
||||
{HTTPLogFilterNon200, "Non-2xx"},
|
||||
{HTTPLogFilterErrors, "Errors (4xx/5xx)"},
|
||||
{mode: HTTPLogFilterNone, expected: "All"},
|
||||
{mode: HTTPLogFilterText, expected: "Text"},
|
||||
{mode: HTTPLogFilterNon200, expected: "Non-2xx"},
|
||||
{mode: HTTPLogFilterErrors, expected: "Errors (4xx/5xx)"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+33
-53
@@ -10,36 +10,28 @@ import (
|
||||
|
||||
// MockDiscovery is a mock implementation of DiscoveryInterface for testing
|
||||
type MockDiscovery struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Return values
|
||||
Contexts []string
|
||||
CurrentContext string
|
||||
Namespaces []string
|
||||
Pods []k8s.PodInfo
|
||||
PodsWithSelector []k8s.PodInfo
|
||||
Services []k8s.ServiceInfo
|
||||
|
||||
// Errors to return
|
||||
ListContextsErr error
|
||||
GetCurrentContextErr error
|
||||
ListNamespacesErr error
|
||||
ListPodsErr error
|
||||
ListPodsWithSelectorErr error
|
||||
ListServicesErr error
|
||||
|
||||
// Call tracking
|
||||
ListPodsErr error
|
||||
ListServicesErr error
|
||||
ListPodsWithSelectorErr error
|
||||
ListContextsErr error
|
||||
GetCurrentContextErr error
|
||||
ListNamespacesErr error
|
||||
LastSelector string
|
||||
CurrentContext string
|
||||
LastNamespace string
|
||||
LastContextName string
|
||||
PodsWithSelector []k8s.PodInfo
|
||||
Services []k8s.ServiceInfo
|
||||
Pods []k8s.PodInfo
|
||||
Namespaces []string
|
||||
Contexts []string
|
||||
ListContextsCalls int
|
||||
GetCurrentContextCalls int
|
||||
ListNamespacesCalls int
|
||||
ListPodsCalls int
|
||||
ListPodsWithSelectorCalls int
|
||||
ListServicesCalls int
|
||||
|
||||
// Captured arguments
|
||||
LastContextName string
|
||||
LastNamespace string
|
||||
LastSelector string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMockDiscovery() *MockDiscovery {
|
||||
@@ -104,34 +96,26 @@ func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace
|
||||
|
||||
// MockMutator is a mock implementation of MutatorInterface for testing
|
||||
type MockMutator struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Errors to return
|
||||
AddForwardErr error
|
||||
RemoveForwardsErr error
|
||||
RemoveForwardByIDErr error
|
||||
UpdateForwardErr error
|
||||
|
||||
// Call tracking
|
||||
AddForwardCalls int
|
||||
RemoveForwardsCalls int
|
||||
RemoveForwardByIDCalls int
|
||||
UpdateForwardCalls int
|
||||
|
||||
// Captured arguments
|
||||
LastContextName string
|
||||
LastNamespaceName string
|
||||
LastForward config.Forward
|
||||
LastOldID string
|
||||
LastRemovedID string
|
||||
LastPredicate func(ctx, ns string, fwd config.Forward) bool
|
||||
|
||||
// Storage for testing
|
||||
Forwards []struct {
|
||||
AddForwardErr error
|
||||
RemoveForwardsErr error
|
||||
LastPredicate func(ctx, ns string, fwd config.Forward) bool
|
||||
LastContextName string
|
||||
LastOldID string
|
||||
LastNamespaceName string
|
||||
LastRemovedID string
|
||||
Forwards []struct {
|
||||
Context string
|
||||
Namespace string
|
||||
Forward config.Forward
|
||||
}
|
||||
LastForward config.Forward
|
||||
RemoveForwardByIDCalls int
|
||||
UpdateForwardCalls int
|
||||
RemoveForwardsCalls int
|
||||
AddForwardCalls int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMockMutator() *MockMutator {
|
||||
@@ -186,14 +170,10 @@ func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName stri
|
||||
|
||||
// MockHTTPLogSubscriber is a mock for HTTP log subscription
|
||||
type MockHTTPLogSubscriber struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Subscription tracking
|
||||
Subscriptions map[string]func(HTTPLogEntry)
|
||||
CleanupCalls int
|
||||
|
||||
// Control
|
||||
ShouldFail bool
|
||||
mu sync.Mutex
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
|
||||
@@ -237,11 +217,11 @@ func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
|
||||
|
||||
// MockToggleCallback tracks toggle callback invocations
|
||||
type MockToggleCallback struct {
|
||||
mu sync.Mutex
|
||||
Calls []struct {
|
||||
ID string
|
||||
Enable bool
|
||||
}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMockToggleCallback() *MockToggleCallback {
|
||||
|
||||
@@ -14,17 +14,17 @@ type ForwardStatus struct {
|
||||
Context string
|
||||
Namespace string
|
||||
Alias string
|
||||
Type string // "service", "pod", etc.
|
||||
Resource string // name without type prefix
|
||||
Type string
|
||||
Resource string
|
||||
Status string
|
||||
RemotePort int
|
||||
LocalPort int
|
||||
Status string // "Starting", "Active", "Reconnecting", "Error"
|
||||
}
|
||||
|
||||
// TableUI manages the terminal table display
|
||||
type TableUI struct {
|
||||
forwards map[string]*ForwardStatus
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
}
|
||||
|
||||
@@ -101,12 +101,12 @@ func (t *TableUI) Render() {
|
||||
|
||||
// Sort forwards by local port for consistent display
|
||||
type sortEntry struct {
|
||||
id string
|
||||
fwd *ForwardStatus
|
||||
id string
|
||||
}
|
||||
var entries []sortEntry
|
||||
for id, fwd := range t.forwards {
|
||||
entries = append(entries, sortEntry{id, fwd})
|
||||
entries = append(entries, sortEntry{fwd: fwd, id: id})
|
||||
}
|
||||
|
||||
// Simple sort by local port
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/nvm/kportal/internal/benchmark"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,53 +20,53 @@ const (
|
||||
|
||||
// ContextsLoadedMsg is sent when contexts have been loaded
|
||||
type ContextsLoadedMsg struct {
|
||||
contexts []string
|
||||
err error
|
||||
contexts []string
|
||||
}
|
||||
|
||||
// NamespacesLoadedMsg is sent when namespaces have been loaded
|
||||
type NamespacesLoadedMsg struct {
|
||||
namespaces []string
|
||||
err error
|
||||
namespaces []string
|
||||
}
|
||||
|
||||
// PodsLoadedMsg is sent when pods have been loaded
|
||||
type PodsLoadedMsg struct {
|
||||
pods []k8s.PodInfo
|
||||
err error
|
||||
pods []k8s.PodInfo
|
||||
}
|
||||
|
||||
// ServicesLoadedMsg is sent when services have been loaded
|
||||
type ServicesLoadedMsg struct {
|
||||
services []k8s.ServiceInfo
|
||||
err error
|
||||
services []k8s.ServiceInfo
|
||||
}
|
||||
|
||||
// SelectorValidatedMsg is sent when a selector has been validated
|
||||
type SelectorValidatedMsg struct {
|
||||
valid bool
|
||||
pods []k8s.PodInfo
|
||||
err error
|
||||
pods []k8s.PodInfo
|
||||
valid bool
|
||||
}
|
||||
|
||||
// PortCheckedMsg is sent when a port's availability has been checked
|
||||
type PortCheckedMsg struct {
|
||||
message string
|
||||
port int
|
||||
available bool
|
||||
message string
|
||||
}
|
||||
|
||||
// ForwardSavedMsg is sent when a forward has been saved to config
|
||||
type ForwardSavedMsg struct {
|
||||
success bool
|
||||
err error
|
||||
success bool
|
||||
}
|
||||
|
||||
// ForwardsRemovedMsg is sent when forwards have been removed from config
|
||||
type ForwardsRemovedMsg struct {
|
||||
success bool
|
||||
count int
|
||||
err error
|
||||
count int
|
||||
success bool
|
||||
}
|
||||
|
||||
// WizardCompleteMsg signals that the wizard has completed
|
||||
@@ -241,9 +242,9 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
|
||||
|
||||
// BenchmarkCompleteMsg is sent when a benchmark run completes
|
||||
type BenchmarkCompleteMsg struct {
|
||||
ForwardID string
|
||||
Results *benchmark.Results
|
||||
Error error
|
||||
Results *benchmark.Results
|
||||
ForwardID string
|
||||
}
|
||||
|
||||
// BenchmarkProgressMsg is sent periodically during benchmark execution
|
||||
@@ -291,7 +292,7 @@ func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPa
|
||||
// Recover from panics in the callback
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Silently recover - progress callback failure shouldn't crash the benchmark
|
||||
logger.Debug("recovered from panic in progress callback", map[string]any{"panic": r})
|
||||
}
|
||||
}()
|
||||
// Non-blocking send to progress channel
|
||||
|
||||
@@ -86,10 +86,10 @@ func TestWizardMutualExclusion_HTTPLogBlocksOthers(t *testing.T) {
|
||||
// TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic
|
||||
func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(*BubbleTeaUI)
|
||||
expectActive bool
|
||||
name string
|
||||
activeModalStr string
|
||||
expectActive bool
|
||||
}{
|
||||
{
|
||||
name: "no modal active",
|
||||
|
||||
+62
-86
@@ -109,45 +109,33 @@ func (r ResourceType) Description() string {
|
||||
|
||||
// AddWizardState maintains the state for the add port forward wizard
|
||||
type AddWizardState struct {
|
||||
step AddWizardStep
|
||||
inputMode InputMode
|
||||
cursor int
|
||||
scrollOffset int // For scrolling long lists
|
||||
textInput string
|
||||
searchFilter string // For filtering lists (contexts, namespaces, services)
|
||||
loading bool
|
||||
error error
|
||||
|
||||
// Selections made by user
|
||||
error error
|
||||
resourceValue string
|
||||
originalID string
|
||||
portCheckMsg string
|
||||
alias string
|
||||
textInput string
|
||||
searchFilter string
|
||||
selector string
|
||||
selectedContext string
|
||||
selectedNamespace string
|
||||
pods []k8s.PodInfo
|
||||
contexts []string
|
||||
detectedPorts []k8s.PortInfo
|
||||
matchingPods []k8s.PodInfo
|
||||
services []k8s.ServiceInfo
|
||||
namespaces []string
|
||||
scrollOffset int
|
||||
selectedResourceType ResourceType
|
||||
resourceValue string // pod prefix or service name
|
||||
selector string // for pod selector type
|
||||
remotePort int
|
||||
step AddWizardStep
|
||||
localPort int
|
||||
alias string
|
||||
|
||||
// Available options (loaded asynchronously from k8s)
|
||||
contexts []string
|
||||
namespaces []string
|
||||
pods []k8s.PodInfo
|
||||
services []k8s.ServiceInfo
|
||||
|
||||
// Validation state
|
||||
portAvailable bool
|
||||
portCheckMsg string
|
||||
matchingPods []k8s.PodInfo
|
||||
|
||||
// Edit mode
|
||||
isEditing bool
|
||||
originalID string // ID of the forward being edited
|
||||
|
||||
// Detected ports from resources
|
||||
detectedPorts []k8s.PortInfo
|
||||
|
||||
// Confirmation focus (alias field vs buttons)
|
||||
confirmationFocus ConfirmationFocus
|
||||
cursor int
|
||||
remotePort int
|
||||
inputMode InputMode
|
||||
confirmationFocus ConfirmationFocus
|
||||
portAvailable bool
|
||||
isEditing bool
|
||||
loading bool
|
||||
}
|
||||
|
||||
// newAddWizardState creates a new add wizard state initialized to the first step
|
||||
@@ -239,11 +227,11 @@ func (w *AddWizardState) clearTextInput() {
|
||||
|
||||
// RemoveWizardState maintains the state for the remove port forward wizard
|
||||
type RemoveWizardState struct {
|
||||
selected map[int]bool
|
||||
forwards []RemovableForward
|
||||
cursor int
|
||||
selected map[int]bool
|
||||
confirmCursor int
|
||||
confirming bool
|
||||
confirmCursor int // 0 = Yes, 1 = No
|
||||
}
|
||||
|
||||
// RemovableForward represents a forward that can be removed
|
||||
@@ -387,45 +375,39 @@ const (
|
||||
|
||||
// BenchmarkState maintains the state for the benchmark wizard
|
||||
type BenchmarkState struct {
|
||||
step BenchmarkStep
|
||||
error error
|
||||
results *BenchmarkResults
|
||||
cancelFunc func()
|
||||
progressCh chan BenchmarkProgressMsg
|
||||
textInput string
|
||||
forwardID string
|
||||
forwardAlias string
|
||||
urlPath string
|
||||
method string
|
||||
cursor int
|
||||
progress int
|
||||
total int
|
||||
step BenchmarkStep
|
||||
requests int
|
||||
concurrency int
|
||||
localPort int
|
||||
|
||||
// Configuration
|
||||
urlPath string
|
||||
method string
|
||||
concurrency int
|
||||
requests int
|
||||
cursor int // Current field being edited
|
||||
textInput string
|
||||
|
||||
// Running state
|
||||
running bool
|
||||
progress int
|
||||
total int
|
||||
progressCh chan BenchmarkProgressMsg // Channel for progress updates
|
||||
cancelFunc func() // Function to cancel the running benchmark
|
||||
|
||||
// Results
|
||||
results *BenchmarkResults
|
||||
error error
|
||||
running bool
|
||||
}
|
||||
|
||||
// BenchmarkResults holds benchmark results for display
|
||||
type BenchmarkResults struct {
|
||||
StatusCodes map[int]int
|
||||
TotalRequests int
|
||||
Successful int
|
||||
Failed int
|
||||
MinLatency float64 // milliseconds
|
||||
MinLatency float64
|
||||
MaxLatency float64
|
||||
AvgLatency float64
|
||||
P50Latency float64
|
||||
P95Latency float64
|
||||
P99Latency float64
|
||||
Throughput float64 // requests per second
|
||||
Throughput float64
|
||||
BytesRead int64
|
||||
StatusCodes map[int]int
|
||||
}
|
||||
|
||||
// newBenchmarkState creates a new benchmark state for a forward
|
||||
@@ -455,41 +437,35 @@ const (
|
||||
|
||||
// HTTPLogState maintains the state for HTTP log viewing
|
||||
type HTTPLogState struct {
|
||||
forwardID string
|
||||
forwardAlias string
|
||||
entries []HTTPLogEntry
|
||||
cursor int
|
||||
scrollOffset int
|
||||
autoScroll bool
|
||||
|
||||
// Filtering
|
||||
filterMode HTTPLogFilterMode
|
||||
filterText string
|
||||
filterActive bool // true when typing in filter input
|
||||
|
||||
// Detail view
|
||||
showingDetail bool // true when viewing full entry details
|
||||
detailScroll int // scroll position in detail view
|
||||
copyMessage string // temporary message after copying (e.g., "Copied!")
|
||||
forwardID string
|
||||
forwardAlias string
|
||||
filterText string
|
||||
copyMessage string
|
||||
entries []HTTPLogEntry
|
||||
cursor int
|
||||
scrollOffset int
|
||||
filterMode HTTPLogFilterMode
|
||||
detailScroll int
|
||||
autoScroll bool
|
||||
filterActive bool
|
||||
showingDetail bool
|
||||
}
|
||||
|
||||
// HTTPLogEntry represents a single HTTP log entry for display
|
||||
type HTTPLogEntry struct {
|
||||
RequestID string // Used to match request/response pairs
|
||||
Timestamp string
|
||||
Direction string
|
||||
Method string
|
||||
Path string
|
||||
StatusCode int
|
||||
LatencyMs int64
|
||||
BodySize int
|
||||
|
||||
// Detail fields - for viewing full request/response
|
||||
RequestHeaders map[string]string
|
||||
ResponseHeaders map[string]string
|
||||
Method string
|
||||
RequestID string
|
||||
Path string
|
||||
Direction string
|
||||
Timestamp string
|
||||
RequestBody string
|
||||
ResponseBody string
|
||||
Error string
|
||||
StatusCode int
|
||||
LatencyMs int64
|
||||
BodySize int
|
||||
}
|
||||
|
||||
// newHTTPLogState creates a new HTTP log viewing state
|
||||
|
||||
@@ -285,10 +285,10 @@ func TestClearSearchFilter(t *testing.T) {
|
||||
func TestMoveCursorWithFilteredLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
step AddWizardStep
|
||||
searchFilter string
|
||||
contexts []string
|
||||
namespaces []string
|
||||
searchFilter string
|
||||
step AddWizardStep
|
||||
initialCursor int
|
||||
delta int
|
||||
expectedCursor int
|
||||
|
||||
@@ -143,7 +143,6 @@ func renderBreadcrumb(parts ...string) string {
|
||||
func renderList(items []string, cursor int, prefix string, scrollOffset int) string {
|
||||
var b strings.Builder
|
||||
|
||||
const viewportHeight = 20
|
||||
totalItems := len(items)
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
@@ -153,7 +152,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
|
||||
|
||||
// Calculate visible range
|
||||
start := scrollOffset
|
||||
end := scrollOffset + viewportHeight
|
||||
end := scrollOffset + ViewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user