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 — exactly like tamagosh func (t *HostListTab) View() string { var b strings.Builder // Error display if t.err != nil { b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err))) b.WriteString("\n") t.err = nil } if len(t.hosts) == 0 { msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)") b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg)) return b.String() } // Build rows (plain + styled) type styledRow struct { text string plain string } var rows []styledRow // Scrolling availH := t.height - 10 if availH < 1 { availH = 1 } maxHosts := availH if maxHosts > len(t.hosts) { maxHosts = len(t.hosts) } start := t.selectedIndex - maxHosts/2 if start < 0 { start = 0 } if start+maxHosts > len(t.hosts) { start = len(t.hosts) - maxHosts } for i := start; i < start+maxHosts; i++ { host := t.hosts[i] // tamagosh format: " %-12s %-18s :%d" raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port) var styled string if i == t.selectedIndex { styled = lipgloss.NewStyle(). Foreground(gbFg). Background(gbBgSel). Bold(true). Render("▸ " + strings.TrimLeft(raw, " ")) } else { styled = lipgloss.NewStyle().Foreground(gbFg).Render(raw) } rows = append(rows, styledRow{text: styled, plain: raw}) } // Footer var footer styledRow footerText := "[n]ew [e]dit [d]el [f]sftp [K]eygen [/]find [q]uit Ctrl+Tab:switch Ctrl+Q:close" footer = styledRow{text: SubtitleStyle.Render(footerText), plain: footerText} rows = append(rows, footer) // for widest measurement // Title title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render("Connection List") titlePlain := "Connection List" // tamagosh: widest content line drives the box's natural width widestRow := 0 for _, r := range rows { if w := lipgloss.Width(r.plain); w > widestRow { widestRow = w } } contentW := widestRow if w := lipgloss.Width(footer.plain); w > contentW { contentW = w } if w := lipgloss.Width(titlePlain); w > contentW { contentW = w } const sidePad = 6 targetW := contentW + sidePad*2 // Uniform left shift for the row block rowShift := (targetW - widestRow) / 2 var content strings.Builder // Centered title content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title)) content.WriteString("\n\n") // Centered rows shift := strings.Repeat(" ", rowShift) for _, r := range rows[:len(rows)-1] { // exclude footer, added below line := lipgloss.PlaceHorizontal(targetW, lipgloss.Left, shift+r.text) content.WriteString(line) content.WriteString("\n") } content.WriteString("\n") // Centered footer content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text)) // Wrap in a rounded border box box := BorderStyle.Render(content.String()) // Center the whole box horizontally b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } // 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 } // 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, " ") }