Compare commits

...

5 Commits

9 changed files with 350 additions and 233 deletions
+1 -1
View File
@@ -39,4 +39,4 @@ Thumbs.db
*.local.json
# Build output
dist/
dist/hostkeeper
+32 -19
View File
@@ -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("Ctrl+Tab:switch Ctrl+Q:close Tab:next 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()
}
+87 -93
View File
@@ -104,90 +104,120 @@ 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))
// 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)
msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
return b.String()
}
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 {
start = 0
}
if start+maxCards > len(t.hosts) {
start = len(t.hosts) - maxCards
if start+maxHosts > len(t.hosts) {
start = len(t.hosts) - maxHosts
}
for i := start; i < start+maxCards; i++ {
for i := start; i < start+maxHosts; 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")
// 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)
}
b.WriteString("\n")
rows = append(rows, styledRow{text: styled, plain: raw})
}
if maxCards < len(t.hosts) {
b.WriteString(centerText(
SubtitleStyle.Render(fmt.Sprintf("%d of %d hosts", start+1, len(t.hosts))),
availW))
// Footer
var footer styledRow
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
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
var content strings.Builder
// Centered title
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
content.WriteString("\n\n")
// Center each row independently within the box
for _, r := range rows[:len(rows)-1] { // exclude footer, added below
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, 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 +249,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 {
+26 -11
View File
@@ -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("Ctrl+Tab:switch Ctrl+Q:close Tab:next 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()
}
+70 -26
View File
@@ -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,95 @@ func (t *KeyListTab) View() string {
t.mu.Lock()
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
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"))
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+Tab:switch Ctrl+Q:close ↑↓:nav 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
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")
// Render rows, excluding the footer (last element)
for _, r := range rows[:len(rows)-1] {
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, 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()
}
+18 -14
View File
@@ -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("Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close")))
return b.String()
}
+26 -11
View File
@@ -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("Ctrl+Tab:switch Ctrl+Q:close Tab:next 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()
}
+71 -28
View File
@@ -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,95 @@ func (t *SnippetListTab) View() string {
t.mu.Lock()
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
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"))
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+Tab:switch Ctrl+Q:close ↑↓:nav 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
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")
// Render rows, excluding the footer (last element)
for _, r := range rows[:len(rows)-1] {
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, 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()
}
+8 -19
View File
@@ -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
}