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()