mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-06 06:16:10 +00:00
bugfixes nov2025 (#3)
* Fix enter misbehaving. * Cleanup after previous tui implementation. * Fix race condition and improve logging * Add filtering of the namespaces by text input in the wizard UI
This commit is contained in:
@@ -378,7 +378,7 @@ func (m model) renderMainView() string {
|
||||
}
|
||||
|
||||
isSelected := (idx == m.ui.selectedIndex)
|
||||
isDisabled := m.ui.disabledMap[id]
|
||||
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
||||
|
||||
// Selection indicator
|
||||
indicator := " "
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// InteractiveController handles keyboard input and selection state
|
||||
type InteractiveController struct {
|
||||
mu sync.RWMutex
|
||||
selectedIndex int
|
||||
forwardIDs []string // Ordered list of forward IDs
|
||||
disabledMap map[string]bool // Tracks which forwards are disabled
|
||||
toggleCallback func(id string, enable bool)
|
||||
enabled bool
|
||||
oldTermState *term.State
|
||||
}
|
||||
|
||||
// NewInteractiveController creates a new interactive controller
|
||||
func NewInteractiveController(toggleCallback func(id string, enable bool)) *InteractiveController {
|
||||
return &InteractiveController{
|
||||
selectedIndex: 0,
|
||||
forwardIDs: make([]string, 0),
|
||||
disabledMap: make(map[string]bool),
|
||||
toggleCallback: toggleCallback,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Enable puts the terminal in raw mode for keyboard input
|
||||
func (ic *InteractiveController) Enable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save current terminal state
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable raw mode: %w", err)
|
||||
}
|
||||
|
||||
ic.oldTermState = oldState
|
||||
ic.enabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable restores the terminal to normal mode
|
||||
func (ic *InteractiveController) Disable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if !ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ic.oldTermState != nil {
|
||||
if err := term.Restore(int(os.Stdin.Fd()), ic.oldTermState); err != nil {
|
||||
return fmt.Errorf("failed to restore terminal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ic.enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateForwardsList updates the list of forwards for navigation
|
||||
func (ic *InteractiveController) UpdateForwardsList(ids []string) {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
ic.forwardIDs = ids
|
||||
|
||||
// Ensure selected index is valid
|
||||
if ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.selectedIndex = len(ic.forwardIDs) - 1
|
||||
}
|
||||
if ic.selectedIndex < 0 && len(ic.forwardIDs) > 0 {
|
||||
ic.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves selection up
|
||||
func (ic *InteractiveController) MoveUp() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex > 0 {
|
||||
ic.selectedIndex--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves selection down
|
||||
func (ic *InteractiveController) MoveDown() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex < len(ic.forwardIDs)-1 {
|
||||
ic.selectedIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleSelected toggles the enable/disable state of the selected forward
|
||||
func (ic *InteractiveController) ToggleSelected() {
|
||||
ic.mu.Lock()
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
selectedID := ic.forwardIDs[ic.selectedIndex]
|
||||
currentlyDisabled := ic.disabledMap[selectedID]
|
||||
newState := !currentlyDisabled
|
||||
ic.disabledMap[selectedID] = newState
|
||||
ic.mu.Unlock()
|
||||
|
||||
// Call the toggle callback
|
||||
if ic.toggleCallback != nil {
|
||||
ic.toggleCallback(selectedID, !newState) // enable is inverse of disabled
|
||||
}
|
||||
}
|
||||
|
||||
// GetSelectedIndex returns the current selection index
|
||||
func (ic *InteractiveController) GetSelectedIndex() int {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.selectedIndex
|
||||
}
|
||||
|
||||
// IsDisabled returns whether a forward is disabled
|
||||
func (ic *InteractiveController) IsDisabled(id string) bool {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.disabledMap[id]
|
||||
}
|
||||
|
||||
// GetSelectedID returns the ID of the currently selected forward
|
||||
func (ic *InteractiveController) GetSelectedID() string {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
return ""
|
||||
}
|
||||
return ic.forwardIDs[ic.selectedIndex]
|
||||
}
|
||||
|
||||
// HandleKey processes keyboard input and returns true if should continue
|
||||
func (ic *InteractiveController) HandleKey(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle single byte keys
|
||||
if len(b) == 1 {
|
||||
switch b[0] {
|
||||
case 'q', 'Q', 3: // q, Q, or Ctrl+C
|
||||
return false
|
||||
case ' ', '\r': // Space or Enter to toggle
|
||||
ic.ToggleSelected()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape sequences (arrow keys)
|
||||
if len(b) == 3 && b[0] == 27 && b[1] == 91 {
|
||||
switch b[2] {
|
||||
case 65: // Up arrow
|
||||
ic.MoveUp()
|
||||
case 66: // Down arrow
|
||||
ic.MoveDown()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+7
-48
@@ -23,10 +23,9 @@ type ForwardStatus struct {
|
||||
|
||||
// TableUI manages the terminal table display
|
||||
type TableUI struct {
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
interactive *InteractiveController
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewTableUI creates a new table UI manager
|
||||
@@ -37,13 +36,6 @@ func NewTableUI(verbose bool) *TableUI {
|
||||
}
|
||||
}
|
||||
|
||||
// SetInteractiveController sets the interactive controller
|
||||
func (t *TableUI) SetInteractiveController(ic *InteractiveController) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.interactive = ic
|
||||
}
|
||||
|
||||
// AddForward registers a new forward for display
|
||||
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
|
||||
t.mu.Lock()
|
||||
@@ -126,27 +118,10 @@ func (t *TableUI) Render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update interactive controller with current forward IDs (in display order)
|
||||
if t.interactive != nil {
|
||||
ids := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
ids[i] = entry.id
|
||||
}
|
||||
t.interactive.UpdateForwardsList(ids)
|
||||
}
|
||||
|
||||
// Print each forward
|
||||
for i, entry := range entries {
|
||||
for _, entry := range entries {
|
||||
fwd := entry.fwd
|
||||
|
||||
// Check if this row is selected
|
||||
isSelected := false
|
||||
isDisabled := false
|
||||
if t.interactive != nil {
|
||||
isSelected = (i == t.interactive.GetSelectedIndex())
|
||||
isDisabled = t.interactive.IsDisabled(entry.id)
|
||||
}
|
||||
|
||||
// Truncate long names
|
||||
alias := truncate(fwd.Alias, 25)
|
||||
resource := truncate(fwd.Resource, 25)
|
||||
@@ -154,8 +129,8 @@ func (t *TableUI) Render() {
|
||||
// Color code status with indicator
|
||||
statusStr := formatStatusWithIndicator(fwd.Status)
|
||||
|
||||
// Build the row content
|
||||
rowContent := fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
|
||||
// Print the row
|
||||
fmt.Printf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
|
||||
fwd.Context,
|
||||
fwd.Namespace,
|
||||
alias,
|
||||
@@ -164,26 +139,10 @@ func (t *TableUI) Render() {
|
||||
fwd.RemotePort,
|
||||
fwd.LocalPort,
|
||||
statusStr)
|
||||
|
||||
// Apply selection highlighting or disabled styling
|
||||
if isSelected {
|
||||
// Replace leading spaces with arrow, then apply reverse video to entire line
|
||||
rowContent = "\033[7m> " + rowContent[2:] + "\033[0m"
|
||||
} else if isDisabled {
|
||||
// Apply dimmed styling to entire line
|
||||
rowContent = "\033[2m" + rowContent + "\033[0m"
|
||||
}
|
||||
|
||||
fmt.Println(rowContent)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
helpText := "Total forwards: %d | ↑↓: Navigate | Space: Toggle | q: Quit"
|
||||
if !t.verbose {
|
||||
fmt.Printf(helpText+"\n", len(t.forwards))
|
||||
} else {
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
}
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
|
||||
// In verbose mode, add a newline to separate from logs
|
||||
if t.verbose {
|
||||
|
||||
@@ -10,6 +10,19 @@ import (
|
||||
"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
|
||||
@@ -224,6 +237,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
@@ -242,6 +261,7 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
wizard.step--
|
||||
wizard.cursor = 0
|
||||
wizard.clearTextInput()
|
||||
wizard.clearSearchFilter()
|
||||
wizard.error = nil
|
||||
|
||||
// Reset input mode based on the step we're going back to
|
||||
@@ -300,26 +320,48 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.handleAddWizardEnter()
|
||||
|
||||
case "backspace":
|
||||
// Allow backspace in text input mode OR when focused on alias in confirmation
|
||||
// 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)
|
||||
if canBackspace && len(wizard.textInput) > 0 {
|
||||
wizard.textInput = wizard.textInput[:len(wizard.textInput)-1]
|
||||
(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)
|
||||
if canTypeText && len(msg.String()) == 1 {
|
||||
wizard.handleTextInput(rune(msg.String()[0]))
|
||||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
||||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,19 +376,23 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
switch wizard.step {
|
||||
case StepSelectContext:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.contexts) {
|
||||
wizard.selectedContext = wizard.contexts[wizard.cursor]
|
||||
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:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.namespaces) {
|
||||
wizard.selectedNamespace = wizard.namespaces[wizard.cursor]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -403,13 +449,15 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case ResourceTypeService:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.services) {
|
||||
wizard.resourceValue = wizard.services[wizard.cursor].Name
|
||||
filteredServices := wizard.getFilteredServices()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
||||
wizard.resourceValue = filteredServices[wizard.cursor].Name
|
||||
wizard.step = StepEnterRemotePort
|
||||
wizard.clearTextInput()
|
||||
wizard.clearSearchFilter()
|
||||
|
||||
// Get ports from selected service
|
||||
wizard.detectedPorts = wizard.services[wizard.cursor].Ports
|
||||
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
wizard.inputMode = InputModeList
|
||||
wizard.cursor = 0
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// filterStrings filters a slice of strings by a search filter (case-insensitive substring match)
|
||||
func filterStrings(items []string, filter string) []string {
|
||||
if filter == "" {
|
||||
return items
|
||||
}
|
||||
filtered := []string{}
|
||||
filterLower := strings.ToLower(filter)
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item), filterLower) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// matchesFilter checks if a string matches the filter (case-insensitive substring match)
|
||||
func matchesFilter(item, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(item), strings.ToLower(filter))
|
||||
}
|
||||
|
||||
// ViewMode represents the current view state of the UI
|
||||
type ViewMode int
|
||||
|
||||
@@ -87,6 +112,7 @@ type AddWizardState struct {
|
||||
cursor int
|
||||
scrollOffset int // For scrolling long lists
|
||||
textInput string
|
||||
searchFilter string // For filtering lists (contexts, namespaces, services)
|
||||
loading bool
|
||||
error error
|
||||
|
||||
@@ -142,14 +168,14 @@ func (w *AddWizardState) moveCursor(delta int) {
|
||||
|
||||
switch w.step {
|
||||
case StepSelectContext:
|
||||
maxItems = len(w.contexts)
|
||||
maxItems = len(w.getFilteredContexts())
|
||||
case StepSelectNamespace:
|
||||
maxItems = len(w.namespaces)
|
||||
maxItems = len(w.getFilteredNamespaces())
|
||||
case StepSelectResourceType:
|
||||
maxItems = 3 // Three resource types
|
||||
case StepEnterResource:
|
||||
if w.selectedResourceType == ResourceTypeService {
|
||||
maxItems = len(w.services)
|
||||
maxItems = len(w.getFilteredServices())
|
||||
}
|
||||
case StepEnterRemotePort:
|
||||
if len(w.detectedPorts) > 0 {
|
||||
@@ -300,3 +326,40 @@ func (w *RemoveWizardState) getSelectedForwards() []RemovableForward {
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// getFilteredContexts returns contexts filtered by search string
|
||||
func (w *AddWizardState) getFilteredContexts() []string {
|
||||
if w.searchFilter == "" {
|
||||
return w.contexts
|
||||
}
|
||||
return filterStrings(w.contexts, w.searchFilter)
|
||||
}
|
||||
|
||||
// getFilteredNamespaces returns namespaces filtered by search string
|
||||
func (w *AddWizardState) getFilteredNamespaces() []string {
|
||||
if w.searchFilter == "" {
|
||||
return w.namespaces
|
||||
}
|
||||
return filterStrings(w.namespaces, w.searchFilter)
|
||||
}
|
||||
|
||||
// getFilteredServices returns services filtered by search string
|
||||
func (w *AddWizardState) getFilteredServices() []k8s.ServiceInfo {
|
||||
if w.searchFilter == "" {
|
||||
return w.services
|
||||
}
|
||||
filtered := []k8s.ServiceInfo{}
|
||||
for _, svc := range w.services {
|
||||
if matchesFilter(svc.Name, w.searchFilter) {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// clearSearchFilter clears the search filter and resets cursor/scroll
|
||||
func (w *AddWizardState) clearSearchFilter() {
|
||||
w.searchFilter = ""
|
||||
w.cursor = 0
|
||||
w.scrollOffset = 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty filter returns all items",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "",
|
||||
expected: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
},
|
||||
{
|
||||
name: "filter matches multiple items",
|
||||
items: []string{"prod-api", "prod-db", "staging-api", "dev-api"},
|
||||
filter: "prod",
|
||||
expected: []string{"prod-api", "prod-db"},
|
||||
},
|
||||
{
|
||||
name: "filter matches single item",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "2",
|
||||
expected: []string{"namespace-2"},
|
||||
},
|
||||
{
|
||||
name: "filter matches no items",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "case insensitive matching",
|
||||
items: []string{"Production", "Staging", "Development"},
|
||||
filter: "prod",
|
||||
expected: []string{"Production"},
|
||||
},
|
||||
{
|
||||
name: "partial string matching",
|
||||
items: []string{"my-app-frontend", "my-app-backend", "other-service"},
|
||||
filter: "app",
|
||||
expected: []string{"my-app-frontend", "my-app-backend"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterStrings(tt.items, tt.filter)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
filter string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty filter matches everything",
|
||||
item: "namespace-1",
|
||||
filter: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
item: "namespace-1",
|
||||
filter: "namespace-1",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "partial match",
|
||||
item: "production-api",
|
||||
filter: "prod",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
item: "namespace-1",
|
||||
filter: "xyz",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
item: "Production",
|
||||
filter: "prod",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := matchesFilter(tt.item, tt.filter)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredContexts(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
contexts: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'prod'",
|
||||
filter: "prod",
|
||||
expected: []string{"prod-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'cluster'",
|
||||
filter: "cluster",
|
||||
expected: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'staging'",
|
||||
filter: "staging",
|
||||
expected: []string{"staging-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter with no matches",
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredContexts()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredNamespaces(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
namespaces: []string{
|
||||
"kube-system", "kube-public", "default",
|
||||
"prod-api", "prod-db", "staging-api", "staging-db",
|
||||
"monitoring", "logging",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{
|
||||
"kube-system", "kube-public", "default",
|
||||
"prod-api", "prod-db", "staging-api", "staging-db",
|
||||
"monitoring", "logging",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter by 'prod'",
|
||||
filter: "prod",
|
||||
expected: []string{"prod-api", "prod-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'kube'",
|
||||
filter: "kube",
|
||||
expected: []string{"kube-system", "kube-public"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'api'",
|
||||
filter: "api",
|
||||
expected: []string{"prod-api", "staging-api"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'ing' (partial match)",
|
||||
filter: "ing",
|
||||
expected: []string{"staging-api", "staging-db", "monitoring", "logging"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredNamespaces()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredServices(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
services: []k8s.ServiceInfo{
|
||||
{Name: "api-gateway"},
|
||||
{Name: "api-backend"},
|
||||
{Name: "database"},
|
||||
{Name: "redis-cache"},
|
||||
{Name: "postgres-db"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{"api-gateway", "api-backend", "database", "redis-cache", "postgres-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'api'",
|
||||
filter: "api",
|
||||
expected: []string{"api-gateway", "api-backend"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'db'",
|
||||
filter: "db",
|
||||
expected: []string{"postgres-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'base'",
|
||||
filter: "base",
|
||||
expected: []string{"database"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'redis'",
|
||||
filter: "redis",
|
||||
expected: []string{"redis-cache"},
|
||||
},
|
||||
{
|
||||
name: "filter with no matches",
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredServices()
|
||||
resultNames := make([]string, len(result))
|
||||
for i, svc := range result {
|
||||
resultNames[i] = svc.Name
|
||||
}
|
||||
assert.Equal(t, tt.expected, resultNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSearchFilter(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
searchFilter: "test",
|
||||
cursor: 5,
|
||||
scrollOffset: 10,
|
||||
}
|
||||
|
||||
wizard.clearSearchFilter()
|
||||
|
||||
assert.Equal(t, "", wizard.searchFilter, "searchFilter should be cleared")
|
||||
assert.Equal(t, 0, wizard.cursor, "cursor should be reset to 0")
|
||||
assert.Equal(t, 0, wizard.scrollOffset, "scrollOffset should be reset to 0")
|
||||
}
|
||||
|
||||
func TestMoveCursorWithFilteredLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
step AddWizardStep
|
||||
contexts []string
|
||||
namespaces []string
|
||||
searchFilter string
|
||||
initialCursor int
|
||||
delta int
|
||||
expectedCursor int
|
||||
}{
|
||||
{
|
||||
name: "move down in filtered contexts",
|
||||
step: StepSelectContext,
|
||||
contexts: []string{"prod-1", "prod-2", "staging-1", "dev-1"},
|
||||
searchFilter: "prod",
|
||||
initialCursor: 0,
|
||||
delta: 1,
|
||||
expectedCursor: 1,
|
||||
},
|
||||
{
|
||||
name: "cannot move beyond filtered list",
|
||||
step: StepSelectContext,
|
||||
contexts: []string{"prod-1", "prod-2", "staging-1", "dev-1"},
|
||||
searchFilter: "prod",
|
||||
initialCursor: 1,
|
||||
delta: 1,
|
||||
expectedCursor: 1, // Should stay at 1 (last item in filtered list)
|
||||
},
|
||||
{
|
||||
name: "move up in filtered list",
|
||||
step: StepSelectNamespace,
|
||||
namespaces: []string{"ns-1", "ns-2", "ns-3", "other"},
|
||||
searchFilter: "ns",
|
||||
initialCursor: 2,
|
||||
delta: -1,
|
||||
expectedCursor: 1,
|
||||
},
|
||||
{
|
||||
name: "cannot move above 0",
|
||||
step: StepSelectNamespace,
|
||||
namespaces: []string{"ns-1", "ns-2", "ns-3"},
|
||||
searchFilter: "ns",
|
||||
initialCursor: 0,
|
||||
delta: -1,
|
||||
expectedCursor: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
step: tt.step,
|
||||
inputMode: InputModeList,
|
||||
cursor: tt.initialCursor,
|
||||
contexts: tt.contexts,
|
||||
namespaces: tt.namespaces,
|
||||
searchFilter: tt.searchFilter,
|
||||
}
|
||||
|
||||
wizard.moveCursor(tt.delta)
|
||||
|
||||
assert.Equal(t, tt.expectedCursor, wizard.cursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
+89
-38
@@ -45,6 +45,12 @@ func (m model) renderSelectContext() string {
|
||||
b.WriteString(renderHeader("Add Port Forward", renderProgress(1, 7)))
|
||||
b.WriteString("Select Kubernetes Context:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
|
||||
} else if wizard.error != nil {
|
||||
@@ -52,46 +58,56 @@ func (m model) renderSelectContext() string {
|
||||
} else if len(wizard.contexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
|
||||
} else {
|
||||
const viewportHeight = 20
|
||||
totalItems := len(wizard.contexts)
|
||||
filteredContexts := wizard.getFilteredContexts()
|
||||
if len(filteredContexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching contexts"))
|
||||
} else {
|
||||
const viewportHeight = 20
|
||||
totalItems := len(filteredContexts)
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
|
||||
// Render visible contexts with (current) marker on first one
|
||||
for i := start; i < end; i++ {
|
||||
prefix := " "
|
||||
text := wizard.contexts[i]
|
||||
if i == 0 {
|
||||
text += mutedStyle.Render(" (current)")
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
}
|
||||
|
||||
if i == wizard.cursor {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + text))
|
||||
} else {
|
||||
b.WriteString(prefix + text)
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
// Render visible contexts with (current) marker on first one (only if not filtered)
|
||||
for i := start; i < end; i++ {
|
||||
prefix := " "
|
||||
text := filteredContexts[i]
|
||||
// Only show (current) marker if no filter and this is the first item in original list
|
||||
if wizard.searchFilter == "" && i == 0 {
|
||||
text += mutedStyle.Render(" (current)")
|
||||
}
|
||||
|
||||
if i == wizard.cursor {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + text))
|
||||
} else {
|
||||
b.WriteString(prefix + text)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -105,6 +121,12 @@ func (m model) renderSelectNamespace() string {
|
||||
|
||||
b.WriteString("Select Namespace:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading namespaces..."))
|
||||
} else if wizard.error != nil {
|
||||
@@ -113,11 +135,20 @@ func (m model) renderSelectNamespace() string {
|
||||
} else if len(wizard.namespaces) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No namespaces found"))
|
||||
} else {
|
||||
b.WriteString(renderList(wizard.namespaces, wizard.cursor, " ", wizard.scrollOffset))
|
||||
filteredNamespaces := wizard.getFilteredNamespaces()
|
||||
if len(filteredNamespaces) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching namespaces"))
|
||||
} else {
|
||||
b.WriteString(renderList(filteredNamespaces, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -242,21 +273,41 @@ func (m model) renderEnterResource() string {
|
||||
case ResourceTypeService:
|
||||
b.WriteString("Select service:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading services..."))
|
||||
} else if len(wizard.services) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No services found"))
|
||||
} else {
|
||||
serviceNames := make([]string, len(wizard.services))
|
||||
for i, svc := range wizard.services {
|
||||
serviceNames[i] = svc.Name
|
||||
filteredServices := wizard.getFilteredServices()
|
||||
if len(filteredServices) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching services"))
|
||||
} else {
|
||||
serviceNames := make([]string, len(filteredServices))
|
||||
for i, svc := range filteredServices {
|
||||
serviceNames[i] = svc.Name
|
||||
}
|
||||
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
// Show appropriate help text based on resource type and filter state
|
||||
if wizard.selectedResourceType == ResourceTypeService {
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user