daa20ac72b
CRITICAL BUG: sshConnectCmd wrapped tea.ExecProcess inside an extra closure, so Bubble Tea never executed the SSH process — Enter appeared to do nothing. Fix: - sshConnectCmd now returns tea.ExecProcess directly (no extra closure) - tea.ExecProcess handles all process lifecycle (start, PTY, exit) - sshExitMsg errors are shown in host list view (ErrorStyle) - Error routed via Model.Error → HostListTab.err → View()
245 lines
5.1 KiB
Go
245 lines
5.1 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
|
|
}
|
|
|
|
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 {
|
|
for i, host := range t.hosts {
|
|
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
|
|
// Center the card (which produces 4 lines) horizontally
|
|
for _, line := range strings.Split(entry, "\n") {
|
|
b.WriteString(centerText(line, availW))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
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, " ")
|
|
}
|