Files
HostKeeper/pkg/tui/host_list_tab.go
T
swanadiva 53db03814e feat: centered card-style host list layout
- Each host rendered as a bordered card (HostCardStyle / HostCardActiveStyle)
- Title centered horizontally
- Content constrained to 80 columns max
- Vertical centering padding
- Selected host card gets green border (gbGreen #a9b665)
- Empty state message centered
- Removed old plain-text renderHost/renderSelectedHost functions
2026-06-23 15:42:03 +07:00

237 lines
5.0 KiB
Go

package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return sshConnectToMsg{host: host}
}
}
case "ctrl+n":
return t, func() tea.Msg {
return openHostFormMsg{}
}
case "ctrl+e":
if len(t.hosts) > 0 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return openHostFormMsg{editing: host}
}
}
case "ctrl+f":
if len(t.hosts) > 0 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return openSFTPMsg{host: host}
}
}
case "ctrl+k":
return t, func() tea.Msg {
return openKeyListMsg{}
}
case "ctrl+p":
return t, func() tea.Msg {
return openSnippetListMsg{}
}
case "q", "ctrl+c":
return t, func() tea.Msg {
return quitMsg{}
}
}
}
return t, nil
}
// View renders the host list
func (t *HostListTab) View() string {
availW := t.width
if availW < 40 {
availW = 80
}
// Constrain content width so cards don't stretch too wide
contentW := availW
if contentW > 80 {
contentW = 80
}
var b strings.Builder
// Centered title
title := AppTitleStyle.Render(" HOSTKEEPER - SSH Manager ")
b.WriteString(lipgloss.Place(contentW, 1, lipgloss.Center, lipgloss.Center, title))
b.WriteString("\n\n")
if len(t.hosts) == 0 {
b.WriteString(lipgloss.Place(contentW, 1, lipgloss.Center, lipgloss.Center,
SubtitleStyle.Render("No hosts found. Add with Ctrl+N")))
} else {
cardW := contentW - 4
if cardW < 30 {
cardW = 30
}
for i, host := range t.hosts {
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
b.WriteString(entry)
b.WriteString("\n")
}
}
// Center horizontally within the full terminal width
content := lipgloss.Place(availW, 1, lipgloss.Center, lipgloss.Top, b.String())
// Add vertical padding to push content toward center of screen
availH := t.height - 5
if availH < 0 {
availH = 0
}
contentLines := strings.Count(content, "\n") + 1
if contentLines < availH {
topPad := (availH - contentLines) / 2
content = strings.Repeat("\n", topPad) + content
}
return content
}
// Close is a no-op for host list tab
func (t *HostListTab) Close() {}
// 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
}
// renderHostEntry renders a single host as a card
func renderHostEntry(host *models.Host, selected bool, width int) string {
// Build the card content
var inner strings.Builder
// First line: host name (bold)
name := truncateString(host.Name, width-4)
inner.WriteString(HostNameStyle.Render(" " + name))
inner.WriteString("\n")
// Second line: user@host:port with tags
addr := fmt.Sprintf("%s@%s:%d", host.Username, host.Hostname, host.Port)
if len(host.Tags) > 0 {
addr += " " + formatTagsForTUI(host.Tags)
}
inner.WriteString(HostDetailStyle.Render(" " + truncateString(addr, width-2)))
cardStyle := HostCardStyle
if selected {
cardStyle = HostCardActiveStyle
}
return cardStyle.Width(width).Render(inner.String())
}
// truncateString truncates a string to the given max length, appending "…" if needed
func truncateString(s string, maxLen int) string {
if maxLen < 1 {
return ""
}
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}
// 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, " ")
}