mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* Allow configuring forwards-table columns, order, and width Adds an optional tui.columns section to .kportal.yaml so the visible columns, their order, and their widths can be customized instead of always rendering the hardcoded 8-column layout. Omitting the section keeps the existing default columns/order/widths. Closes #81 * fix(ui): keep the plain table layout and check column widths The plain verbose table must look the same when no tui section is set. It had narrower columns and shorter port headers than before. The default layout is now identical to the old one, and a golden test guards it. A status column that is not last now pads by visible width. Table rules follow the column widths. The UI applies new columns only after a reload succeeds, including the SIGHUP path of the verbose table. The validator rejects a column width above 200. The README describes the tui.columns setting. Refs #81 * fix(httplog): replace the deprecated ReverseProxy Director with Rewrite Staticcheck reports SA1019 because Go 1.26 deprecates Director. The proxy now sets the target in a Rewrite function. Rewrite drops the X-Forwarded-For header that Director sent, so the function calls SetXForwarded to keep that header. A new test checks the target port, the path and the header. * fix(release): drop the deprecated cask url verified key Homebrew warns about the verified parameter of the cask url stanza. The setting is not needed. GoReleaser check now passes without a deprecation warning. The postflight warning remains. GoReleaser generates it from the post install hook, and the upstream fix is not released yet. Refs #79 --------- Co-authored-by: Lukasz Raczylo <[email protected]>
1002 lines
26 KiB
Go
1002 lines
26 KiB
Go
// Package ui provides the terminal user interface for kportal using bubbletea.
|
|
// It displays port-forward status in an interactive table and provides wizards
|
|
// for adding, editing, and removing forwards.
|
|
//
|
|
// The main components are:
|
|
// - BubbleTeaUI: The interactive TUI with table display and modal dialogs
|
|
// - TableUI: A simpler non-interactive status display for verbose mode
|
|
// - Wizards: Step-by-step interfaces for configuration changes
|
|
// - Controller: Coordinates UI with the forward manager
|
|
//
|
|
// Key bindings in the main view:
|
|
// - ↑↓/jk: Navigate forwards
|
|
// - Space: Toggle forward enabled/disabled
|
|
// - n: New forward wizard
|
|
// - e: Edit forward wizard
|
|
// - d: Delete forward
|
|
// - b: Benchmark forward
|
|
// - l: View HTTP logs
|
|
// - q: Quit
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/lipgloss/table"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/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 {
|
|
Forward *ForwardStatus
|
|
ID string
|
|
}
|
|
|
|
// 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 {
|
|
discovery *k8s.Discovery
|
|
program *tea.Program
|
|
forwards map[string]*ForwardStatus
|
|
columns []ResolvedColumn
|
|
benchmarkState *BenchmarkState
|
|
httpLogSubscriber HTTPLogSubscriber
|
|
disabledMap map[string]bool
|
|
toggleCallback func(id string, enable bool)
|
|
httpLogCleanup func()
|
|
httpLogState *HTTPLogState
|
|
errors map[string]string
|
|
mutator *config.Mutator
|
|
removeWizard *RemoveWizardState
|
|
addWizard *AddWizardState
|
|
updateVersion string
|
|
updateURL string
|
|
configPath string
|
|
deleteConfirmID string
|
|
deleteConfirmAlias string
|
|
version string
|
|
forwardOrder []string
|
|
viewMode ViewMode
|
|
deleteConfirmCursor int
|
|
selectedIndex int
|
|
mu sync.RWMutex
|
|
deleteConfirming bool
|
|
updateAvailable bool
|
|
}
|
|
|
|
// 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,
|
|
columns: ResolveColumns(nil),
|
|
}
|
|
|
|
return ui
|
|
}
|
|
|
|
// SetColumns updates the forwards-table column set and order, e.g. after a
|
|
// config hot-reload. cfg may be nil to reset to the built-in defaults.
|
|
func (ui *BubbleTeaUI) SetColumns(cfg *config.Config) {
|
|
ui.mu.Lock()
|
|
defer ui.mu.Unlock()
|
|
ui.columns = ResolveColumns(cfg)
|
|
}
|
|
|
|
// 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
|
|
// Clear any previous error when re-enabling
|
|
delete(ui.errors, id)
|
|
ui.mu.Unlock()
|
|
|
|
if ui.program != nil {
|
|
ui.program.Send(ForwardUpdateMsg{ID: id, Status: "Starting"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parse resource (e.g., "pod/my-app" -> type="pod", name="my-app")
|
|
resourceType := "pod"
|
|
resourceName := fwd.Resource
|
|
if parts := strings.SplitN(fwd.Resource, "/", 2); len(parts) == 2 {
|
|
resourceType = parts[0]
|
|
resourceName = parts[1]
|
|
}
|
|
|
|
alias := fwd.Alias
|
|
if alias == "" {
|
|
alias = resourceName
|
|
}
|
|
|
|
status := &ForwardStatus{
|
|
Context: fwd.GetContext(),
|
|
Namespace: fwd.GetNamespace(),
|
|
Alias: alias,
|
|
Type: resourceType,
|
|
Resource: resourceName,
|
|
HTTPLog: fwd.HTTPLog,
|
|
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 = DefaultTermWidth
|
|
}
|
|
if termHeight == 0 {
|
|
termHeight = DefaultTermHeight
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// mainViewColors holds the color palette for the main view
|
|
type mainViewColors struct {
|
|
header lipgloss.Color
|
|
active lipgloss.Color
|
|
warning lipgloss.Color
|
|
errorColor lipgloss.Color
|
|
muted lipgloss.Color
|
|
selectedBg lipgloss.Color
|
|
selectedFg lipgloss.Color
|
|
}
|
|
|
|
// defaultMainViewColors returns the default color palette
|
|
func defaultMainViewColors() mainViewColors {
|
|
return mainViewColors{
|
|
header: lipgloss.Color("220"), // Yellow
|
|
active: lipgloss.Color("46"), // Green
|
|
warning: lipgloss.Color("220"), // Yellow
|
|
errorColor: lipgloss.Color("196"), // Red
|
|
muted: lipgloss.Color("240"), // Gray
|
|
selectedBg: lipgloss.Color("240"), // Gray background
|
|
selectedFg: lipgloss.Color("230"), // Light foreground
|
|
}
|
|
}
|
|
|
|
// keyBinding represents a keyboard shortcut and its description
|
|
type keyBinding struct {
|
|
key string
|
|
desc string
|
|
}
|
|
|
|
// mainViewKeyBindings returns the key bindings for the main view
|
|
func mainViewKeyBindings() []keyBinding {
|
|
return []keyBinding{
|
|
{"↑↓/jk", "Navigate"},
|
|
{"PgUp/Dn", "Page"},
|
|
{"Space", "Toggle"},
|
|
{"n", "New"},
|
|
{"e", "Edit"},
|
|
{"d", "Delete"},
|
|
{"b", "Bench"},
|
|
{"l", "Logs"},
|
|
{"q", "Quit"},
|
|
}
|
|
}
|
|
|
|
func (m model) renderMainView() string {
|
|
m.ui.mu.RLock()
|
|
defer m.ui.mu.RUnlock()
|
|
|
|
var b strings.Builder
|
|
colors := defaultMainViewColors()
|
|
|
|
// Get terminal dimensions for proper sizing
|
|
termWidth, termHeight := m.getTermDimensions()
|
|
|
|
// Render title header
|
|
b.WriteString(m.renderTitle(colors.header))
|
|
|
|
// Render forwards table or empty message
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
b.WriteString(m.renderEmptyMessage(colors.muted))
|
|
} else {
|
|
b.WriteString(m.renderForwardsTable(colors))
|
|
}
|
|
|
|
// Render error section if any errors exist
|
|
if len(m.ui.errors) > 0 {
|
|
b.WriteString(m.renderErrorSection(termWidth))
|
|
}
|
|
|
|
// Render footer with proper spacing
|
|
b.WriteString(m.renderFooterWithSpacing(termWidth, termHeight, &b))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// getTermDimensions returns terminal dimensions with fallback defaults
|
|
func (m model) getTermDimensions() (width, height int) {
|
|
width = m.termWidth
|
|
height = m.termHeight
|
|
if width == 0 {
|
|
width = DefaultTermWidth
|
|
}
|
|
if height == 0 {
|
|
height = DefaultTermHeight
|
|
}
|
|
return
|
|
}
|
|
|
|
// renderTitle renders the title bar with version and optional update notification
|
|
func (m model) renderTitle(headerColor lipgloss.Color) string {
|
|
var b strings.Builder
|
|
|
|
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")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderEmptyMessage renders the message shown when no forwards are configured.
|
|
// It includes an actionable hint so a first-time user knows how to proceed.
|
|
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
|
|
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
|
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
|
|
return mutedStyle.Render("No forwards configured") + "\n\n" +
|
|
hintStyle.Render(" Press ") + selectedStyle.Render("n") +
|
|
hintStyle.Render(" to add your first port forward.") + "\n"
|
|
}
|
|
|
|
// renderForwardsTable renders the forwards table with all styling
|
|
func (m model) renderForwardsTable(colors mainViewColors) string {
|
|
var b strings.Builder
|
|
|
|
// Build table rows
|
|
rows := m.buildTableRows()
|
|
|
|
headers := make([]string, len(m.ui.columns))
|
|
for i, col := range m.ui.columns {
|
|
headers[i] = col.Header
|
|
}
|
|
|
|
// Create table with styling (no borders for cleaner look)
|
|
t := table.New().
|
|
Border(lipgloss.HiddenBorder()).
|
|
Headers(headers...).
|
|
Rows(rows...).
|
|
StyleFunc(m.createTableStyleFunc(colors))
|
|
|
|
b.WriteString(t.Render())
|
|
b.WriteString("\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// buildTableRows builds the data rows for the forwards table
|
|
func (m model) buildTableRows() [][]string {
|
|
var rows [][]string
|
|
|
|
for _, id := range m.ui.forwardOrder {
|
|
fwd, ok := m.ui.forwards[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
|
|
|
|
row := make([]string, len(m.ui.columns))
|
|
for i, col := range m.ui.columns {
|
|
switch col.Key {
|
|
case ColKeyLocal:
|
|
localPortText := fmt.Sprintf("%d", fwd.LocalPort)
|
|
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) {
|
|
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort))
|
|
}
|
|
row[i] = localPortText
|
|
case ColKeyStatus:
|
|
row[i] = statusIcon + " " + statusText
|
|
default:
|
|
row[i] = columnValue(col, fwd)
|
|
}
|
|
}
|
|
|
|
rows = append(rows, row)
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
// getStatusIconAndText returns the appropriate status icon and text for a forward
|
|
func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text string) {
|
|
icon = "●"
|
|
text = fwd.Status
|
|
|
|
if m.ui.isForwardDisabled(id) {
|
|
return "○", "Disabled"
|
|
}
|
|
|
|
switch fwd.Status {
|
|
case "Starting":
|
|
icon = "○"
|
|
case "Reconnecting":
|
|
icon = "◐"
|
|
case "Error":
|
|
icon = "✗"
|
|
}
|
|
|
|
return icon, text
|
|
}
|
|
|
|
// createTableStyleFunc creates the style function for the forwards table
|
|
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
|
|
statusCol := columnIndex(m.ui.columns, ColKeyStatus)
|
|
|
|
return func(row, col int) lipgloss.Style {
|
|
// Header row
|
|
if row == table.HeaderRow {
|
|
return lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(colors.header).
|
|
Padding(0, 1)
|
|
}
|
|
|
|
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.isForwardDisabled(id)
|
|
|
|
// Selected row gets background highlight
|
|
if isSelected {
|
|
return baseStyle.
|
|
Background(colors.selectedBg).
|
|
Foreground(colors.selectedFg)
|
|
}
|
|
|
|
// Disabled rows are muted
|
|
if isDisabled {
|
|
return baseStyle.Foreground(colors.muted)
|
|
}
|
|
|
|
// Status column gets colored based on status
|
|
if col == statusCol && ok {
|
|
switch fwd.Status {
|
|
case "Active":
|
|
return baseStyle.Foreground(colors.active)
|
|
case "Starting", "Reconnecting":
|
|
return baseStyle.Foreground(colors.warning)
|
|
case "Error":
|
|
return baseStyle.Foreground(colors.errorColor)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
return baseStyle
|
|
}
|
|
}
|
|
|
|
// renderErrorSection renders the error display section, sized to the terminal.
|
|
func (m model) renderErrorSection(termWidth int) string {
|
|
var b strings.Builder
|
|
|
|
width := errorWidth(termWidth)
|
|
|
|
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(width).
|
|
MaxWidth(width)
|
|
|
|
for id, errMsg := range m.ui.errors {
|
|
// Find the forward to display its alias
|
|
if fwd, ok := m.ui.forwards[id]; ok {
|
|
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, width, errorLineStyle))
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// errorWidth clamps the error display to the terminal width, falling back to the
|
|
// default cap so errors never overflow narrow terminals nor sprawl on wide ones.
|
|
func errorWidth(termWidth int) int {
|
|
width := ErrorDisplayWidth
|
|
if termWidth > 0 && termWidth-2 < width {
|
|
width = termWidth - 2
|
|
}
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
return width
|
|
}
|
|
|
|
// renderErrorLine renders a single error line with proper wrapping
|
|
func (m model) renderErrorLine(alias, errMsg string, width int, style lipgloss.Style) string {
|
|
var b strings.Builder
|
|
|
|
// Format: " • alias: error message"
|
|
prefix := fmt.Sprintf(" • %s: ", alias)
|
|
|
|
// Wrap the error message if it's too long
|
|
maxErrLen := width - len(prefix)
|
|
if maxErrLen < 1 {
|
|
maxErrLen = 1
|
|
}
|
|
wrappedMsg := wrapText(errMsg, maxErrLen)
|
|
|
|
// Render first line with prefix
|
|
lines := strings.Split(wrappedMsg, "\n")
|
|
if len(lines) > 0 {
|
|
b.WriteString(style.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(style.Render(indent + lines[i]))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderFooterWithSpacing renders the footer with proper vertical spacing
|
|
func (m model) renderFooterWithSpacing(termWidth, termHeight int, content *strings.Builder) string {
|
|
var b strings.Builder
|
|
|
|
// Calculate current content height
|
|
currentContent := content.String()
|
|
currentLines := strings.Count(currentContent, "\n") + 1
|
|
|
|
// Build footer content
|
|
footerLines := m.buildFooterLines(termWidth)
|
|
|
|
// Calculate footer height and add spacing
|
|
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
|
|
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
b.WriteString("\n")
|
|
for i, line := range footerLines {
|
|
if i > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(footerStyle.Render(line))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// buildFooterLines builds the footer lines that fit within terminal width
|
|
func (m model) buildFooterLines(termWidth int) []string {
|
|
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
|
bindings := mainViewKeyBindings()
|
|
|
|
var footerLines []string
|
|
var currentLine strings.Builder
|
|
currentLineVisualLen := 0
|
|
|
|
// Calculate how much space we need for the total count suffix.
|
|
// Use lipgloss.Width for true display width (the "│" glyph is 3 bytes / 1 col).
|
|
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
|
|
totalSuffixLen := lipgloss.Width(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
|
|
// True display width: strips ANSI and counts wide/unicode glyphs (e.g. ↑↓)
|
|
// correctly, where len() would over-count multibyte runes and wrap early.
|
|
bindingVisualLen := lipgloss.Width(bindingText)
|
|
|
|
// 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())
|
|
|
|
return footerLines
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// isForwardDisabled checks if a forward is disabled.
|
|
// A forward is considered disabled if either:
|
|
// 1. The user has disabled it via the UI (tracked in disabledMap)
|
|
// 2. The forward's status is "Disabled" (from the manager)
|
|
// Caller must hold ui.mu.RLock or ui.mu.Lock.
|
|
func (ui *BubbleTeaUI) isForwardDisabled(id string) bool {
|
|
if ui.disabledMap[id] {
|
|
return true
|
|
}
|
|
if fwd, ok := ui.forwards[id]; ok && fwd.Status == "Disabled" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|