Add configuration wizard. (#2)

* Add configuration wizard.
This commit is contained in:
2025-11-24 02:28:08 +00:00
committed by GitHub
parent 0f86f8e230
commit f41c316b2b
28 changed files with 5977 additions and 496 deletions
+268 -41
View File
@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
// ForwardUpdateMsg is sent when a forward status changes
@@ -44,11 +45,29 @@ type BubbleTeaUI struct {
toggleCallback func(id string, enable bool)
version string
errors map[string]string // Track error messages by forward ID
// Modal wizard state
viewMode ViewMode
addWizard *AddWizardState
removeWizard *RemoveWizardState
// Delete confirmation state
deleteConfirming bool
deleteConfirmID string
deleteConfirmAlias string
deleteConfirmCursor int // 0 = Yes, 1 = No
// Dependencies for wizards
discovery *k8s.Discovery
mutator *config.Mutator
configPath string
}
// bubbletea model
type model struct {
ui *BubbleTeaUI
ui *BubbleTeaUI
termWidth int
termHeight int
}
// NewBubbleTeaUI creates a new bubbletea-based UI
@@ -61,11 +80,22 @@ func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string)
toggleCallback: toggleCallback,
version: version,
errors: make(map[string]string),
viewMode: ViewModeMain,
}
return ui
}
// SetWizardDependencies sets the dependencies needed for the add/remove wizards
func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) {
ui.mu.Lock()
defer ui.mu.Unlock()
ui.discovery = discovery
ui.mutator = mutator
ui.configPath = configPath
}
// Start starts the bubbletea application
func (ui *BubbleTeaUI) Start() error {
m := model{ui: ui}
@@ -187,33 +217,55 @@ func (m model) Init() tea.Cmd {
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ui.mu.RLock()
viewMode := m.ui.viewMode
m.ui.mu.RUnlock()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Update terminal dimensions on resize
m.termWidth = msg.Width
m.termHeight = msg.Height
return m, nil
case tea.KeyMsg:
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 " ", "enter":
m.ui.toggleSelected()
// Route based on current view mode
switch viewMode {
case ViewModeMain:
return m.handleMainViewKeys(msg)
case ViewModeAddWizard:
return m.handleAddWizardKeys(msg)
case ViewModeRemoveWizard:
return m.handleRemoveWizardKeys(msg)
}
case ForwardAddMsg:
// Already handled in AddForward, just trigger re-render
// Forward management messages (always update main view data)
case ForwardAddMsg, ForwardUpdateMsg, ForwardErrorMsg, ForwardRemoveMsg:
return m, nil
case ForwardUpdateMsg:
// Already handled in UpdateStatus, just trigger re-render
return m, nil
case ForwardErrorMsg:
// Already handled in SetError, just trigger re-render
return m, nil
case ForwardRemoveMsg:
// Already handled in Remove, just trigger re-render
// Wizard-specific messages
case ContextsLoadedMsg:
return m.handleContextsLoaded(msg)
case NamespacesLoadedMsg:
return m.handleNamespacesLoaded(msg)
case PodsLoadedMsg:
return m.handlePodsLoaded(msg)
case ServicesLoadedMsg:
return m.handleServicesLoaded(msg)
case SelectorValidatedMsg:
return m.handleSelectorValidated(msg)
case PortCheckedMsg:
return m.handlePortChecked(msg)
case ForwardSavedMsg:
return m.handleForwardSaved(msg)
case ForwardsRemovedMsg:
return m.handleForwardsRemoved(msg)
case WizardCompleteMsg:
m.ui.mu.Lock()
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
m.ui.removeWizard = nil
m.ui.mu.Unlock()
return m, nil
}
@@ -221,11 +273,57 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) View() string {
m.ui.mu.RLock()
viewMode := m.ui.viewMode
deleteConfirming := m.ui.deleteConfirming
m.ui.mu.RUnlock()
// Always render main view as base
mainView := m.renderMainView()
// Use actual terminal dimensions for proper centering
termWidth := m.termWidth
termHeight := m.termHeight
// Fallback to reasonable defaults if dimensions not yet received
if termWidth == 0 {
termWidth = 120
}
if termHeight == 0 {
termHeight = 40
}
// Overlay delete confirmation if active
if deleteConfirming {
modal := m.renderDeleteConfirmation()
return overlayContent(mainView, modal, termWidth, termHeight)
}
// Overlay wizard if active
switch viewMode {
case ViewModeAddWizard:
modal := m.renderAddWizard()
return overlayContent(mainView, modal, termWidth, termHeight)
case ViewModeRemoveWizard:
modal := m.renderRemoveWizard()
return overlayContent(mainView, modal, termWidth, termHeight)
default:
return mainView
}
}
func (m model) renderMainView() string {
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
var b strings.Builder
// Get terminal dimensions for proper sizing
termHeight := m.termHeight
if termHeight == 0 {
termHeight = 40 // Fallback
}
// Styles
titleStyle := lipgloss.NewStyle().
Bold(true).
@@ -350,21 +448,7 @@ func (m model) View() string {
}
}
// Footer
b.WriteString("\n")
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: Quit │ Total: %d",
keyStyle.Render("↑↓"),
keyStyle.Render("jk"),
keyStyle.Render("Space"),
keyStyle.Render("q"),
len(m.ui.forwardOrder))
b.WriteString(footerStyle.Render(footer))
// Display errors if any
// Display errors if any (before footer)
if len(m.ui.errors) > 0 {
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
@@ -374,20 +458,104 @@ func (m model) View() string {
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 {
errorLineStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
line := fmt.Sprintf(" • %s: %s", fwd.Alias, errMsg)
b.WriteString(errorLineStyle.Render(line))
b.WriteString("\n")
// 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")
}
}
}
}
}
// Calculate current content height
currentContent := b.String()
currentLines := strings.Count(currentContent, "\n") + 1
// Footer styles
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Quit │ Total: %d",
keyStyle.Render("↑↓"),
keyStyle.Render("jk"),
keyStyle.Render("Space"),
keyStyle.Render("n"),
keyStyle.Render("e"),
keyStyle.Render("d"),
keyStyle.Render("q"),
len(m.ui.forwardOrder))
// Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer)
footerHeight := 2
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
}
// Add footer at bottom
b.WriteString("\n")
b.WriteString(footerStyle.Render(footer))
return b.String()
}
// wrapText wraps text to the specified width, breaking at word boundaries
func wrapText(text string, width int) string {
if len(text) <= width {
return text
}
var result strings.Builder
var line strings.Builder
words := strings.Fields(text)
for i, word := range words {
// If adding this word would exceed width, start new line
if line.Len()+len(word)+1 > width && line.Len() > 0 {
result.WriteString(line.String())
result.WriteString("\n")
line.Reset()
}
// Add space before word (except first word on line)
if line.Len() > 0 {
line.WriteString(" ")
}
line.WriteString(word)
// Last word - flush the line
if i == len(words)-1 {
result.WriteString(line.String())
}
}
return result.String()
}
// moveSelection moves the selection up or down
func (ui *BubbleTeaUI) moveSelection(delta int) {
ui.mu.Lock()
@@ -406,6 +574,65 @@ func (ui *BubbleTeaUI) moveSelection(delta int) {
}
}
// renderDeleteConfirmation renders the delete confirmation dialog
func (m model) renderDeleteConfirmation() string {
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
var b strings.Builder
// Use wizard color palette for consistency
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(warningColor). // Yellow for warning (delete action)
Padding(0, 1)
buttonSelectedStyle := lipgloss.NewStyle().
Background(primaryColor). // Pink/Magenta background
Foreground(lipgloss.Color("230")). // Light yellow text
Bold(true).
Padding(0, 1)
buttonUnselectedStyle := lipgloss.NewStyle().
Foreground(mutedColor). // Gray
Padding(0, 1)
deleteInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")). // Light gray for info text
Italic(true)
// Title
b.WriteString(titleStyle.Render("⚠ Delete Port Forward"))
b.WriteString("\n\n")
// Message
b.WriteString("Are you sure you want to delete:\n\n")
b.WriteString(deleteInfoStyle.Render(" " + m.ui.deleteConfirmAlias))
b.WriteString("\n\n")
// Buttons
if m.ui.deleteConfirmCursor == 0 {
b.WriteString(buttonSelectedStyle.Render(" Yes "))
b.WriteString(" ")
b.WriteString(buttonUnselectedStyle.Render(" No "))
} else {
b.WriteString(buttonUnselectedStyle.Render(" Yes "))
b.WriteString(" ")
b.WriteString(buttonSelectedStyle.Render(" No "))
}
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel"))
// Wrap in a box using wizard style
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentColor). // Purple border like other wizards
Padding(1, 2)
return boxStyle.Render(b.String())
}
// toggleSelected toggles the selected forward on/off
func (ui *BubbleTeaUI) toggleSelected() {
ui.mu.Lock()
+222
View File
@@ -0,0 +1,222 @@
package ui
import (
"context"
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
const (
k8sAPITimeout = 10 * time.Second
)
// Messages sent from async commands back to the update loop
// ContextsLoadedMsg is sent when contexts have been loaded
type ContextsLoadedMsg struct {
contexts []string
err error
}
// NamespacesLoadedMsg is sent when namespaces have been loaded
type NamespacesLoadedMsg struct {
namespaces []string
err error
}
// PodsLoadedMsg is sent when pods have been loaded
type PodsLoadedMsg struct {
pods []k8s.PodInfo
err error
}
// ServicesLoadedMsg is sent when services have been loaded
type ServicesLoadedMsg struct {
services []k8s.ServiceInfo
err error
}
// SelectorValidatedMsg is sent when a selector has been validated
type SelectorValidatedMsg struct {
valid bool
pods []k8s.PodInfo
err error
}
// PortCheckedMsg is sent when a port's availability has been checked
type PortCheckedMsg struct {
port int
available bool
message string
}
// ForwardSavedMsg is sent when a forward has been saved to config
type ForwardSavedMsg struct {
success bool
err error
}
// ForwardsRemovedMsg is sent when forwards have been removed from config
type ForwardsRemovedMsg struct {
success bool
count int
err error
}
// WizardCompleteMsg signals that the wizard has completed
type WizardCompleteMsg struct{}
// Command functions (return tea.Cmd)
// loadContextsCmd loads available Kubernetes contexts
func loadContextsCmd(discovery *k8s.Discovery) tea.Cmd {
return func() tea.Msg {
contexts, err := discovery.ListContexts()
if err != nil {
return ContextsLoadedMsg{err: err}
}
return ContextsLoadedMsg{contexts: contexts}
}
}
// loadNamespacesCmd loads namespaces for the given context
func loadNamespacesCmd(discovery *k8s.Discovery, contextName string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
defer cancel()
namespaces, err := discovery.ListNamespaces(ctx, contextName)
if err != nil {
return NamespacesLoadedMsg{err: err}
}
return NamespacesLoadedMsg{namespaces: namespaces}
}
}
// loadPodsCmd loads pods for the given context and namespace
func loadPodsCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
defer cancel()
pods, err := discovery.ListPods(ctx, contextName, namespace)
if err != nil {
return PodsLoadedMsg{err: err}
}
return PodsLoadedMsg{pods: pods}
}
}
// loadServicesCmd loads services for the given context and namespace
func loadServicesCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
defer cancel()
services, err := discovery.ListServices(ctx, contextName, namespace)
if err != nil {
return ServicesLoadedMsg{err: err}
}
return ServicesLoadedMsg{services: services}
}
}
// validateSelectorCmd validates a label selector and returns matching pods
func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selector string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
defer cancel()
pods, err := discovery.ListPodsWithSelector(ctx, contextName, namespace, selector)
if err != nil {
return SelectorValidatedMsg{valid: false, err: err}
}
return SelectorValidatedMsg{
valid: len(pods) > 0,
pods: pods,
}
}
}
// checkPortCmd checks if a local port is available
func checkPortCmd(port int) tea.Cmd {
return func() tea.Msg {
available, processInfo, err := k8s.CheckPortAvailability(port)
msg := ""
if err != nil {
msg = fmt.Sprintf("✗ Error: %v", err)
} else if available {
msg = fmt.Sprintf("✓ Port %d available", port)
} else {
msg = fmt.Sprintf("✗ Port %d in use by %s", port, processInfo)
}
return PortCheckedMsg{
port: port,
available: available,
message: msg,
}
}
}
// saveForwardCmd saves a new forward to the configuration file
func saveForwardCmd(mutator *config.Mutator, contextName, namespace string, fwd config.Forward) tea.Cmd {
return func() tea.Msg {
err := mutator.AddForward(contextName, namespace, fwd)
return ForwardSavedMsg{
success: err == nil,
err: err,
}
}
}
// updateForwardCmd atomically updates an existing forward (used in edit mode)
func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
return func() tea.Msg {
err := mutator.UpdateForward(oldID, contextName, namespace, fwd)
return ForwardSavedMsg{
success: err == nil,
err: err,
}
}
}
// removeForwardsCmd removes selected forwards from the configuration file
func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd {
return func() tea.Msg {
// Create a map of IDs to remove
idsToRemove := make(map[string]bool)
for _, fwd := range forwards {
idsToRemove[fwd.ID] = true
}
// Remove forwards matching the IDs
err := mutator.RemoveForwards(func(ctx, ns string, fwd config.Forward) bool {
return idsToRemove[fwd.ID()]
})
return ForwardsRemovedMsg{
success: err == nil,
count: len(forwards),
err: err,
}
}
}
// removeForwardByIDCmd removes a single forward by its ID
func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
return func() tea.Msg {
err := mutator.RemoveForwardByID(id)
return ForwardsRemovedMsg{
success: err == nil,
count: 1,
err: err,
}
}
}
+763
View File
@@ -0,0 +1,763 @@
package ui
import (
"fmt"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
// 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 " ", "enter":
m.ui.toggleSelected()
case "n": // Enter add wizard
m.ui.mu.Lock()
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()
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()
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 = 0 // Default to "No" for safety
m.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.deleteConfirming = false
m.ui.deleteConfirmID = ""
m.ui.deleteConfirmAlias = ""
m.ui.deleteConfirmCursor = 0 // Reset cursor
m.ui.mu.Unlock()
// Force a repaint by returning the model
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.deleteConfirming = false
m.ui.deleteConfirmID = ""
m.ui.deleteConfirmAlias = ""
m.ui.mu.Unlock()
return m, removeForwardByIDCmd(m.ui.mutator, id)
}
// Enter on No = cancel
m.ui.deleteConfirming = false
m.ui.deleteConfirmID = ""
m.ui.deleteConfirmAlias = ""
m.ui.deleteConfirmCursor = 0 // Reset cursor
m.ui.mu.Unlock()
return m, tea.ClearScreen
case "n":
// Quick 'n' for no
m.ui.deleteConfirming = false
m.ui.deleteConfirmID = ""
m.ui.deleteConfirmAlias = ""
m.ui.deleteConfirmCursor = 0 // Reset cursor
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":
// 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.cursor = 0
wizard.clearTextInput()
wizard.error = nil
// 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 "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
canBackspace := wizard.inputMode == InputModeText ||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias)
if canBackspace && 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]))
// 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
switch wizard.step {
case StepSelectContext:
if wizard.cursor >= 0 && wizard.cursor < len(wizard.contexts) {
wizard.selectedContext = wizard.contexts[wizard.cursor]
wizard.step = StepSelectNamespace
wizard.cursor = 0
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]
wizard.step = StepSelectResourceType
wizard.cursor = 0
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:
if wizard.cursor >= 0 && wizard.cursor < len(wizard.services) {
wizard.resourceValue = wizard.services[wizard.cursor].Name
wizard.step = StepEnterRemotePort
wizard.clearTextInput()
// Get ports from selected service
wizard.detectedPorts = wizard.services[wizard.cursor].Ports
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
wizard.remotePort = int(wizard.detectedPorts[wizard.cursor].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 || port < 1 || port > 65535 {
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 || port < 1 || port > 65535 {
wizard.error = fmt.Errorf("invalid port number")
} else {
wizard.localPort = port
wizard.step = StepConfirmation
wizard.clearTextInput()
wizard.cursor = 0
wizard.inputMode = InputModeList
wizard.error = nil
wizard.loading = true
return m, checkPortCmd(port)
}
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 {
// 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,
}
if wizard.selectedResourceType == ResourceTypePodPrefix {
fwd.Resource = "pod/" + wizard.resourceValue
} else if wizard.selectedResourceType == ResourceTypePodSelector {
fwd.Resource = wizard.resourceValue
fwd.Selector = wizard.selector
} else if wizard.selectedResourceType == 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
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
}
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
m.ui.viewMode = ViewModeMain
m.ui.addWizard = nil
}
}
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 " ":
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
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
}
}
}
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
}
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, nil
}
+302
View File
@@ -0,0 +1,302 @@
package ui
import (
"github.com/nvm/kportal/internal/k8s"
)
// ViewMode represents the current view state of the UI
type ViewMode int
const (
ViewModeMain ViewMode = iota
ViewModeAddWizard
ViewModeRemoveWizard
)
// InputMode represents whether the wizard is in list selection or text input mode
type InputMode int
const (
InputModeList InputMode = iota
InputModeText
)
// AddWizardStep represents the current step in the add wizard flow
type AddWizardStep int
const (
StepSelectContext AddWizardStep = iota
StepSelectNamespace
StepSelectResourceType
StepEnterResource
StepEnterRemotePort
StepEnterLocalPort
StepConfirmation
StepSuccess
)
// ConfirmationFocus represents what the user is focused on in confirmation step
type ConfirmationFocus int
const (
FocusAlias ConfirmationFocus = iota
FocusButtons
)
// ResourceType represents the type of Kubernetes resource to forward to
type ResourceType int
const (
ResourceTypePodPrefix ResourceType = iota
ResourceTypePodSelector
ResourceTypeService
)
// String returns a human-readable name for the resource type
func (r ResourceType) String() string {
switch r {
case ResourceTypePodPrefix:
return "Pod (by name prefix)"
case ResourceTypePodSelector:
return "Pod (by label selector)"
case ResourceTypeService:
return "Service"
default:
return "Unknown"
}
}
// Description returns a description of the resource type
func (r ResourceType) Description() string {
switch r {
case ResourceTypePodPrefix:
return "Recommended for specific pod instances"
case ResourceTypePodSelector:
return "Flexible, survives pod restarts automatically"
case ResourceTypeService:
return "Most stable, load-balanced"
default:
return ""
}
}
// 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
loading bool
error error
// Selections made by user
selectedContext string
selectedNamespace string
selectedResourceType ResourceType
resourceValue string // pod prefix or service name
selector string // for pod selector type
remotePort int
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
}
// newAddWizardState creates a new add wizard state initialized to the first step
func newAddWizardState() *AddWizardState {
return &AddWizardState{
step: StepSelectContext,
inputMode: InputModeList,
cursor: 0,
contexts: []string{},
}
}
// moveCursor moves the cursor up or down in list selection mode
func (w *AddWizardState) moveCursor(delta int) {
if w.inputMode != InputModeList {
return
}
var maxItems int
switch w.step {
case StepSelectContext:
maxItems = len(w.contexts)
case StepSelectNamespace:
maxItems = len(w.namespaces)
case StepSelectResourceType:
maxItems = 3 // Three resource types
case StepEnterResource:
if w.selectedResourceType == ResourceTypeService {
maxItems = len(w.services)
}
case StepEnterRemotePort:
if len(w.detectedPorts) > 0 {
maxItems = len(w.detectedPorts) + 1 // +1 for "Manual entry" option
}
}
w.cursor += delta
if w.cursor < 0 {
w.cursor = 0
}
if w.cursor >= maxItems && maxItems > 0 {
w.cursor = maxItems - 1
}
// Adjust scroll offset to keep cursor visible
// Viewport shows max 20 items at a time
const viewportHeight = 20
// If cursor moved below visible area, scroll down
if w.cursor >= w.scrollOffset+viewportHeight {
w.scrollOffset = w.cursor - viewportHeight + 1
}
// If cursor moved above visible area, scroll up
if w.cursor < w.scrollOffset {
w.scrollOffset = w.cursor
}
// Ensure scroll offset is valid
if w.scrollOffset < 0 {
w.scrollOffset = 0
}
}
// handleTextInput handles a single character input in text mode
func (w *AddWizardState) handleTextInput(char rune) {
// Note: Caller already checks if text input is allowed (inputMode or confirmation step)
// so we don't need to check inputMode here
// Handle backspace
if char == 127 || char == 8 {
if len(w.textInput) > 0 {
w.textInput = w.textInput[:len(w.textInput)-1]
}
return
}
// Only allow printable characters
if char >= 32 && char < 127 {
w.textInput += string(char)
}
}
// clearTextInput clears the text input field
func (w *AddWizardState) clearTextInput() {
w.textInput = ""
}
// RemoveWizardState maintains the state for the remove port forward wizard
type RemoveWizardState struct {
forwards []RemovableForward
cursor int
selected map[int]bool
confirming bool
confirmCursor int // 0 = Yes, 1 = No
}
// RemovableForward represents a forward that can be removed
type RemovableForward struct {
ID string
Context string
Namespace string
Alias string
Resource string
Selector string
Port int
LocalPort int
}
// moveCursor moves the cursor up or down
func (w *RemoveWizardState) moveCursor(delta int) {
if w.confirming {
// Move between Yes/No in confirmation
w.confirmCursor += delta
if w.confirmCursor < 0 {
w.confirmCursor = 0
}
if w.confirmCursor > 1 {
w.confirmCursor = 1
}
} else {
// Move between forwards
w.cursor += delta
if w.cursor < 0 {
w.cursor = 0
}
if w.cursor >= len(w.forwards) {
w.cursor = len(w.forwards) - 1
}
}
}
// toggleSelection toggles the selection of the current forward
func (w *RemoveWizardState) toggleSelection() {
if w.confirming {
return
}
w.selected[w.cursor] = !w.selected[w.cursor]
}
// selectAll selects all forwards for removal
func (w *RemoveWizardState) selectAll() {
if w.confirming {
return
}
for i := range w.forwards {
w.selected[i] = true
}
}
// selectNone deselects all forwards
func (w *RemoveWizardState) selectNone() {
if w.confirming {
return
}
w.selected = make(map[int]bool)
}
// getSelectedCount returns the number of selected forwards
func (w *RemoveWizardState) getSelectedCount() int {
count := 0
for _, selected := range w.selected {
if selected {
count++
}
}
return count
}
// getSelectedForwards returns a list of selected forwards
func (w *RemoveWizardState) getSelectedForwards() []RemovableForward {
selected := make([]RemovableForward, 0)
for i, fwd := range w.forwards {
if w.selected[i] {
selected = append(selected, fwd)
}
}
return selected
}
+211
View File
@@ -0,0 +1,211 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Color palette for wizards
var (
primaryColor = lipgloss.Color("205") // Pink/Magenta
successColor = lipgloss.Color("42") // Green
errorColor = lipgloss.Color("196") // Red
warningColor = lipgloss.Color("220") // Yellow
mutedColor = lipgloss.Color("241") // Gray
accentColor = lipgloss.Color("63") // Purple
highlightColor = lipgloss.Color("117") // Light blue
)
// Text styles
var (
wizardHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(primaryColor).
MarginBottom(0)
wizardStepStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true)
breadcrumbStyle = lipgloss.NewStyle().
Foreground(highlightColor).
Bold(true)
selectedStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
errorStyle = lipgloss.NewStyle().
Foreground(errorColor).
Bold(true)
warningStyle = lipgloss.NewStyle().
Foreground(warningColor).
Bold(true)
mutedStyle = lipgloss.NewStyle().
Foreground(mutedColor)
helpStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true)
spinnerStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
)
// Input styles
var (
inputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("252"))
validInputStyle = lipgloss.NewStyle().
Foreground(successColor)
)
// Checkbox styles
var (
checkedBoxStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
uncheckedBoxStyle = lipgloss.NewStyle().
Foreground(mutedColor)
)
// Container styles
var (
wizardBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentColor).
Padding(1, 2).
Width(60)
)
// Helper functions for rendering
// renderProgress returns a step indicator like "Step 2/7"
func renderProgress(current, total int) string {
return wizardStepStyle.Render(fmt.Sprintf("Step %d/%d", current, total))
}
// renderHeader returns a formatted header with title and progress
func renderHeader(title, progress string) string {
header := wizardHeaderStyle.Render(title)
if progress != "" {
header += " " + progress
}
return header + "\n\n"
}
// renderBreadcrumb returns a formatted breadcrumb path
func renderBreadcrumb(parts ...string) string {
return breadcrumbStyle.Render(strings.Join(parts, " / "))
}
// renderList renders a list of items with cursor selection and viewport scrolling
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
if scrollOffset > 0 {
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
}
// Calculate visible range
start := scrollOffset
end := scrollOffset + viewportHeight
if end > totalItems {
end = totalItems
}
// Render visible items
for i := start; i < end; i++ {
cursorPrefix := prefix
if i == cursor {
cursorPrefix = "▸ "
b.WriteString(selectedStyle.Render(cursorPrefix + items[i]))
} else {
b.WriteString(cursorPrefix + items[i])
}
b.WriteString("\n")
}
// Show scroll down indicator if there are items below the viewport
if end < totalItems {
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
}
return b.String()
}
// renderTextInput renders a text input field with a cursor
func renderTextInput(label, value string, valid bool) string {
var b strings.Builder
b.WriteString(label)
inputText := value + "█"
if valid {
b.WriteString(validInputStyle.Render(inputText))
} else {
b.WriteString(inputStyle.Render(inputText))
}
return b.String()
}
// overlayContent overlays modal content centered on the base view
func overlayContent(base, modal string, termWidth, termHeight int) string {
baseLines := strings.Split(base, "\n")
modalLines := strings.Split(modal, "\n")
// Ensure base has enough lines
for len(baseLines) < termHeight {
baseLines = append(baseLines, "")
}
modalHeight := len(modalLines)
modalWidth := 0
for _, line := range modalLines {
w := lipgloss.Width(line)
if w > modalWidth {
modalWidth = w
}
}
// Calculate center position
startRow := (termHeight - modalHeight) / 2
if startRow < 0 {
startRow = 0
}
// Create result with modal overlaid
result := make([]string, len(baseLines))
copy(result, baseLines)
for i, modalLine := range modalLines {
row := startRow + i
if row >= 0 && row < len(result) {
// Center the modal line
padding := (termWidth - lipgloss.Width(modalLine)) / 2
if padding < 0 {
padding = 0
}
result[row] = strings.Repeat(" ", padding) + modalLine
}
}
return strings.Join(result, "\n")
}
+602
View File
@@ -0,0 +1,602 @@
package ui
import (
"fmt"
"strings"
)
// renderAddWizard renders the appropriate step of the add wizard
func (m model) renderAddWizard() string {
if m.ui.addWizard == nil {
return ""
}
wizard := m.ui.addWizard
var content string
switch wizard.step {
case StepSelectContext:
content = m.renderSelectContext()
case StepSelectNamespace:
content = m.renderSelectNamespace()
case StepSelectResourceType:
content = m.renderSelectResourceType()
case StepEnterResource:
content = m.renderEnterResource()
case StepEnterRemotePort:
content = m.renderEnterRemotePort()
case StepEnterLocalPort:
content = m.renderEnterLocalPort()
case StepConfirmation:
content = m.renderConfirmation()
case StepSuccess:
content = m.renderSuccess()
default:
content = "Unknown step"
}
return wizardBoxStyle.Render(content)
}
func (m model) renderSelectContext() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(1, 7)))
b.WriteString("Select Kubernetes Context:\n\n")
if wizard.loading {
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
} else if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", wizard.error)))
} else if len(wizard.contexts) == 0 {
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
} else {
const viewportHeight = 20
totalItems := len(wizard.contexts)
// 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)")
}
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"))
return b.String()
}
func (m model) renderSelectNamespace() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)))
b.WriteString("Select Namespace:\n\n")
if wizard.loading {
b.WriteString(spinnerStyle.Render("⣾ Loading namespaces..."))
} else if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v\n", wizard.error)))
b.WriteString(mutedStyle.Render("\nCluster may be unreachable. Check context."))
} else if len(wizard.namespaces) == 0 {
b.WriteString(mutedStyle.Render("No namespaces found"))
} else {
b.WriteString(renderList(wizard.namespaces, wizard.cursor, " ", wizard.scrollOffset))
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
return b.String()
}
func (m model) renderSelectResourceType() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(3, 7)))
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
b.WriteString("\n\n")
b.WriteString("Select Resource Type:\n\n")
resourceTypes := []ResourceType{
ResourceTypePodPrefix,
ResourceTypePodSelector,
ResourceTypeService,
}
for i, rt := range resourceTypes {
prefix := " "
if i == wizard.cursor {
prefix = "▸ "
b.WriteString(selectedStyle.Render(prefix + rt.String()))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(" " + rt.Description()))
} else {
b.WriteString(prefix + rt.String())
}
b.WriteString("\n")
if i < len(resourceTypes)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
return b.String()
}
func (m model) renderEnterResource() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(4, 7)))
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
b.WriteString("\n\n")
switch wizard.selectedResourceType {
case ResourceTypePodPrefix:
b.WriteString("Enter pod name prefix:\n\n")
// Show running pods for reference
if wizard.loading {
b.WriteString(spinnerStyle.Render("⣾ Loading pods..."))
} else if len(wizard.pods) > 0 {
b.WriteString(mutedStyle.Render("Running pods:\n"))
showCount := 0
for _, pod := range wizard.pods {
if strings.HasPrefix(pod.Name, wizard.textInput) || wizard.textInput == "" {
if showCount < 5 { // Limit to 5 pods
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name)))
showCount++
}
}
}
if showCount == 0 && wizard.textInput != "" {
b.WriteString(mutedStyle.Render(" (no matching pods)\n"))
} else if len(wizard.pods) > showCount {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.pods)-showCount)))
}
b.WriteString("\n")
}
// Text input
b.WriteString(renderTextInput("Prefix: ", wizard.textInput, true))
b.WriteString("\n\n")
// Show match count
if wizard.textInput != "" {
matchCount := 0
for _, pod := range wizard.pods {
if strings.HasPrefix(pod.Name, wizard.textInput) {
matchCount++
}
}
if matchCount > 0 {
b.WriteString(successStyle.Render(fmt.Sprintf("✓ Matches %d pod(s)", matchCount)))
} else {
b.WriteString(warningStyle.Render("⚠ No matching pods (you can still proceed)"))
}
}
case ResourceTypePodSelector:
b.WriteString("Enter label selector:\n")
b.WriteString(mutedStyle.Render("Format: key=value,key2=value2\n\n"))
b.WriteString(renderTextInput("Selector: ", wizard.textInput, true))
b.WriteString("\n\n")
if wizard.loading {
b.WriteString(spinnerStyle.Render("⣾ Validating selector..."))
} else if len(wizard.matchingPods) > 0 {
b.WriteString(successStyle.Render(fmt.Sprintf("✓ Found %d matching pod(s):\n", len(wizard.matchingPods))))
showCount := 0
for _, pod := range wizard.matchingPods {
if showCount < 3 {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name)))
showCount++
}
}
if len(wizard.matchingPods) > 3 {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.matchingPods)-3)))
}
} else if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Invalid selector: %v", wizard.error)))
}
case ResourceTypeService:
b.WriteString("Select service:\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
}
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
return b.String()
}
func (m model) renderEnterRemotePort() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(5, 7)))
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
b.WriteString("\n")
// Show resource selection
resourceInfo := wizard.resourceValue
if wizard.selector != "" {
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
}
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n\n", resourceInfo)))
// If we have detected ports and in list mode, show them as a list
if len(wizard.detectedPorts) > 0 && wizard.inputMode == InputModeList {
b.WriteString("Select remote port:\n\n")
const viewportHeight = 20
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
// 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 detected ports within viewport
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
port := wizard.detectedPorts[i]
portDesc := fmt.Sprintf("%d", port.Port)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
prefix := " "
if i == wizard.cursor {
prefix = "▸ "
b.WriteString(selectedStyle.Render(prefix + portDesc))
} else {
b.WriteString(prefix + portDesc)
}
b.WriteString("\n")
}
// Add "Manual entry" option if within viewport
manualIdx := len(wizard.detectedPorts)
if manualIdx >= start && manualIdx < end {
manualOption := "Manual entry (type port number)"
prefix := " "
if wizard.cursor == manualIdx {
prefix = "▸ "
b.WriteString(selectedStyle.Render(prefix + manualOption))
} else {
b.WriteString(mutedStyle.Render(prefix + manualOption))
}
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: Back Ctrl+C: Cancel"))
} else {
// Text input mode (no detected ports or user chose manual entry)
if len(wizard.detectedPorts) > 0 {
b.WriteString(mutedStyle.Render("Detected ports:\n"))
for _, port := range wizard.detectedPorts {
portDesc := fmt.Sprintf("%d", port.Port)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
}
b.WriteString("\n")
}
b.WriteString(renderTextInput("Remote port: ", wizard.textInput, wizard.error == nil))
b.WriteString("\n\n")
if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v", wizard.error)))
} else if wizard.textInput != "" {
b.WriteString(mutedStyle.Render("Press Enter to continue"))
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
}
return b.String()
}
func (m model) renderEnterLocalPort() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(6, 7)))
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
b.WriteString("\n")
resourceInfo := wizard.resourceValue
if wizard.selector != "" {
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
}
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n", resourceInfo)))
b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d\n\n", wizard.remotePort)))
b.WriteString(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil))
b.WriteString("\n\n")
if wizard.loading {
b.WriteString(spinnerStyle.Render("⣾ Checking availability..."))
} else if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v", wizard.error)))
} else if wizard.portCheckMsg != "" {
if wizard.portAvailable {
b.WriteString(successStyle.Render(wizard.portCheckMsg))
} else {
b.WriteString(errorStyle.Render(wizard.portCheckMsg))
}
} else if wizard.textInput != "" && wizard.localPort > 0 {
b.WriteString(mutedStyle.Render("Press Enter to check availability"))
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
return b.String()
}
func (m model) renderConfirmation() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(7, 7)))
b.WriteString("\n")
b.WriteString("Review Configuration:\n\n")
resourceInfo := wizard.resourceValue
if wizard.selector != "" {
resourceInfo = fmt.Sprintf("pod (selector: %s)", wizard.selector)
} else if wizard.selectedResourceType == ResourceTypePodPrefix {
resourceInfo = fmt.Sprintf("pod/%s", wizard.resourceValue)
} else if wizard.selectedResourceType == ResourceTypeService {
resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue)
}
b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext))
b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace))
b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo))
b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort))
b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort))
b.WriteString(" Protocol: tcp\n")
b.WriteString("\n")
// Show alias field with focus indicator
if wizard.confirmationFocus == FocusAlias {
b.WriteString(selectedStyle.Render("▸ Optional alias (friendly name):") + "\n")
b.WriteString(" Alias: " + validInputStyle.Render(wizard.textInput+"█") + "\n")
} else {
b.WriteString(mutedStyle.Render(" Optional alias (friendly name):") + "\n")
b.WriteString(mutedStyle.Render(" Alias: "+wizard.textInput) + "\n")
}
b.WriteString("\n")
// Show buttons with focus indicator
if wizard.confirmationFocus == FocusButtons {
if wizard.cursor == 0 {
b.WriteString(selectedStyle.Render("▸ Add to .kportal.yaml") + "\n")
b.WriteString(" Cancel\n")
} else {
b.WriteString(" Add to .kportal.yaml\n")
b.WriteString(selectedStyle.Render("▸ Cancel") + "\n")
}
} else {
b.WriteString(mutedStyle.Render(" Add to .kportal.yaml") + "\n")
b.WriteString(mutedStyle.Render(" Cancel") + "\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back"))
return b.String()
}
func (m model) renderSuccess() string {
wizard := m.ui.addWizard
var b strings.Builder
b.WriteString(successStyle.Render("Success! ✓"))
b.WriteString("\n\n")
if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", wizard.error)))
} else {
b.WriteString("Added to .kportal.yaml\n\n")
forwardDesc := fmt.Sprintf("localhost:%d → %s:%d",
wizard.localPort,
wizard.resourceValue,
wizard.remotePort)
if wizard.alias != "" {
forwardDesc = fmt.Sprintf("%s (%s)", wizard.alias, forwardDesc)
}
b.WriteString(successStyle.Render(forwardDesc))
b.WriteString("\n\n")
b.WriteString(mutedStyle.Render("The port forward will be active shortly."))
}
b.WriteString("\n\n")
b.WriteString("Would you like to:\n")
if wizard.cursor == 0 {
b.WriteString(selectedStyle.Render("▸ Add another port forward") + "\n")
b.WriteString(" Return to main view\n")
} else {
b.WriteString(" Add another port forward\n")
b.WriteString(selectedStyle.Render("▸ Return to main view") + "\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select"))
return b.String()
}
// renderRemoveWizard renders the remove wizard
func (m model) renderRemoveWizard() string {
if m.ui.removeWizard == nil {
return ""
}
wizard := m.ui.removeWizard
var content string
if wizard.confirming {
content = m.renderRemoveConfirmation()
} else {
content = m.renderRemoveSelection()
}
return wizardBoxStyle.Render(content)
}
func (m model) renderRemoveSelection() string {
wizard := m.ui.removeWizard
var b strings.Builder
b.WriteString(renderHeader("Remove Port Forwards", ""))
b.WriteString("\n")
b.WriteString("Select forwards to remove (Space to toggle):\n\n")
for i, fwd := range wizard.forwards {
isSelected := i == wizard.cursor
isChecked := wizard.selected[i]
line1 := fmt.Sprintf("%s:%d→%d", fwd.Alias, fwd.Port, fwd.LocalPort)
line2 := fmt.Sprintf(" %s/%s/%s", fwd.Context, fwd.Namespace, fwd.Resource)
checkbox := "[ ] "
if isChecked {
checkbox = "[✓] "
}
fullLine := checkbox + line1
if isSelected {
b.WriteString(selectedStyle.Render(fullLine))
} else {
if isChecked {
b.WriteString(checkedBoxStyle.Render(checkbox) + line1)
} else {
b.WriteString(uncheckedBoxStyle.Render(checkbox) + line1)
}
}
b.WriteString("\n")
b.WriteString(mutedStyle.Render(line2))
b.WriteString("\n\n")
}
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel"))
return b.String()
}
func (m model) renderRemoveConfirmation() string {
wizard := m.ui.removeWizard
var b strings.Builder
b.WriteString(renderHeader("Confirm Removal", ""))
b.WriteString("\n")
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount))
selectedForwards := wizard.getSelectedForwards()
for _, fwd := range selectedForwards {
b.WriteString(errorStyle.Render(fmt.Sprintf(" • %s:%d→%d\n", fwd.Alias, fwd.Port, fwd.LocalPort)))
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s/%s/%s\n", fwd.Context, fwd.Namespace, fwd.Resource)))
}
b.WriteString("\n")
b.WriteString(warningStyle.Render("This action cannot be undone."))
b.WriteString("\n\n")
// Yes/No buttons
if wizard.confirmCursor == 0 {
b.WriteString(selectedStyle.Render("▸ Yes, remove them") + "\n")
b.WriteString(" Cancel\n")
} else {
b.WriteString(" Yes, remove them\n")
b.WriteString(selectedStyle.Render("▸ Cancel") + "\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel"))
return b.String()
}