fix(tui): center all tabs, full-screen SFTP, muted inline keybindings

This commit is contained in:
swanadiva
2026-06-23 17:12:12 +07:00
parent c7568e0bf4
commit 7f197b9e3a
8 changed files with 355 additions and 232 deletions
+32 -19
View File
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
) )
@@ -283,18 +284,28 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) {
} }
func (t *HostFormTab) View() string { 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" title := "Add New Host"
if t.mode == formModeEdit { if t.mode == formModeEdit {
title = "Edit Host" title = "Edit Host"
} }
b.WriteString(AppTitleStyle.Render(title)) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
} }
for i := fieldID(0); i < fieldCount; i++ { for i := fieldID(0); i < fieldCount; i++ {
@@ -305,11 +316,10 @@ func (t *HostFormTab) View() string {
if i == t.focus { if i == t.focus {
style = HighlightStyle style = HighlightStyle
} }
b.WriteString(style.Render(label + ":")) inner.WriteString(style.Render(label + ":"))
b.WriteString("\n") inner.WriteString("\n")
if i == fieldAuthType { if i == fieldAuthType {
// Render auth type as toggle pills
current := input.Value() current := input.Value()
pills := []string{"password", "key"} pills := []string{"password", "key"}
var parts []string var parts []string
@@ -324,25 +334,28 @@ func (t *HostFormTab) View() string {
parts = append(parts, SubtitleStyle.Render(" "+p+" ")) parts = append(parts, SubtitleStyle.Render(" "+p+" "))
} }
} }
b.WriteString(" ") inner.WriteString(" ")
b.WriteString(strings.Join(parts, " ")) inner.WriteString(strings.Join(parts, " "))
b.WriteString("\n") inner.WriteString("\n")
if i == t.focus { 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 { } else {
renderedInput := input.View() renderedInput := input.View()
b.WriteString(" ") inner.WriteString(" ")
b.WriteString(renderedInput) inner.WriteString(renderedInput)
b.WriteString("\n\n") inner.WriteString("\n\n")
} }
} }
// Help text inner.WriteString("\n")
b.WriteString(SubtitleStyle.Render( inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) 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() return b.String()
} }
+91 -93
View File
@@ -104,90 +104,124 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
return t, nil return t, nil
} }
// View renders the host list // View renders the host list — exactly like tamagosh
func (t *HostListTab) View() string { 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 var b strings.Builder
// Show error if any // Error display
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err))) b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
b.WriteString("\n") b.WriteString("\n")
t.err = nil 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 { if len(t.hosts) == 0 {
b.WriteString(centerText(SubtitleStyle.Render("No hosts found. Add with Ctrl+N"), availW)) msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
b.WriteString("\n") b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
} else { return b.String()
// 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 // 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 { if start < 0 {
start = 0 start = 0
} }
if start+maxCards > len(t.hosts) { if start+maxHosts > len(t.hosts) {
start = len(t.hosts) - maxCards start = len(t.hosts) - maxHosts
} }
for i := start; i < start+maxCards; i++ { for i := start; i < start+maxHosts; i++ {
host := t.hosts[i] host := t.hosts[i]
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
for _, line := range strings.Split(entry, "\n") { // tamagosh format: " %-12s %-18s :%d"
b.WriteString(centerText(line, availW)) raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
b.WriteString("\n")
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)
} }
b.WriteString("\n") rows = append(rows, styledRow{text: styled, plain: raw})
} }
if maxCards < len(t.hosts) { // Footer
b.WriteString(centerText( var footer styledRow
SubtitleStyle.Render(fmt.Sprintf("%d of %d hosts", start+1, len(t.hosts))), footerText := "[n]ew [e]dit [d]el [f]sftp [K]eygen [/]find [q]uit Ctrl+Tab:switch Ctrl+Q:close"
availW)) 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() 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 // Close is a no-op for host list tab
func (t *HostListTab) Close() {} func (t *HostListTab) Close() {}
@@ -219,42 +253,6 @@ func FindHostListTab(tabs []Tab) *HostListTab {
return nil 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 // formatTagsForTUI formats tags for TUI display
func formatTagsForTUI(tags []string) string { func formatTagsForTUI(tags []string) string {
if len(tags) == 0 { if len(tags) == 0 {
+26 -11
View File
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
) )
@@ -191,18 +192,28 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
} }
func (t *KeyFormTab) View() string { 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" title := "Add SSH Key"
if t.mode == keyFormEdit { if t.mode == keyFormEdit {
title = "Edit SSH Key" title = "Edit SSH Key"
} }
b.WriteString(AppTitleStyle.Render(title)) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
} }
for i := keyFieldID(0); i < keyFieldCount; i++ { for i := keyFieldID(0); i < keyFieldCount; i++ {
@@ -212,15 +223,19 @@ func (t *KeyFormTab) View() string {
if i == t.focus { if i == t.focus {
style = HighlightStyle style = HighlightStyle
} }
b.WriteString(style.Render(label + ":")) inner.WriteString(style.Render(label + ":"))
b.WriteString("\n ") inner.WriteString("\n ")
b.WriteString(input.View()) inner.WriteString(input.View())
b.WriteString("\n\n") inner.WriteString("\n\n")
} }
b.WriteString(SubtitleStyle.Render( inner.WriteString("\n")
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) 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() return b.String()
} }
+71 -26
View File
@@ -7,6 +7,7 @@ import (
"sync" "sync"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
@@ -134,52 +135,96 @@ func (t *KeyListTab) View() string {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
if t.state == keyListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading keys..."))
}
if t.state == keyListError {
var b strings.Builder var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
b.WriteString(AppTitleStyle.Render("SSH Key Pairs")) ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n\n") b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
switch t.state { SubtitleStyle.Render("Press Esc to go back")))
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"))
return b.String() return b.String()
} }
type styledRow struct {
text string
plain string
}
var rows []styledRow
title := "SSH Key Pairs"
titlePlain := title
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) errLine := fmt.Sprintf("Error: %v", t.err)
b.WriteString("\n\n") rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
} }
if len(t.keys) == 0 { if len(t.keys) == 0 {
b.WriteString(SubtitleStyle.Render("No SSH keys stored.")) empty := "No SSH keys stored."
b.WriteString("\n") hint := "Press Ctrl+N to add a new key."
b.WriteString(SubtitleStyle.Render("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 { } else {
for i, key := range t.keys { for i, key := range t.keys {
var line string var plain string
if key.Type != "" { if key.Type != "" {
line = fmt.Sprintf(" %s (%s)", key.Name, key.Type) plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
} else { } else {
line = fmt.Sprintf(" %s", key.Name) plain = fmt.Sprintf(" %s", key.Name)
} }
var styled string
if i == t.selected { if i == t.selected {
b.WriteString(SelectedStyle.Render(line)) styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else { } 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") footerPlain := "Ctrl+N:add Ctrl+E:edit D:delete Esc:back Ctrl+Tab:switch Ctrl+Q:close"
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) 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() return b.String()
} }
+18 -14
View File
@@ -526,20 +526,21 @@ func (t *SFTPBrowserTab) View() string {
defer t.mu.Unlock() defer t.mu.Unlock()
if !t.connected && t.err != nil { if !t.connected && t.err != nil {
return fmt.Sprintf("%s\n\n%s", var b strings.Builder
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)), b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to close")) 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 { 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 halfW := t.width / 2
if contentW < 40 {
contentW = 80
}
halfW := contentW/2 - 1
if halfW < 20 { if halfW < 20 {
halfW = 20 halfW = 20
} }
@@ -549,21 +550,24 @@ func (t *SFTPBrowserTab) View() string {
var b strings.Builder 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("\n")
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)) b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
if t.err != nil { 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 != "" { 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( b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
"Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close")) 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() return b.String()
} }
+26 -11
View File
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
) )
@@ -199,18 +200,28 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
} }
func (t *SnippetFormTab) View() string { 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" title := "Add Snippet"
if t.mode == snippetFormEdit { if t.mode == snippetFormEdit {
title = "Edit Snippet" title = "Edit Snippet"
} }
b.WriteString(AppTitleStyle.Render(title)) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
b.WriteString("\n\n") ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
} }
for i := snippetFieldID(0); i < snippetFieldCount; i++ { for i := snippetFieldID(0); i < snippetFieldCount; i++ {
@@ -220,15 +231,19 @@ func (t *SnippetFormTab) View() string {
if i == t.focus { if i == t.focus {
style = HighlightStyle style = HighlightStyle
} }
b.WriteString(style.Render(label + ":")) inner.WriteString(style.Render(label + ":"))
b.WriteString("\n ") inner.WriteString("\n ")
b.WriteString(input.View()) inner.WriteString(input.View())
b.WriteString("\n\n") inner.WriteString("\n\n")
} }
b.WriteString(SubtitleStyle.Render( inner.WriteString("\n")
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) 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() return b.String()
} }
+72 -28
View File
@@ -7,6 +7,7 @@ import (
"sync" "sync"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
@@ -134,53 +135,96 @@ func (t *SnippetListTab) View() string {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
if t.state == snippetListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading snippets..."))
}
if t.state == snippetListError {
var b strings.Builder var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
b.WriteString(AppTitleStyle.Render("Command Snippets")) ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n\n") b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
switch t.state { SubtitleStyle.Render("Press Esc to go back")))
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"))
return b.String() return b.String()
} }
type styledRow struct {
text string
plain string
}
var rows []styledRow
title := "Command Snippets"
titlePlain := title
if t.err != nil { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) errLine := fmt.Sprintf("Error: %v", t.err)
b.WriteString("\n\n") rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
} }
if len(t.snippets) == 0 { if len(t.snippets) == 0 {
b.WriteString(SubtitleStyle.Render("No snippets stored.")) empty := "No snippets stored."
b.WriteString("\n") hint := "Press Ctrl+N to add a new snippet."
b.WriteString(SubtitleStyle.Render("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 { } else {
for i, sn := range t.snippets { for i, sn := range t.snippets {
var line string var plain string
desc := sn.Description if sn.Description != "" {
if desc != "" { plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description)
line = fmt.Sprintf(" %s — %s", sn.Name, desc)
} else { } else {
line = fmt.Sprintf(" %s", sn.Name) plain = fmt.Sprintf(" %s", sn.Name)
} }
var styled string
if i == t.selected { if i == t.selected {
b.WriteString(SelectedStyle.Render(line)) styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else { } 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") footerPlain := "Ctrl+N:add Ctrl+E:edit D:delete Esc:back Ctrl+Tab:switch Ctrl+Q:close"
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) 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() return b.String()
} }
+8 -19
View File
@@ -4,7 +4,6 @@ import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
// Tab represents a single tab in the TUI // 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 { func (tm *TabManager) Add(tab Tab) tea.Cmd {
tm.tabs = append(tm.tabs, tab) tm.tabs = append(tm.tabs, tab)
tm.active = len(tm.tabs) - 1 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() return tab.Init()
} }
@@ -168,10 +174,6 @@ func (tm *TabManager) View() string {
b.WriteString(content) b.WriteString(content)
} }
// Render status bar
b.WriteString("\n")
b.WriteString(renderStatusBar(tm))
return b.String() return b.String()
} }
@@ -195,17 +197,4 @@ func renderTabBar(tm *TabManager) string {
return TabBarStyle.Render(bar) 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
}