mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-08 23:39:46 +00:00
838 lines
21 KiB
Go
838 lines
21 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/lipgloss/table"
|
|
"github.com/nvm/kportal/internal/config"
|
|
"github.com/nvm/kportal/internal/k8s"
|
|
)
|
|
|
|
// safeRecover recovers from panics and logs them
|
|
// Use with defer at the start of goroutines and callbacks that could panic
|
|
func safeRecover(context string) {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[UI] Panic recovered in %s: %v", context, r)
|
|
}
|
|
}
|
|
|
|
// ForwardUpdateMsg is sent when a forward status changes
|
|
type ForwardUpdateMsg struct {
|
|
ID string
|
|
Status string
|
|
}
|
|
|
|
// ForwardErrorMsg is sent when a forward has an error
|
|
type ForwardErrorMsg struct {
|
|
ID string
|
|
Error string
|
|
}
|
|
|
|
// ForwardAddMsg is sent when a new forward is added
|
|
type ForwardAddMsg struct {
|
|
ID string
|
|
Forward *ForwardStatus
|
|
}
|
|
|
|
// ForwardRemoveMsg is sent when a forward is removed
|
|
type ForwardRemoveMsg struct {
|
|
ID string
|
|
}
|
|
|
|
// HTTPLogSubscriber is a function that subscribes to HTTP logs for a forward
|
|
// It returns a cleanup function to call when unsubscribing
|
|
type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry)) func()
|
|
|
|
// BubbleTeaUI is a bubbletea-based terminal UI
|
|
type BubbleTeaUI struct {
|
|
mu sync.RWMutex
|
|
program *tea.Program
|
|
forwards map[string]*ForwardStatus
|
|
forwardOrder []string
|
|
selectedIndex int
|
|
disabledMap map[string]bool
|
|
toggleCallback func(id string, enable bool)
|
|
version string
|
|
errors map[string]string // Track error messages by forward ID
|
|
|
|
// Update notification
|
|
updateAvailable bool
|
|
updateVersion string
|
|
updateURL string
|
|
|
|
// Modal wizard state
|
|
viewMode ViewMode
|
|
addWizard *AddWizardState
|
|
removeWizard *RemoveWizardState
|
|
|
|
// Delete confirmation state
|
|
deleteConfirming bool
|
|
deleteConfirmID string
|
|
deleteConfirmAlias string
|
|
deleteConfirmCursor int // 0 = Yes, 1 = No
|
|
|
|
// Benchmark state
|
|
benchmarkState *BenchmarkState
|
|
|
|
// HTTP log viewing state
|
|
httpLogState *HTTPLogState
|
|
|
|
// Log callback cleanup function
|
|
httpLogCleanup func()
|
|
|
|
// Dependencies for wizards
|
|
discovery *k8s.Discovery
|
|
mutator *config.Mutator
|
|
configPath string
|
|
|
|
// Manager for accessing workers
|
|
httpLogSubscriber HTTPLogSubscriber
|
|
}
|
|
|
|
// bubbletea model
|
|
type model struct {
|
|
ui *BubbleTeaUI
|
|
termWidth int
|
|
termHeight int
|
|
}
|
|
|
|
// NewBubbleTeaUI creates a new bubbletea-based UI
|
|
func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string) *BubbleTeaUI {
|
|
ui := &BubbleTeaUI{
|
|
forwards: make(map[string]*ForwardStatus),
|
|
forwardOrder: make([]string, 0),
|
|
selectedIndex: 0,
|
|
disabledMap: make(map[string]bool),
|
|
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
|
|
}
|
|
|
|
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
|
|
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
|
|
ui.mu.Lock()
|
|
defer ui.mu.Unlock()
|
|
|
|
ui.httpLogSubscriber = subscriber
|
|
}
|
|
|
|
// SetUpdateAvailable sets the update notification to be displayed
|
|
func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
|
|
ui.mu.Lock()
|
|
defer ui.mu.Unlock()
|
|
|
|
ui.updateAvailable = true
|
|
ui.updateVersion = version
|
|
ui.updateURL = url
|
|
}
|
|
|
|
// Start starts the bubbletea application
|
|
func (ui *BubbleTeaUI) Start() error {
|
|
m := model{ui: ui}
|
|
ui.program = tea.NewProgram(m, tea.WithAltScreen())
|
|
_, err := ui.program.Run()
|
|
return err
|
|
}
|
|
|
|
// Stop stops the application
|
|
func (ui *BubbleTeaUI) Stop() {
|
|
if ui.program != nil {
|
|
ui.program.Quit()
|
|
}
|
|
}
|
|
|
|
// AddForward adds a forward to display
|
|
func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
|
|
ui.mu.Lock()
|
|
|
|
// Check if already exists (re-enabling case)
|
|
if existing, ok := ui.forwards[id]; ok {
|
|
existing.Status = "Starting"
|
|
ui.disabledMap[id] = false
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardUpdateMsg{ID: id, Status: "Starting"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parse resource
|
|
resourceType := "pod"
|
|
resourceName := fwd.Resource
|
|
for idx := 0; idx < len(fwd.Resource); idx++ {
|
|
if fwd.Resource[idx] == '/' {
|
|
resourceType = fwd.Resource[:idx]
|
|
resourceName = fwd.Resource[idx+1:]
|
|
break
|
|
}
|
|
}
|
|
|
|
alias := fwd.Alias
|
|
if alias == "" {
|
|
alias = resourceName
|
|
}
|
|
|
|
status := &ForwardStatus{
|
|
Context: fwd.GetContext(),
|
|
Namespace: fwd.GetNamespace(),
|
|
Alias: alias,
|
|
Type: resourceType,
|
|
Resource: resourceName,
|
|
RemotePort: fwd.Port,
|
|
LocalPort: fwd.LocalPort,
|
|
Status: "Starting",
|
|
}
|
|
|
|
ui.forwards[id] = status
|
|
ui.forwardOrder = append(ui.forwardOrder, id)
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardAddMsg{ID: id, Forward: status})
|
|
}
|
|
}
|
|
|
|
// UpdateStatus updates forward status
|
|
func (ui *BubbleTeaUI) UpdateStatus(id string, status string) {
|
|
ui.mu.Lock()
|
|
if fwd, ok := ui.forwards[id]; ok {
|
|
fwd.Status = status
|
|
}
|
|
// Only clear error when forward becomes Active again
|
|
// This keeps error visible during Reconnecting/Starting states
|
|
if status == "Active" {
|
|
delete(ui.errors, id)
|
|
}
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardUpdateMsg{ID: id, Status: status})
|
|
}
|
|
}
|
|
|
|
// SetError sets an error message for a forward
|
|
func (ui *BubbleTeaUI) SetError(id, msg string) {
|
|
ui.mu.Lock()
|
|
ui.errors[id] = msg
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardErrorMsg{ID: id, Error: msg})
|
|
}
|
|
}
|
|
|
|
// Remove removes a forward
|
|
func (ui *BubbleTeaUI) Remove(id string) {
|
|
ui.mu.Lock()
|
|
delete(ui.forwards, id)
|
|
|
|
// Clear any error associated with this forward
|
|
delete(ui.errors, id)
|
|
|
|
// Remove from order
|
|
removedIndex := -1
|
|
for i, fid := range ui.forwardOrder {
|
|
if fid == id {
|
|
removedIndex = i
|
|
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Adjust selectedIndex if necessary
|
|
if removedIndex >= 0 {
|
|
// If we removed the selected item or an item before it, adjust
|
|
if ui.selectedIndex >= len(ui.forwardOrder) {
|
|
ui.selectedIndex = len(ui.forwardOrder) - 1
|
|
}
|
|
// Ensure selectedIndex is never negative
|
|
if ui.selectedIndex < 0 {
|
|
ui.selectedIndex = 0
|
|
}
|
|
}
|
|
|
|
// Clear delete confirmation if we're deleting the same forward
|
|
if ui.deleteConfirming && ui.deleteConfirmID == id {
|
|
ui.resetDeleteConfirmation()
|
|
}
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardRemoveMsg{ID: id})
|
|
}
|
|
}
|
|
|
|
// Bubble Tea Model Implementation
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
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:
|
|
// 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 ViewModeBenchmark:
|
|
return m.handleBenchmarkKeys(msg)
|
|
case ViewModeHTTPLog:
|
|
return m.handleHTTPLogKeys(msg)
|
|
}
|
|
|
|
// Forward management messages (always update main view data)
|
|
case ForwardAddMsg, ForwardUpdateMsg, ForwardErrorMsg, ForwardRemoveMsg:
|
|
return m, nil
|
|
|
|
// 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, tea.ClearScreen
|
|
|
|
case BenchmarkCompleteMsg:
|
|
return m.handleBenchmarkComplete(msg)
|
|
|
|
case BenchmarkProgressMsg:
|
|
return m.handleBenchmarkProgress(msg)
|
|
|
|
case HTTPLogEntryMsg:
|
|
return m.handleHTTPLogEntry(msg)
|
|
|
|
case clearCopyMessageMsg:
|
|
m.ui.mu.Lock()
|
|
if m.ui.httpLogState != nil {
|
|
m.ui.httpLogState.copyMessage = ""
|
|
}
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
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)
|
|
case ViewModeBenchmark:
|
|
modal := m.renderBenchmark()
|
|
return overlayContent(mainView, modal, termWidth, termHeight)
|
|
case ViewModeHTTPLog:
|
|
// HTTP Log is full-screen, don't overlay on main view
|
|
return m.renderHTTPLog()
|
|
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
|
|
}
|
|
|
|
// Color palette
|
|
headerColor := lipgloss.Color("220") // Yellow
|
|
activeColor := lipgloss.Color("46") // Green
|
|
warningColor := lipgloss.Color("220") // Yellow
|
|
errorColor := lipgloss.Color("196") // Red
|
|
mutedColor := lipgloss.Color("240") // Gray
|
|
selectedBg := lipgloss.Color("240") // Gray background
|
|
selectedFg := lipgloss.Color("230") // Light foreground
|
|
|
|
// Title with version
|
|
titleStyle := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(headerColor).
|
|
Padding(0, 1)
|
|
|
|
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
|
b.WriteString(titleStyle.Render(title))
|
|
|
|
// Show update notification if available
|
|
if m.ui.updateAvailable {
|
|
updateStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("42")). // Green
|
|
Bold(true)
|
|
updateMsg := fmt.Sprintf(" Update available: v%s", m.ui.updateVersion)
|
|
b.WriteString(updateStyle.Render(updateMsg))
|
|
}
|
|
b.WriteString("\n\n")
|
|
|
|
// No forwards
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
|
b.WriteString(disabledStyle.Render("No forwards configured\n"))
|
|
} else {
|
|
// Build table rows
|
|
var rows [][]string
|
|
for _, id := range m.ui.forwardOrder {
|
|
fwd, ok := m.ui.forwards[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
|
|
|
// Status icon and text
|
|
statusIcon := "●"
|
|
statusText := fwd.Status
|
|
|
|
if isDisabled {
|
|
statusIcon = "○"
|
|
statusText = "Disabled"
|
|
} else {
|
|
switch fwd.Status {
|
|
case "Starting":
|
|
statusIcon = "○"
|
|
case "Reconnecting":
|
|
statusIcon = "◐"
|
|
case "Error":
|
|
statusIcon = "✗"
|
|
}
|
|
}
|
|
|
|
rows = append(rows, []string{
|
|
truncate(fwd.Context, 14),
|
|
truncate(fwd.Namespace, 16),
|
|
truncate(fwd.Alias, 18),
|
|
truncate(fwd.Type, 8),
|
|
truncate(fwd.Resource, 20),
|
|
fmt.Sprintf("%d", fwd.RemotePort),
|
|
fmt.Sprintf("%d", fwd.LocalPort),
|
|
statusIcon + " " + statusText,
|
|
})
|
|
}
|
|
|
|
// Create table with styling (no borders for cleaner look)
|
|
t := table.New().
|
|
Border(lipgloss.HiddenBorder()).
|
|
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
|
Rows(rows...).
|
|
StyleFunc(func(row, col int) lipgloss.Style {
|
|
// Header row
|
|
if row == table.HeaderRow {
|
|
return lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(headerColor).
|
|
Padding(0, 1)
|
|
}
|
|
|
|
// Get the forward for this row to check its status
|
|
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
|
|
|
if row >= 0 && row < len(m.ui.forwardOrder) {
|
|
id := m.ui.forwardOrder[row]
|
|
fwd, ok := m.ui.forwards[id]
|
|
isSelected := row == m.ui.selectedIndex
|
|
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
|
|
|
|
// Selected row gets background highlight
|
|
if isSelected {
|
|
return baseStyle.
|
|
Background(selectedBg).
|
|
Foreground(selectedFg)
|
|
}
|
|
|
|
// Disabled rows are muted
|
|
if isDisabled {
|
|
return baseStyle.Foreground(mutedColor)
|
|
}
|
|
|
|
// Status column gets colored based on status
|
|
if col == 7 && ok { // STATUS column
|
|
switch fwd.Status {
|
|
case "Active":
|
|
return baseStyle.Foreground(activeColor)
|
|
case "Starting", "Reconnecting":
|
|
return baseStyle.Foreground(warningColor)
|
|
case "Error":
|
|
return baseStyle.Foreground(errorColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
return baseStyle
|
|
})
|
|
|
|
b.WriteString(t.Render())
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Display errors if any (before footer)
|
|
if len(m.ui.errors) > 0 {
|
|
b.WriteString("\n\n")
|
|
errorHeaderStyle := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("196"))
|
|
|
|
b.WriteString(errorHeaderStyle.Render("Errors:"))
|
|
b.WriteString("\n")
|
|
|
|
errorLineStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("196")).
|
|
Width(118). // Slightly less than table width (120) for padding
|
|
MaxWidth(118)
|
|
|
|
for id, errMsg := range m.ui.errors {
|
|
// Find the forward to display its alias
|
|
if fwd, ok := m.ui.forwards[id]; ok {
|
|
// Format: " • alias: error message"
|
|
prefix := fmt.Sprintf(" • %s: ", fwd.Alias)
|
|
|
|
// Wrap the error message if it's too long
|
|
// Max line length is 118, subtract prefix length
|
|
maxErrLen := 118 - len(prefix)
|
|
wrappedMsg := wrapText(errMsg, maxErrLen)
|
|
|
|
// Render first line with prefix
|
|
lines := strings.Split(wrappedMsg, "\n")
|
|
if len(lines) > 0 {
|
|
b.WriteString(errorLineStyle.Render(prefix + lines[0]))
|
|
b.WriteString("\n")
|
|
|
|
// Render subsequent lines with indentation
|
|
indent := strings.Repeat(" ", len(prefix))
|
|
for i := 1; i < len(lines); i++ {
|
|
b.WriteString(errorLineStyle.Render(indent + lines[i]))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
|
|
// Get terminal width for footer wrapping
|
|
termWidth := m.termWidth
|
|
if termWidth == 0 {
|
|
termWidth = 120
|
|
}
|
|
|
|
// Define key bindings as structured data for flexible rendering
|
|
type keyBinding struct {
|
|
key string
|
|
desc string
|
|
}
|
|
bindings := []keyBinding{
|
|
{"↑↓/jk", "Navigate"},
|
|
{"Space", "Toggle"},
|
|
{"n", "New"},
|
|
{"e", "Edit"},
|
|
{"d", "Delete"},
|
|
{"b", "Bench"},
|
|
{"l", "Logs"},
|
|
{"q", "Quit"},
|
|
}
|
|
|
|
// Build footer lines that fit within terminal width
|
|
var footerLines []string
|
|
var currentLine strings.Builder
|
|
currentLineVisualLen := 0
|
|
|
|
// Calculate how much space we need for the total count suffix
|
|
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
|
|
totalSuffixLen := len(totalSuffix)
|
|
|
|
// Available width (account for some margin)
|
|
availableWidth := termWidth - 4
|
|
|
|
for i, binding := range bindings {
|
|
// Build this binding's text
|
|
keyRendered := keyStyle.Render(binding.key)
|
|
bindingText := keyRendered + ": " + binding.desc
|
|
// Visual length without ANSI codes
|
|
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
|
|
|
|
// Add separator if not first item on line
|
|
separator := ""
|
|
separatorLen := 0
|
|
if currentLine.Len() > 0 {
|
|
separator = " "
|
|
separatorLen = 2
|
|
}
|
|
|
|
// Check if this binding fits on current line
|
|
// For the last binding, also need to fit the total suffix
|
|
neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen
|
|
if i == len(bindings)-1 {
|
|
neededWidth += totalSuffixLen
|
|
}
|
|
|
|
if neededWidth > availableWidth && currentLine.Len() > 0 {
|
|
// Start a new line
|
|
footerLines = append(footerLines, currentLine.String())
|
|
currentLine.Reset()
|
|
currentLineVisualLen = 0
|
|
separator = ""
|
|
separatorLen = 0
|
|
}
|
|
|
|
currentLine.WriteString(separator)
|
|
currentLine.WriteString(bindingText)
|
|
currentLineVisualLen += separatorLen + bindingVisualLen
|
|
}
|
|
|
|
// Add total count to the last line
|
|
currentLine.WriteString(totalSuffix)
|
|
footerLines = append(footerLines, currentLine.String())
|
|
|
|
// Calculate footer height
|
|
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
|
|
remainingLines := termHeight - currentLines - footerHeight
|
|
if remainingLines > 0 {
|
|
b.WriteString(strings.Repeat("\n", remainingLines))
|
|
}
|
|
|
|
// Add footer at bottom
|
|
b.WriteString("\n")
|
|
for i, line := range footerLines {
|
|
if i > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(footerStyle.Render(line))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// 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()
|
|
defer ui.mu.Unlock()
|
|
|
|
if len(ui.forwardOrder) == 0 {
|
|
return
|
|
}
|
|
|
|
ui.selectedIndex += delta
|
|
if ui.selectedIndex < 0 {
|
|
ui.selectedIndex = 0
|
|
}
|
|
if ui.selectedIndex >= len(ui.forwardOrder) {
|
|
ui.selectedIndex = len(ui.forwardOrder) - 1
|
|
}
|
|
}
|
|
|
|
// resetDeleteConfirmation resets the delete confirmation dialog state.
|
|
// Caller must hold ui.mu lock.
|
|
func (ui *BubbleTeaUI) resetDeleteConfirmation() {
|
|
ui.deleteConfirming = false
|
|
ui.deleteConfirmID = ""
|
|
ui.deleteConfirmAlias = ""
|
|
ui.deleteConfirmCursor = 0
|
|
}
|
|
|
|
// 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(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
// 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()
|
|
|
|
if ui.selectedIndex < 0 || ui.selectedIndex >= len(ui.forwardOrder) {
|
|
ui.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
selectedID := ui.forwardOrder[ui.selectedIndex]
|
|
currentlyDisabled := ui.disabledMap[selectedID]
|
|
newState := !currentlyDisabled
|
|
ui.disabledMap[selectedID] = newState
|
|
|
|
ui.mu.Unlock()
|
|
|
|
// Call the toggle callback in a goroutine to avoid blocking the UI
|
|
if ui.toggleCallback != nil {
|
|
go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled
|
|
}
|
|
}
|