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
This commit is contained in:
+68
-30
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
@@ -105,27 +106,54 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
|
||||
// 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
|
||||
|
||||
b.WriteString(AppTitleStyle.Render("HOSTKEEPER - SSH Manager"))
|
||||
// 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(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(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")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
// 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
|
||||
@@ -159,28 +187,40 @@ func FindHostListTab(tabs []Tab) *HostListTab {
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderHost renders a single host
|
||||
func renderHost(host *models.Host) string {
|
||||
var b strings.Builder
|
||||
// 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
|
||||
|
||||
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))
|
||||
// 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 {
|
||||
tags := formatTagsForTUI(host.Tags)
|
||||
b.WriteString(" " + TagStyle.Render(tags))
|
||||
addr += " " + formatTagsForTUI(host.Tags)
|
||||
}
|
||||
inner.WriteString(HostDetailStyle.Render(" " + truncateString(addr, width-2)))
|
||||
|
||||
cardStyle := HostCardStyle
|
||||
if selected {
|
||||
cardStyle = HostCardActiveStyle
|
||||
}
|
||||
|
||||
return b.String()
|
||||
return cardStyle.Width(width).Render(inner.String())
|
||||
}
|
||||
|
||||
// renderSelectedHost renders the selected host with highlight
|
||||
func renderSelectedHost(host *models.Host) string {
|
||||
hostText := renderHost(host)
|
||||
return SelectedStyle.Render(hostText)
|
||||
// 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
|
||||
@@ -188,11 +228,9 @@ 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, " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user