847989df75
- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/ - Create v1/README.md with V1 documentation - Update root README for V1 + V2 structure - V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass - Root is now clean for V2 development
232 lines
4.6 KiB
Go
232 lines
4.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Tab represents a single tab in the TUI
|
|
type Tab interface {
|
|
Init() tea.Cmd
|
|
Update(tea.Msg) (Tab, tea.Cmd)
|
|
View() string
|
|
Name() string
|
|
// Close is called when the tab is removed; implement for cleanup (e.g. disconnect SSH)
|
|
Close()
|
|
}
|
|
|
|
// TabManager manages multiple tabs
|
|
type TabManager struct {
|
|
tabs []Tab
|
|
active int
|
|
width int
|
|
height int
|
|
}
|
|
|
|
// NewTabManager creates a new TabManager with an initial tab
|
|
func NewTabManager(initial Tab) *TabManager {
|
|
return &TabManager{
|
|
tabs: []Tab{initial},
|
|
active: 0,
|
|
}
|
|
}
|
|
|
|
// Active returns the currently active tab
|
|
func (tm *TabManager) Active() Tab {
|
|
if len(tm.tabs) == 0 {
|
|
return nil
|
|
}
|
|
return tm.tabs[tm.active]
|
|
}
|
|
|
|
// Add adds a new tab, switches to it, and returns its init command
|
|
func (tm *TabManager) Add(tab Tab) tea.Cmd {
|
|
tm.tabs = append(tm.tabs, tab)
|
|
tm.active = len(tm.tabs) - 1
|
|
|
|
// Forward current terminal size so new tabs know their dimensions
|
|
if tm.width > 0 && tm.height > 0 {
|
|
updated, _ := tab.Update(tea.WindowSizeMsg{Width: tm.width, Height: tm.height})
|
|
tm.tabs[tm.active] = updated
|
|
}
|
|
|
|
return tab.Init()
|
|
}
|
|
|
|
// Close removes the tab at index and returns the active tab
|
|
func (tm *TabManager) Close(index int) Tab {
|
|
if index < 0 || index >= len(tm.tabs) {
|
|
return nil
|
|
}
|
|
|
|
// Call Close for cleanup (e.g. disconnect SSH)
|
|
tm.tabs[index].Close()
|
|
|
|
tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
|
|
|
|
if len(tm.tabs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if tm.active >= len(tm.tabs) {
|
|
tm.active = len(tm.tabs) - 1
|
|
}
|
|
return tm.tabs[tm.active]
|
|
}
|
|
|
|
// CloseActive closes the active tab
|
|
func (tm *TabManager) CloseActive() Tab {
|
|
if len(tm.tabs) <= 1 {
|
|
return nil
|
|
}
|
|
return tm.Close(tm.active)
|
|
}
|
|
|
|
// Next switches to the next tab
|
|
func (tm *TabManager) Next() {
|
|
if len(tm.tabs) <= 1 {
|
|
return
|
|
}
|
|
tm.active = (tm.active + 1) % len(tm.tabs)
|
|
}
|
|
|
|
// Prev switches to the previous tab
|
|
func (tm *TabManager) Prev() {
|
|
if len(tm.tabs) <= 1 {
|
|
return
|
|
}
|
|
tm.active--
|
|
if tm.active < 0 {
|
|
tm.active = len(tm.tabs) - 1
|
|
}
|
|
}
|
|
|
|
// Len returns the number of tabs
|
|
func (tm *TabManager) Len() int {
|
|
return len(tm.tabs)
|
|
}
|
|
|
|
// SetSize updates the terminal size for the tab manager
|
|
func (tm *TabManager) SetSize(width, height int) {
|
|
tm.width = width
|
|
tm.height = height
|
|
}
|
|
|
|
// Init initializes all tabs
|
|
func (tm *TabManager) Init() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for _, t := range tm.tabs {
|
|
if cmd := t.Init(); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// Update sends a message to the active tab
|
|
func (tm *TabManager) Update(msg tea.Msg) (tea.Cmd, error) {
|
|
if len(tm.tabs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Handle tab-level keys
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
switch keyMsg.String() {
|
|
case "ctrl+tab":
|
|
tm.Next()
|
|
return nil, nil
|
|
case "shift+tab":
|
|
tm.Prev()
|
|
return nil, nil
|
|
case "ctrl+q":
|
|
if closed := tm.CloseActive(); closed != nil {
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle window resize — forward to ALL tabs
|
|
if wsMsg, ok := msg.(tea.WindowSizeMsg); ok {
|
|
tm.SetSize(wsMsg.Width, wsMsg.Height)
|
|
for i, t := range tm.tabs {
|
|
updated, _ := t.Update(msg)
|
|
tm.tabs[i] = updated
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
updated, cmd := tm.tabs[tm.active].Update(msg)
|
|
tm.tabs[tm.active] = updated
|
|
return cmd, nil
|
|
}
|
|
|
|
// View renders the tab bar and active tab content
|
|
func (tm *TabManager) View() string {
|
|
if len(tm.tabs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
// Render tab bar
|
|
b.WriteString(renderTabBar(tm))
|
|
|
|
// Render active tab content
|
|
content := tm.tabs[tm.active].View()
|
|
if content != "" {
|
|
b.WriteString("\n")
|
|
b.WriteString(content)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderTabBar renders the top tab bar (responsive: truncates names on overflow)
|
|
func renderTabBar(tm *TabManager) string {
|
|
if len(tm.tabs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
const cellPad = 4 // Padding(0,2) per tab = 2 left + 2 right
|
|
|
|
availW := tm.width - 2
|
|
if availW < 1 {
|
|
availW = 1
|
|
}
|
|
|
|
// Measure total width and decide if truncation is needed
|
|
totalW := 0
|
|
for _, tab := range tm.tabs {
|
|
totalW += lipgloss.Width(tab.Name()) + cellPad
|
|
}
|
|
|
|
maxNameW := 0
|
|
if totalW > availW {
|
|
perTab := availW / len(tm.tabs)
|
|
maxNameW = perTab - cellPad
|
|
if maxNameW < 1 {
|
|
maxNameW = 1
|
|
}
|
|
}
|
|
|
|
var cells []string
|
|
for i, tab := range tm.tabs {
|
|
name := tab.Name()
|
|
if maxNameW > 0 && lipgloss.Width(name) > maxNameW {
|
|
name = truncateStr(name, maxNameW)
|
|
}
|
|
if i == tm.active {
|
|
cells = append(cells, TabActiveStyle.Render(name))
|
|
} else {
|
|
cells = append(cells, TabInactiveStyle.Render(name))
|
|
}
|
|
}
|
|
|
|
bar := strings.Join(cells, "")
|
|
return TabBarStyle.Render(bar)
|
|
}
|
|
|
|
|