fix(tui): center all tabs, full-screen SFTP, muted inline keybindings
This commit is contained in:
+100
-102
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user