From 5d9e38f8db26f1cbe83ae21eb6b34be0588cb934 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 14:51:13 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Task=2015=20=E2=80=94=20TUI=20Tab=20Fra?= =?UTF-8?q?mework=20+=20Orange=20Theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/tui/host_list.go | 84 ----------------- pkg/tui/host_list_tab.go | 156 +++++++++++++++++++++++++++++++ pkg/tui/styles.go | 44 +++++++++ pkg/tui/tabs.go | 195 +++++++++++++++++++++++++++++++++++++++ pkg/tui/tui.go | 79 ++++++++-------- pkg/tui/tui_test.go | 91 +++++++++++++++++- 6 files changed, 523 insertions(+), 126 deletions(-) delete mode 100644 pkg/tui/host_list.go create mode 100644 pkg/tui/host_list_tab.go create mode 100644 pkg/tui/styles.go create mode 100644 pkg/tui/tabs.go diff --git a/pkg/tui/host_list.go b/pkg/tui/host_list.go deleted file mode 100644 index b2170d0..0000000 --- a/pkg/tui/host_list.go +++ /dev/null @@ -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, " ") -} diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go new file mode 100644 index 0000000..16f65d4 --- /dev/null +++ b/pkg/tui/host_list_tab.go @@ -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, " ") +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go new file mode 100644 index 0000000..06fde9d --- /dev/null +++ b/pkg/tui/styles.go @@ -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 +} diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go new file mode 100644 index 0000000..3479e24 --- /dev/null +++ b/pkg/tui/tabs.go @@ -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) +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 42101ed..acd4e27 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -2,22 +2,11 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" ) -// Styles for TUI components -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 +// Screen represents different TUI screens (deprecated, use tabs) type Screen int const ( @@ -28,16 +17,21 @@ const ( // Model represents the main TUI model type Model struct { - CurrentScreen Screen - Hosts []*models.Host - SelectedIndex int + tabs *TabManager + CurrentScreen Screen // deprecated, kept for backward compat + Hosts []*models.Host // deprecated + SelectedIndex int // deprecated Error error Quit bool } // New creates a new TUI model func New() *Model { + hostList := NewHostListTab() + tm := NewTabManager(hostList) + return &Model{ + tabs: tm, CurrentScreen: ScreenHostList, SelectedIndex: 0, Quit: false, @@ -46,7 +40,7 @@ func New() *Model { // Init initializes the TUI func (m *Model) Init() tea.Cmd { - return nil + return m.tabs.Init() } // 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": m.Quit = true 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 func (m *Model) View() string { if m.Quit { + m.tabs = nil return "Thanks for using hostkeeper!\n" } - switch m.CurrentScreen { - case ScreenHostList: - return renderHostList(m) - default: - return "Screen not implemented yet" + if m.tabs == nil || m.tabs.Len() == 0 { + return "No tabs open. Press 'q' to quit.\n" } + + return m.tabs.View() } // LoadHosts loads hosts into the TUI model func (m *Model) LoadHosts(hosts []*models.Host) { - m.Hosts = hosts - if len(hosts) > 0 && m.SelectedIndex >= len(hosts) { - m.SelectedIndex = len(hosts) - 1 + if m.tabs == nil { + return + } + + 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 +} diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go index 2b652f8..a565d6e 100644 --- a/pkg/tui/tui_test.go +++ b/pkg/tui/tui_test.go @@ -10,13 +10,23 @@ func TestTUIInitialization(t *testing.T) { t.Fatal("Failed to initialize TUI") } - if ui.CurrentScreen != ScreenHostList { - t.Errorf("expected CurrentScreen ScreenHostList, got %d", ui.CurrentScreen) + if ui.tabs == nil { + 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 { 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) { @@ -29,4 +39,81 @@ func TestTUILoadHosts(t *testing.T) { if ui.Hosts != 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") + } }