mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-10 06:52:00 +00:00
+268
-41
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user