feat: Task 15 — TUI Tab Framework + Orange Theme

- pkg/tui/styles.go — orange theme palette and component styles
- pkg/tui/tabs.go — Tab interface + TabManager with add/close/next/prev
- pkg/tui/host_list_tab.go — HostListTab implementing Tab interface
- pkg/tui/tui.go — refactored to use TabManager (backward compatible)
- pkg/tui/host_list.go — removed (logic moved to host_list_tab.go)
- pkg/tui/tui_test.go — adapted + added TabManager tests
This commit is contained in:
swanadiva
2026-06-23 14:51:13 +07:00
parent c5cc3bd711
commit 5d9e38f8db
6 changed files with 523 additions and 126 deletions
-84
View File
@@ -1,84 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
var (
HostNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
SelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("235")).Padding(0, 1)
TagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
)
// renderHostList renders the host list screen
func renderHostList(m *Model) string {
var b strings.Builder
b.WriteString(TitleStyle.Render("HOSTKEEPER - SSH Manager"))
b.WriteString("\n\n")
if len(m.Hosts) == 0 {
b.WriteString(SubtitleStyle.Render("No hosts found. Add your first host with: hostkeeper add"))
b.WriteString("\n\n")
b.WriteString(InfoStyle.Render("Press 'q' to quit"))
return b.String()
}
for i, host := range m.Hosts {
if i == m.SelectedIndex {
b.WriteString(renderSelectedHost(host))
} else {
b.WriteString(renderHost(host))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(SubtitleStyle.Render("\u2191\u2193: Navigate | Enter: Connect | q: Quit"))
return b.String()
}
// renderHost renders a single host
func renderHost(host *models.Host) string {
var b strings.Builder
b.WriteString(HostNameStyle.Render(host.Name))
b.WriteString("\n")
details := fmt.Sprintf(" %s@%s:%d", host.Username, host.Hostname, host.Port)
b.WriteString(HostDetailStyle.Render(details))
if len(host.Tags) > 0 {
tags := formatTagsForTUI(host.Tags)
b.WriteString(" " + TagStyle.Render(tags))
}
return b.String()
}
// renderSelectedHost renders the selected host with highlight
func renderSelectedHost(host *models.Host) string {
hostText := renderHost(host)
return SelectedStyle.Render(hostText)
}
// formatTagsForTUI formats tags for TUI display
func formatTagsForTUI(tags []string) string {
if len(tags) == 0 {
return ""
}
var formatted []string
for _, tag := range tags {
formatted = append(formatted, "["+tag+"]")
}
return strings.Join(formatted, " ")
}
+156
View File
@@ -0,0 +1,156 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
// HostListTab is the host list tab
type HostListTab struct {
hosts []*models.Host
selectedIndex int
err error
width int
height int
}
// NewHostListTab creates a new host list tab
func NewHostListTab() *HostListTab {
return &HostListTab{
selectedIndex: 0,
}
}
// Init initializes the tab
func (t *HostListTab) Init() tea.Cmd {
return nil
}
// Name returns the tab name
func (t *HostListTab) Name() string {
return "Hosts"
}
// Update handles messages for the host list
func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if t.selectedIndex > 0 {
t.selectedIndex--
}
case "down", "j":
if t.selectedIndex < len(t.hosts)-1 {
t.selectedIndex++
}
case "enter", " ":
if len(t.hosts) > 0 {
return t, tea.Quit
}
}
}
return t, nil
}
// View renders the host list
func (t *HostListTab) View() string {
var b strings.Builder
b.WriteString(AppTitleStyle.Render("HOSTKEEPER - SSH Manager"))
b.WriteString("\n\n")
if len(t.hosts) == 0 {
b.WriteString(SubtitleStyle.Render("No hosts found. Add your first host with: hostkeeper add"))
b.WriteString("\n\n")
return b.String()
}
for i, host := range t.hosts {
if i == t.selectedIndex {
b.WriteString(renderSelectedHost(host))
} else {
b.WriteString(renderHost(host))
}
b.WriteString("\n")
}
return b.String()
}
// SetHosts sets the host list
func (t *HostListTab) SetHosts(hosts []*models.Host) {
t.hosts = hosts
if len(hosts) > 0 && t.selectedIndex >= len(hosts) {
t.selectedIndex = len(hosts) - 1
}
}
// Hosts returns the host list
func (t *HostListTab) Hosts() []*models.Host {
return t.hosts
}
// SelectedIndex returns the selected index
func (t *HostListTab) SelectedIndex() int {
return t.selectedIndex
}
// FindHostListTab finds the first HostListTab in a list of tabs
func FindHostListTab(tabs []Tab) *HostListTab {
for _, tab := range tabs {
if ht, ok := tab.(*HostListTab); ok {
return ht
}
}
return nil
}
// renderHost renders a single host
func renderHost(host *models.Host) string {
var b strings.Builder
b.WriteString(HostNameStyle.Render(host.Name))
b.WriteString("\n")
details := fmt.Sprintf(" %s@%s:%d", host.Username, host.Hostname, host.Port)
b.WriteString(HostDetailStyle.Render(details))
if len(host.Tags) > 0 {
tags := formatTagsForTUI(host.Tags)
b.WriteString(" " + TagStyle.Render(tags))
}
return b.String()
}
// renderSelectedHost renders the selected host with highlight
func renderSelectedHost(host *models.Host) string {
hostText := renderHost(host)
return SelectedStyle.Render(hostText)
}
// formatTagsForTUI formats tags for TUI display
func formatTagsForTUI(tags []string) string {
if len(tags) == 0 {
return ""
}
var formatted []string
for _, tag := range tags {
formatted = append(formatted, "["+tag+"]")
}
return strings.Join(formatted, " ")
}
+44
View File
@@ -0,0 +1,44 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Orange theme palette
var (
OrangePrimary = lipgloss.Color("#FF6B00")
OrangeSecondary = lipgloss.Color("#FF9F43")
OrangeLight = lipgloss.Color("#FFB800")
DarkBg = lipgloss.Color("#1A1A1A")
LightBg = lipgloss.Color("#2D2D2D")
TextPrimary = lipgloss.Color("#FFFFFF")
TextSecondary = lipgloss.Color("#AAAAAA")
GreenSuccess = lipgloss.Color("#00FF88")
RedError = lipgloss.Color("#FF4444")
BlueInfo = lipgloss.Color("#44AAFF")
)
// Component styles
var (
TabActiveStyle = lipgloss.NewStyle().Background(OrangePrimary).Foreground(DarkBg).Bold(true).Padding(0, 2)
TabInactiveStyle = lipgloss.NewStyle().Background(LightBg).Foreground(TextSecondary).Padding(0, 2)
TabBarStyle = lipgloss.NewStyle().Background(DarkBg)
StatusBarStyle = lipgloss.NewStyle().Background(OrangePrimary).Foreground(DarkBg).Padding(0, 1)
AppTitleStyle = lipgloss.NewStyle().Foreground(OrangeSecondary).Bold(true)
HighlightStyle = lipgloss.NewStyle().Foreground(OrangePrimary).Bold(true)
SelectedStyle = lipgloss.NewStyle().Foreground(DarkBg).Background(OrangePrimary).Padding(0, 1)
ErrorStyle = lipgloss.NewStyle().Foreground(RedError).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(GreenSuccess).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(BlueInfo)
SubtitleStyle = lipgloss.NewStyle().Foreground(TextSecondary)
HostNameStyle = lipgloss.NewStyle().Foreground(OrangeLight).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(TextSecondary)
TagStyle = lipgloss.NewStyle().Foreground(GreenSuccess)
TitleStyle = AppTitleStyle
)
// TabWidth returns the width of the tab bar content
func TabBarWidth(totalWidth int) int {
if totalWidth < 10 {
return totalWidth
}
return totalWidth - 2
}
+195
View File
@@ -0,0 +1,195 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Tab represents a single tab in the TUI
type Tab interface {
Init() tea.Cmd
Update(tea.Msg) (Tab, tea.Cmd)
View() string
Name() string
}
// 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 and switches to it
func (tm *TabManager) Add(tab Tab) {
tm.tabs = append(tm.tabs, tab)
tm.active = len(tm.tabs) - 1
}
// 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
}
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
if wsMsg, ok := msg.(tea.WindowSizeMsg); ok {
tm.SetSize(wsMsg.Width, wsMsg.Height)
}
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)
}
// Render status bar
b.WriteString("\n")
b.WriteString(renderStatusBar(tm))
return b.String()
}
// renderTabBar renders the top tab bar
func renderTabBar(tm *TabManager) string {
if len(tm.tabs) == 0 {
return ""
}
var cells []string
for i, tab := range tm.tabs {
name := tab.Name()
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)
}
// renderStatusBar renders the bottom status bar
func renderStatusBar(tm *TabManager) string {
hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select q:quit"
return StatusBarStyle.Render(hints)
}
+39 -40
View File
@@ -2,22 +2,11 @@ package tui
import ( import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
) )
// Styles for TUI components // Screen represents different TUI screens (deprecated, use tabs)
var (
TitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
SubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
HighlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
)
// Screen represents different TUI screens
type Screen int type Screen int
const ( const (
@@ -28,16 +17,21 @@ const (
// Model represents the main TUI model // Model represents the main TUI model
type Model struct { type Model struct {
CurrentScreen Screen tabs *TabManager
Hosts []*models.Host CurrentScreen Screen // deprecated, kept for backward compat
SelectedIndex int Hosts []*models.Host // deprecated
SelectedIndex int // deprecated
Error error Error error
Quit bool Quit bool
} }
// New creates a new TUI model // New creates a new TUI model
func New() *Model { func New() *Model {
hostList := NewHostListTab()
tm := NewTabManager(hostList)
return &Model{ return &Model{
tabs: tm,
CurrentScreen: ScreenHostList, CurrentScreen: ScreenHostList,
SelectedIndex: 0, SelectedIndex: 0,
Quit: false, Quit: false,
@@ -46,7 +40,7 @@ func New() *Model {
// Init initializes the TUI // Init initializes the TUI
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return nil return m.tabs.Init()
} }
// Update handles messages and updates the model // Update handles messages and updates the model
@@ -57,45 +51,50 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "q": case "ctrl+c", "q":
m.Quit = true m.Quit = true
return m, tea.Quit return m, tea.Quit
case "up", "k":
if m.SelectedIndex > 0 {
m.SelectedIndex--
}
case "down", "j":
if m.SelectedIndex < len(m.Hosts)-1 {
m.SelectedIndex++
}
case "enter", " ":
if len(m.Hosts) > 0 {
return m, tea.Quit
}
} }
} }
return m, nil cmd, err := m.tabs.Update(msg)
if err != nil {
m.Error = err
}
// Sync deprecated fields
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
m.Hosts = ht.Hosts()
m.SelectedIndex = ht.SelectedIndex()
}
return m, cmd
} }
// View renders the TUI // View renders the TUI
func (m *Model) View() string { func (m *Model) View() string {
if m.Quit { if m.Quit {
m.tabs = nil
return "Thanks for using hostkeeper!\n" return "Thanks for using hostkeeper!\n"
} }
switch m.CurrentScreen { if m.tabs == nil || m.tabs.Len() == 0 {
case ScreenHostList: return "No tabs open. Press 'q' to quit.\n"
return renderHostList(m)
default:
return "Screen not implemented yet"
} }
return m.tabs.View()
} }
// LoadHosts loads hosts into the TUI model // LoadHosts loads hosts into the TUI model
func (m *Model) LoadHosts(hosts []*models.Host) { func (m *Model) LoadHosts(hosts []*models.Host) {
m.Hosts = hosts if m.tabs == nil {
if len(hosts) > 0 && m.SelectedIndex >= len(hosts) { return
m.SelectedIndex = len(hosts) - 1 }
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
ht.SetHosts(hosts)
m.Hosts = hosts
} }
} }
// TabManager returns the underlying tab manager
func (m *Model) TabManager() *TabManager {
return m.tabs
}
+89 -2
View File
@@ -10,13 +10,23 @@ func TestTUIInitialization(t *testing.T) {
t.Fatal("Failed to initialize TUI") t.Fatal("Failed to initialize TUI")
} }
if ui.CurrentScreen != ScreenHostList { if ui.tabs == nil {
t.Errorf("expected CurrentScreen ScreenHostList, got %d", ui.CurrentScreen) t.Fatal("expected tabs manager to be initialized")
}
if ui.tabs.Len() != 1 {
t.Errorf("expected 1 tab, got %d", ui.tabs.Len())
} }
if ui.Quit { if ui.Quit {
t.Error("expected Quit to be false") t.Error("expected Quit to be false")
} }
// Should have a HostListTab by default
ht := FindHostListTab(ui.tabs.tabs)
if ht == nil {
t.Error("expected HostListTab to be the initial tab")
}
} }
func TestTUILoadHosts(t *testing.T) { func TestTUILoadHosts(t *testing.T) {
@@ -29,4 +39,81 @@ func TestTUILoadHosts(t *testing.T) {
if ui.Hosts != nil { if ui.Hosts != nil {
t.Error("expected Hosts to be nil") t.Error("expected Hosts to be nil")
} }
// Should still have a valid tab manager
if ui.tabs == nil {
t.Fatal("expected tabs manager to be valid")
}
}
func TestTabManagerBasic(t *testing.T) {
tm := NewTabManager(NewHostListTab())
if tm.Len() != 1 {
t.Errorf("expected 1 tab, got %d", tm.Len())
}
if tm.Active() == nil {
t.Fatal("expected active tab")
}
if tm.Active().Name() != "Hosts" {
t.Errorf("expected 'Hosts', got '%s'", tm.Active().Name())
}
}
func TestTabManagerNavigation(t *testing.T) {
tm := NewTabManager(NewHostListTab())
// Add a second tab
second := NewHostListTab()
tm.Add(second)
if tm.Len() != 2 {
t.Errorf("expected 2 tabs, got %d", tm.Len())
}
// Active should now be the last added tab
if tm.active != 1 {
t.Errorf("expected active index 1, got %d", tm.active)
}
// Previous
tm.Prev()
if tm.active != 0 {
t.Errorf("expected active index 0 after Prev, got %d", tm.active)
}
// Next
tm.Next()
if tm.active != 1 {
t.Errorf("expected active index 1 after Next, got %d", tm.active)
}
}
func TestTabManagerClose(t *testing.T) {
tm := NewTabManager(NewHostListTab())
second := NewHostListTab()
tm.Add(second)
tm.Add(NewHostListTab())
// Close active (last tab)
closed := tm.CloseActive()
if closed == nil {
t.Error("expected closed tab to be returned")
}
if tm.Len() != 2 {
t.Errorf("expected 2 tabs after close, got %d", tm.Len())
}
// Close all tabs except last
tm.Close(0)
if tm.Len() != 1 {
t.Errorf("expected 1 tab after close, got %d", tm.Len())
}
// Should not close the last tab via CloseActive (returns nil)
result := tm.CloseActive()
if result != nil {
t.Error("expected nil when trying to close the last tab")
}
} }