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:
@@ -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, " ")
|
|
||||||
}
|
|
||||||
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user