Files
HostKeeper/pkg/tui/host_list_tab.go
T
swanadiva c7568e0bf4 feat: clear screen on SSH enter/exit, host list scrolling, 'e' edit key
- tea.Batch(tea.ClearScreen, sshConnectCmd) before SSH starts so TUI
  output doesn't bleed into SSH terminal
- tea.ClearScreen after SSH exits so SSH output doesn't ghost in TUI
- Host list now paginates: only (term_height - 8) / 5 cards shown,
  viewport follows selectedIndex with centered scrolling
- Shows 'N of M hosts' indicator when content is clipped
- Added 'e' key alias for editing host (Ctrl+E or E)
2026-06-23 16:04:38 +07:00

269 lines
5.6 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", "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
}
cardW := availW - 6
if cardW > 74 {
cardW = 74
}
if cardW < 30 {
cardW = 30
}
var b strings.Builder
// Show error if any
if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
b.WriteString("\n")
t.err = nil
}
// Capped vertical padding
b.WriteString(strings.Repeat("\n", 2))
// Title
b.WriteString(centerText(AppTitleStyle.Render(" HOSTKEEPER - SSH Manager "), availW))
b.WriteString("\n\n")
if len(t.hosts) == 0 {
b.WriteString(centerText(SubtitleStyle.Render("No hosts found. Add with Ctrl+N"), availW))
b.WriteString("\n")
} else {
// Scrolling: show only what fits on screen
cardH := 5 // 4 lines per card + 1 blank separator
maxCards := (t.height - 8) / cardH
if maxCards < 1 {
maxCards = 1
}
if maxCards > len(t.hosts) {
maxCards = len(t.hosts)
}
start := t.selectedIndex - maxCards/2
if start < 0 {
start = 0
}
if start+maxCards > len(t.hosts) {
start = len(t.hosts) - maxCards
}
for i := start; i < start+maxCards; i++ {
host := t.hosts[i]
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
for _, line := range strings.Split(entry, "\n") {
b.WriteString(centerText(line, availW))
b.WriteString("\n")
}
b.WriteString("\n")
}
if maxCards < len(t.hosts) {
b.WriteString(centerText(
SubtitleStyle.Render(fmt.Sprintf("%d of %d hosts", start+1, len(t.hosts))),
availW))
}
}
return b.String()
}
// centerText centers a line of text horizontally within the given width,
// respecting ANSI escape codes for accurate display width.
func centerText(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
pad := strings.Repeat(" ", (width-w)/2)
return pad + s
}
// 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, " ")
}