Files
swanadiva 847989df75 refactor: move V1 code into v1/ subdirectory
- 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
2026-07-07 11:56:27 +07:00

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)
}