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
+100 -102
View File
@@ -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 {