From 7f197b9e3a28bee7648efeced2fddf657c929238 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 17:12:12 +0700 Subject: [PATCH] fix(tui): center all tabs, full-screen SFTP, muted inline keybindings --- pkg/tui/host_form_tab.go | 51 +++++---- pkg/tui/host_list_tab.go | 202 ++++++++++++++++++------------------ pkg/tui/key_form_tab.go | 37 +++++-- pkg/tui/key_list_tab.go | 99 +++++++++++++----- pkg/tui/sftp_browser_tab.go | 32 +++--- pkg/tui/snippet_form_tab.go | 37 +++++-- pkg/tui/snippet_list_tab.go | 102 ++++++++++++------ pkg/tui/tabs.go | 27 ++--- 8 files changed, 355 insertions(+), 232 deletions(-) diff --git a/pkg/tui/host_form_tab.go b/pkg/tui/host_form_tab.go index 64ec35d..8130e8d 100644 --- a/pkg/tui/host_form_tab.go +++ b/pkg/tui/host_form_tab.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" ) @@ -283,18 +284,28 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) { } func (t *HostFormTab) View() string { - var b strings.Builder + contentW := t.width - 12 + if contentW < 50 { + contentW = 50 + } + if contentW > 70 { + contentW = 70 + } + + var inner strings.Builder title := "Add New Host" if t.mode == formModeEdit { title = "Edit Host" } - b.WriteString(AppTitleStyle.Render(title)) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title))) + inner.WriteString("\n\n") if t.err != nil { - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) + inner.WriteString("\n\n") } for i := fieldID(0); i < fieldCount; i++ { @@ -305,11 +316,10 @@ func (t *HostFormTab) View() string { if i == t.focus { style = HighlightStyle } - b.WriteString(style.Render(label + ":")) - b.WriteString("\n") + inner.WriteString(style.Render(label + ":")) + inner.WriteString("\n") if i == fieldAuthType { - // Render auth type as toggle pills current := input.Value() pills := []string{"password", "key"} var parts []string @@ -324,25 +334,28 @@ func (t *HostFormTab) View() string { parts = append(parts, SubtitleStyle.Render(" "+p+" ")) } } - b.WriteString(" ") - b.WriteString(strings.Join(parts, " ")) - b.WriteString("\n") + inner.WriteString(" ") + inner.WriteString(strings.Join(parts, " ")) + inner.WriteString("\n") if i == t.focus { - b.WriteString(InfoStyle.Render(" Space/←/→ to toggle")) + inner.WriteString(" " + InfoStyle.Render("Space/←/→ to toggle")) } - b.WriteString("\n") + inner.WriteString("\n\n") } else { renderedInput := input.View() - b.WriteString(" ") - b.WriteString(renderedInput) - b.WriteString("\n\n") + inner.WriteString(" ") + inner.WriteString(renderedInput) + inner.WriteString("\n\n") } } - // Help text - b.WriteString(SubtitleStyle.Render( - "↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) + inner.WriteString("\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + SubtitleStyle.Render("↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))) + box := BorderStyle.Render(inner.String()) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index 0bdfbf6..a255450 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -104,90 +104,124 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { return t, nil } -// View renders the host list +// View renders the host list — exactly like tamagosh 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 + // Error display 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)) + 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 - b.WriteString(centerText(AppTitleStyle.Render(" HOSTKEEPER - SSH Manager "), availW)) - b.WriteString("\n\n") + title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render("Connection List") + titlePlain := "Connection List" - 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)) + // 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() } -// 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() {} @@ -219,42 +253,6 @@ func FindHostListTab(tabs []Tab) *HostListTab { 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 { diff --git a/pkg/tui/key_form_tab.go b/pkg/tui/key_form_tab.go index 5f538ac..02c856d 100644 --- a/pkg/tui/key_form_tab.go +++ b/pkg/tui/key_form_tab.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" ) @@ -191,18 +192,28 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) { } func (t *KeyFormTab) View() string { - var b strings.Builder + contentW := t.width - 12 + if contentW < 50 { + contentW = 50 + } + if contentW > 70 { + contentW = 70 + } + + var inner strings.Builder title := "Add SSH Key" if t.mode == keyFormEdit { title = "Edit SSH Key" } - b.WriteString(AppTitleStyle.Render(title)) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title))) + inner.WriteString("\n\n") if t.err != nil { - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) + inner.WriteString("\n\n") } for i := keyFieldID(0); i < keyFieldCount; i++ { @@ -212,15 +223,19 @@ func (t *KeyFormTab) View() string { if i == t.focus { style = HighlightStyle } - b.WriteString(style.Render(label + ":")) - b.WriteString("\n ") - b.WriteString(input.View()) - b.WriteString("\n\n") + inner.WriteString(style.Render(label + ":")) + inner.WriteString("\n ") + inner.WriteString(input.View()) + inner.WriteString("\n\n") } - b.WriteString(SubtitleStyle.Render( - "↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) + inner.WriteString("\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + SubtitleStyle.Render("↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))) + box := BorderStyle.Render(inner.String()) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } diff --git a/pkg/tui/key_list_tab.go b/pkg/tui/key_list_tab.go index d27360d..1b08fd4 100644 --- a/pkg/tui/key_list_tab.go +++ b/pkg/tui/key_list_tab.go @@ -7,6 +7,7 @@ import ( "sync" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" @@ -134,52 +135,96 @@ func (t *KeyListTab) View() string { t.mu.Lock() defer t.mu.Unlock() - var b strings.Builder - - b.WriteString(AppTitleStyle.Render("SSH Key Pairs")) - b.WriteString("\n\n") - - switch t.state { - case keyListLoading: - b.WriteString(SubtitleStyle.Render("Loading keys...")) - return b.String() - case keyListError: - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") - b.WriteString(SubtitleStyle.Render("Press Esc to go back")) + if t.state == keyListLoading { + return lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Loading keys...")) + } + if t.state == keyListError { + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) + b.WriteString("\n") + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Press Esc to go back"))) return b.String() } + type styledRow struct { + text string + plain string + } + var rows []styledRow + + title := "SSH Key Pairs" + titlePlain := title + if t.err != nil { - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") + errLine := fmt.Sprintf("Error: %v", t.err) + rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine}) } if len(t.keys) == 0 { - b.WriteString(SubtitleStyle.Render("No SSH keys stored.")) - b.WriteString("\n") - b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new key.")) + empty := "No SSH keys stored." + hint := "Press Ctrl+N to add a new key." + rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty}) + rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint}) } else { for i, key := range t.keys { - var line string + var plain string if key.Type != "" { - line = fmt.Sprintf(" %s (%s)", key.Name, key.Type) + plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type) } else { - line = fmt.Sprintf(" %s", key.Name) + plain = fmt.Sprintf(" %s", key.Name) } - + var styled string if i == t.selected { - b.WriteString(SelectedStyle.Render(line)) + styled = lipgloss.NewStyle(). + Foreground(gbFg). + Background(gbBgSel). + Bold(true). + Render("▸ " + strings.TrimLeft(plain, " ")) } else { - b.WriteString(HostNameStyle.Render(line)) + styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain) } - b.WriteString("\n") + rows = append(rows, styledRow{text: styled, plain: plain}) } } - b.WriteString("\n") - b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) + footerPlain := "Ctrl+N:add Ctrl+E:edit D:delete Esc:back Ctrl+Tab:switch Ctrl+Q:close" + footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain} + widestRow := lipgloss.Width(titlePlain) + for _, r := range rows { + if w := lipgloss.Width(r.plain); w > widestRow { + widestRow = w + } + } + if w := lipgloss.Width(footer.plain); w > widestRow { + widestRow = w + } + + const sidePad = 6 + targetW := widestRow + sidePad*2 + rowShift := (targetW - widestRow) / 2 + + var inner strings.Builder + + titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain) + inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled)) + inner.WriteString("\n\n") + + shift := strings.Repeat(" ", rowShift) + for _, r := range rows { + line := lipgloss.PlaceHorizontal(targetW, lipgloss.Left, shift+r.text) + inner.WriteString(line) + inner.WriteString("\n") + } + inner.WriteString("\n") + inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text)) + + box := BorderStyle.Render(inner.String()) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } diff --git a/pkg/tui/sftp_browser_tab.go b/pkg/tui/sftp_browser_tab.go index d7b9631..8721a6c 100644 --- a/pkg/tui/sftp_browser_tab.go +++ b/pkg/tui/sftp_browser_tab.go @@ -526,20 +526,21 @@ func (t *SFTPBrowserTab) View() string { defer t.mu.Unlock() if !t.connected && t.err != nil { - return fmt.Sprintf("%s\n\n%s", - ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)), - SubtitleStyle.Render("Press Esc to close")) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)))) + b.WriteString("\n\n") + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Press Esc to close"))) + return b.String() } if !t.connected { - return SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name)) + return lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))) } - contentW := t.width - if contentW < 40 { - contentW = 80 - } - halfW := contentW/2 - 1 + halfW := t.width / 2 if halfW < 20 { halfW = 20 } @@ -549,21 +550,24 @@ func (t *SFTPBrowserTab) View() string { var b strings.Builder - b.WriteString(AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))) + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname)))) b.WriteString("\n") b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)) if t.err != nil { - b.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) } if t.transferring && t.transferMsg != "" { - b.WriteString("\n" + InfoStyle.Render(t.transferMsg)) + b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + InfoStyle.Render(t.transferMsg))) } - b.WriteString("\n" + SubtitleStyle.Render( - "Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close")) + b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close Ctrl+Tab:switch Ctrl+Q:close"))) return b.String() } diff --git a/pkg/tui/snippet_form_tab.go b/pkg/tui/snippet_form_tab.go index 63d5560..0a72419 100644 --- a/pkg/tui/snippet_form_tab.go +++ b/pkg/tui/snippet_form_tab.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" ) @@ -199,18 +200,28 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) { } func (t *SnippetFormTab) View() string { - var b strings.Builder + contentW := t.width - 12 + if contentW < 50 { + contentW = 50 + } + if contentW > 70 { + contentW = 70 + } + + var inner strings.Builder title := "Add Snippet" if t.mode == snippetFormEdit { title = "Edit Snippet" } - b.WriteString(AppTitleStyle.Render(title)) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title))) + inner.WriteString("\n\n") if t.err != nil { - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) + inner.WriteString("\n\n") } for i := snippetFieldID(0); i < snippetFieldCount; i++ { @@ -220,15 +231,19 @@ func (t *SnippetFormTab) View() string { if i == t.focus { style = HighlightStyle } - b.WriteString(style.Render(label + ":")) - b.WriteString("\n ") - b.WriteString(input.View()) - b.WriteString("\n\n") + inner.WriteString(style.Render(label + ":")) + inner.WriteString("\n ") + inner.WriteString(input.View()) + inner.WriteString("\n\n") } - b.WriteString(SubtitleStyle.Render( - "↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) + inner.WriteString("\n") + inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, + SubtitleStyle.Render("↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))) + box := BorderStyle.Render(inner.String()) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } diff --git a/pkg/tui/snippet_list_tab.go b/pkg/tui/snippet_list_tab.go index fa61b49..736b7fc 100644 --- a/pkg/tui/snippet_list_tab.go +++ b/pkg/tui/snippet_list_tab.go @@ -7,6 +7,7 @@ import ( "sync" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" @@ -134,53 +135,96 @@ func (t *SnippetListTab) View() string { t.mu.Lock() defer t.mu.Unlock() - var b strings.Builder - - b.WriteString(AppTitleStyle.Render("Command Snippets")) - b.WriteString("\n\n") - - switch t.state { - case snippetListLoading: - b.WriteString(SubtitleStyle.Render("Loading snippets...")) - return b.String() - case snippetListError: - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") - b.WriteString(SubtitleStyle.Render("Press Esc to go back")) + if t.state == snippetListLoading { + return lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Loading snippets...")) + } + if t.state == snippetListError { + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))) + b.WriteString("\n") + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, + SubtitleStyle.Render("Press Esc to go back"))) return b.String() } + type styledRow struct { + text string + plain string + } + var rows []styledRow + + title := "Command Snippets" + titlePlain := title + if t.err != nil { - b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) - b.WriteString("\n\n") + errLine := fmt.Sprintf("Error: %v", t.err) + rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine}) } if len(t.snippets) == 0 { - b.WriteString(SubtitleStyle.Render("No snippets stored.")) - b.WriteString("\n") - b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new snippet.")) + empty := "No snippets stored." + hint := "Press Ctrl+N to add a new snippet." + rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty}) + rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint}) } else { for i, sn := range t.snippets { - var line string - desc := sn.Description - if desc != "" { - line = fmt.Sprintf(" %s — %s", sn.Name, desc) + var plain string + if sn.Description != "" { + plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description) } else { - line = fmt.Sprintf(" %s", sn.Name) + plain = fmt.Sprintf(" %s", sn.Name) } - + var styled string if i == t.selected { - b.WriteString(SelectedStyle.Render(line)) + styled = lipgloss.NewStyle(). + Foreground(gbFg). + Background(gbBgSel). + Bold(true). + Render("▸ " + strings.TrimLeft(plain, " ")) } else { - b.WriteString(HostNameStyle.Render(line)) + styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain) } - b.WriteString("\n") + rows = append(rows, styledRow{text: styled, plain: plain}) } } - b.WriteString("\n") - b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) + footerPlain := "Ctrl+N:add Ctrl+E:edit D:delete Esc:back Ctrl+Tab:switch Ctrl+Q:close" + footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain} + widestRow := lipgloss.Width(titlePlain) + for _, r := range rows { + if w := lipgloss.Width(r.plain); w > widestRow { + widestRow = w + } + } + if w := lipgloss.Width(footer.plain); w > widestRow { + widestRow = w + } + + const sidePad = 6 + targetW := widestRow + sidePad*2 + rowShift := (targetW - widestRow) / 2 + + var inner strings.Builder + + titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain) + inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled)) + inner.WriteString("\n\n") + + shift := strings.Repeat(" ", rowShift) + for _, r := range rows { + line := lipgloss.PlaceHorizontal(targetW, lipgloss.Left, shift+r.text) + inner.WriteString(line) + inner.WriteString("\n") + } + inner.WriteString("\n") + inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text)) + + box := BorderStyle.Render(inner.String()) + var b strings.Builder + b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go index 3c98b0c..207c4b4 100644 --- a/pkg/tui/tabs.go +++ b/pkg/tui/tabs.go @@ -4,7 +4,6 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // Tab represents a single tab in the TUI @@ -45,6 +44,13 @@ func (tm *TabManager) Active() Tab { func (tm *TabManager) Add(tab Tab) tea.Cmd { tm.tabs = append(tm.tabs, tab) tm.active = len(tm.tabs) - 1 + + // Forward current terminal size so new tabs know their dimensions + if tm.width > 0 && tm.height > 0 { + updated, _ := tab.Update(tea.WindowSizeMsg{Width: tm.width, Height: tm.height}) + tm.tabs[tm.active] = updated + } + return tab.Init() } @@ -168,10 +174,6 @@ func (tm *TabManager) View() string { b.WriteString(content) } - // Render status bar - b.WriteString("\n") - b.WriteString(renderStatusBar(tm)) - return b.String() } @@ -195,17 +197,4 @@ func renderTabBar(tm *TabManager) string { return TabBarStyle.Render(bar) } -// renderStatusBar renders the bottom status bar, centered -func renderStatusBar(tm *TabManager) string { - hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" - rendered := StatusBarStyle.Render(hints) - // Center within the terminal width - if tm.width > 0 { - w := lipgloss.Width(rendered) - if w < tm.width { - pad := (tm.width - w) / 2 - rendered = strings.Repeat(" ", pad) + rendered - } - } - return rendered -} +