mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-29 05:32:38 +00:00
Add user friendly UI allowing to temporarily toggle port forwarding.
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// bubbletea model
|
||||
type model struct {
|
||||
ui *BubbleTeaUI
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Clear error if status is not Error
|
||||
if status != "Error" {
|
||||
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)
|
||||
|
||||
// Remove from order
|
||||
for i, fid := range ui.forwardOrder {
|
||||
if fid == id {
|
||||
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
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) {
|
||||
switch msg := msg.(type) {
|
||||
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()
|
||||
}
|
||||
|
||||
case ForwardAddMsg:
|
||||
// Already handled in AddForward, just trigger re-render
|
||||
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
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
m.ui.mu.RLock()
|
||||
defer m.ui.mu.RUnlock()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Styles
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("220")).
|
||||
Padding(0, 1)
|
||||
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("220"))
|
||||
|
||||
separatorStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("240")).
|
||||
Foreground(lipgloss.Color("230"))
|
||||
|
||||
disabledStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
activeStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("46"))
|
||||
|
||||
startingStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("220"))
|
||||
|
||||
errorStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196"))
|
||||
|
||||
// Title with version
|
||||
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s",
|
||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS")
|
||||
b.WriteString(headerStyle.Render(header))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", 120)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// No forwards
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
b.WriteString(disabledStyle.Render("\nNo forwards configured\n"))
|
||||
} else {
|
||||
// Display forwards
|
||||
for idx, id := range m.ui.forwardOrder {
|
||||
fwd, ok := m.ui.forwards[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isSelected := (idx == m.ui.selectedIndex)
|
||||
isDisabled := m.ui.disabledMap[id]
|
||||
|
||||
// Selection indicator
|
||||
indicator := " "
|
||||
if isSelected {
|
||||
indicator = "> "
|
||||
}
|
||||
|
||||
// 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 = "✗ "
|
||||
}
|
||||
}
|
||||
|
||||
// Format row
|
||||
row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s",
|
||||
indicator,
|
||||
truncate(fwd.Context, 15),
|
||||
truncate(fwd.Namespace, 18),
|
||||
truncate(fwd.Alias, 20),
|
||||
truncate(fwd.Type, 10),
|
||||
truncate(fwd.Resource, 21),
|
||||
fwd.RemotePort,
|
||||
fwd.LocalPort,
|
||||
statusIcon,
|
||||
statusText)
|
||||
|
||||
// Apply styling
|
||||
if isSelected {
|
||||
row = selectedStyle.Render(row)
|
||||
} else if isDisabled {
|
||||
row = disabledStyle.Render(row)
|
||||
} else {
|
||||
// Color the status part
|
||||
switch fwd.Status {
|
||||
case "Active":
|
||||
parts := strings.Split(row, statusIcon)
|
||||
if len(parts) == 2 {
|
||||
row = parts[0] + activeStyle.Render(statusIcon+statusText)
|
||||
}
|
||||
case "Starting", "Reconnecting":
|
||||
parts := strings.Split(row, statusIcon)
|
||||
if len(parts) == 2 {
|
||||
row = parts[0] + startingStyle.Render(statusIcon+statusText)
|
||||
}
|
||||
case "Error":
|
||||
parts := strings.Split(row, statusIcon)
|
||||
if len(parts) == 2 {
|
||||
row = parts[0] + errorStyle.Render(statusIcon+statusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(row)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// InteractiveController handles keyboard input and selection state
|
||||
type InteractiveController struct {
|
||||
mu sync.RWMutex
|
||||
selectedIndex int
|
||||
forwardIDs []string // Ordered list of forward IDs
|
||||
disabledMap map[string]bool // Tracks which forwards are disabled
|
||||
toggleCallback func(id string, enable bool)
|
||||
enabled bool
|
||||
oldTermState *term.State
|
||||
}
|
||||
|
||||
// NewInteractiveController creates a new interactive controller
|
||||
func NewInteractiveController(toggleCallback func(id string, enable bool)) *InteractiveController {
|
||||
return &InteractiveController{
|
||||
selectedIndex: 0,
|
||||
forwardIDs: make([]string, 0),
|
||||
disabledMap: make(map[string]bool),
|
||||
toggleCallback: toggleCallback,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Enable puts the terminal in raw mode for keyboard input
|
||||
func (ic *InteractiveController) Enable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save current terminal state
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable raw mode: %w", err)
|
||||
}
|
||||
|
||||
ic.oldTermState = oldState
|
||||
ic.enabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable restores the terminal to normal mode
|
||||
func (ic *InteractiveController) Disable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if !ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ic.oldTermState != nil {
|
||||
if err := term.Restore(int(os.Stdin.Fd()), ic.oldTermState); err != nil {
|
||||
return fmt.Errorf("failed to restore terminal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ic.enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateForwardsList updates the list of forwards for navigation
|
||||
func (ic *InteractiveController) UpdateForwardsList(ids []string) {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
ic.forwardIDs = ids
|
||||
|
||||
// Ensure selected index is valid
|
||||
if ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.selectedIndex = len(ic.forwardIDs) - 1
|
||||
}
|
||||
if ic.selectedIndex < 0 && len(ic.forwardIDs) > 0 {
|
||||
ic.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves selection up
|
||||
func (ic *InteractiveController) MoveUp() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex > 0 {
|
||||
ic.selectedIndex--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves selection down
|
||||
func (ic *InteractiveController) MoveDown() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex < len(ic.forwardIDs)-1 {
|
||||
ic.selectedIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleSelected toggles the enable/disable state of the selected forward
|
||||
func (ic *InteractiveController) ToggleSelected() {
|
||||
ic.mu.Lock()
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
selectedID := ic.forwardIDs[ic.selectedIndex]
|
||||
currentlyDisabled := ic.disabledMap[selectedID]
|
||||
newState := !currentlyDisabled
|
||||
ic.disabledMap[selectedID] = newState
|
||||
ic.mu.Unlock()
|
||||
|
||||
// Call the toggle callback
|
||||
if ic.toggleCallback != nil {
|
||||
ic.toggleCallback(selectedID, !newState) // enable is inverse of disabled
|
||||
}
|
||||
}
|
||||
|
||||
// GetSelectedIndex returns the current selection index
|
||||
func (ic *InteractiveController) GetSelectedIndex() int {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.selectedIndex
|
||||
}
|
||||
|
||||
// IsDisabled returns whether a forward is disabled
|
||||
func (ic *InteractiveController) IsDisabled(id string) bool {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.disabledMap[id]
|
||||
}
|
||||
|
||||
// GetSelectedID returns the ID of the currently selected forward
|
||||
func (ic *InteractiveController) GetSelectedID() string {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
return ""
|
||||
}
|
||||
return ic.forwardIDs[ic.selectedIndex]
|
||||
}
|
||||
|
||||
// HandleKey processes keyboard input and returns true if should continue
|
||||
func (ic *InteractiveController) HandleKey(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle single byte keys
|
||||
if len(b) == 1 {
|
||||
switch b[0] {
|
||||
case 'q', 'Q', 3: // q, Q, or Ctrl+C
|
||||
return false
|
||||
case ' ', '\r': // Space or Enter to toggle
|
||||
ic.ToggleSelected()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape sequences (arrow keys)
|
||||
if len(b) == 3 && b[0] == 27 && b[1] == 91 {
|
||||
switch b[2] {
|
||||
case 65: // Up arrow
|
||||
ic.MoveUp()
|
||||
case 66: // Down arrow
|
||||
ic.MoveDown()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+48
-6
@@ -23,9 +23,10 @@ type ForwardStatus struct {
|
||||
|
||||
// TableUI manages the terminal table display
|
||||
type TableUI struct {
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
interactive *InteractiveController
|
||||
}
|
||||
|
||||
// NewTableUI creates a new table UI manager
|
||||
@@ -36,6 +37,13 @@ func NewTableUI(verbose bool) *TableUI {
|
||||
}
|
||||
}
|
||||
|
||||
// SetInteractiveController sets the interactive controller
|
||||
func (t *TableUI) SetInteractiveController(ic *InteractiveController) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.interactive = ic
|
||||
}
|
||||
|
||||
// AddForward registers a new forward for display
|
||||
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
|
||||
t.mu.Lock()
|
||||
@@ -118,10 +126,27 @@ func (t *TableUI) Render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update interactive controller with current forward IDs (in display order)
|
||||
if t.interactive != nil {
|
||||
ids := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
ids[i] = entry.id
|
||||
}
|
||||
t.interactive.UpdateForwardsList(ids)
|
||||
}
|
||||
|
||||
// Print each forward
|
||||
for _, entry := range entries {
|
||||
for i, entry := range entries {
|
||||
fwd := entry.fwd
|
||||
|
||||
// Check if this row is selected
|
||||
isSelected := false
|
||||
isDisabled := false
|
||||
if t.interactive != nil {
|
||||
isSelected = (i == t.interactive.GetSelectedIndex())
|
||||
isDisabled = t.interactive.IsDisabled(entry.id)
|
||||
}
|
||||
|
||||
// Truncate long names
|
||||
alias := truncate(fwd.Alias, 25)
|
||||
resource := truncate(fwd.Resource, 25)
|
||||
@@ -129,7 +154,8 @@ func (t *TableUI) Render() {
|
||||
// Color code status with indicator
|
||||
statusStr := formatStatusWithIndicator(fwd.Status)
|
||||
|
||||
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
|
||||
// Build the row content
|
||||
rowContent := fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
|
||||
fwd.Context,
|
||||
fwd.Namespace,
|
||||
alias,
|
||||
@@ -138,10 +164,26 @@ func (t *TableUI) Render() {
|
||||
fwd.RemotePort,
|
||||
fwd.LocalPort,
|
||||
statusStr)
|
||||
|
||||
// Apply selection highlighting or disabled styling
|
||||
if isSelected {
|
||||
// Replace leading spaces with arrow, then apply reverse video to entire line
|
||||
rowContent = "\033[7m> " + rowContent[2:] + "\033[0m"
|
||||
} else if isDisabled {
|
||||
// Apply dimmed styling to entire line
|
||||
rowContent = "\033[2m" + rowContent + "\033[0m"
|
||||
}
|
||||
|
||||
fmt.Println(rowContent)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
helpText := "Total forwards: %d | ↑↓: Navigate | Space: Toggle | q: Quit"
|
||||
if !t.verbose {
|
||||
fmt.Printf(helpText+"\n", len(t.forwards))
|
||||
} else {
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
}
|
||||
|
||||
// In verbose mode, add a newline to separate from logs
|
||||
if t.verbose {
|
||||
|
||||
Reference in New Issue
Block a user