feat: add async SSH ping for all hosts with status indicator

This commit is contained in:
2025-09-06 10:29:56 +02:00
parent 6577002e2b
commit 42387eb1fa
10 changed files with 590 additions and 189 deletions

View File

@@ -9,6 +9,260 @@ import (
"github.com/charmbracelet/bubbles/table"
)
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
// and content length, ensuring all content fits when possible
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
if m.width <= 0 {
// Fallback to static widths if terminal width is not available
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
}
// Calculate content lengths
maxNameLength := 8 // Minimum for "Name" header + status indicator
maxHostnameLength := 8 // Minimum for "Hostname" header
maxTagsLength := 8 // Minimum for "Tags" header
maxLastLoginLength := 12 // Minimum for "Last Login" header
for _, host := range hosts {
// Name column includes status indicator (2 chars) + space (1 char) + name
nameLength := 3 + len(host.Name)
if nameLength > maxNameLength {
maxNameLength = nameLength
}
if len(host.Hostname) > maxHostnameLength {
maxHostnameLength = len(host.Hostname)
}
// Calculate tags string length
var tagsStr string
if len(host.Tags) > 0 {
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
if len(tagsStr) > maxTagsLength {
maxTagsLength = len(tagsStr)
}
// Calculate last login length
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
timeStr := formatTimeAgo(lastConnect)
if len(timeStr) > maxLastLoginLength {
maxLastLoginLength = len(timeStr)
}
}
}
}
// Add padding to each column
maxNameLength += 2
maxHostnameLength += 2
maxTagsLength += 2
maxLastLoginLength += 2
// Calculate available width (minus borders and separators)
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
availableWidth := m.width - 5
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
if totalNeededWidth <= availableWidth {
// Everything fits perfectly
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
}
// Need to adjust widths - prioritize columns by importance
// Priority: Name > Hostname > Last Login > Tags
// Calculate minimum widths
minNameWidth := 15 // Enough for status + short name
minHostnameWidth := 15
minLastLoginWidth := 12
minTagsWidth := 10
remainingWidth := availableWidth
// Allocate minimum widths first
nameWidth := minNameWidth
hostnameWidth := minHostnameWidth
lastLoginWidth := minLastLoginWidth
tagsWidth := minTagsWidth
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
// Distribute remaining space proportionally
if remainingWidth > 0 {
// Calculate how much each column wants beyond minimum
nameWant := maxNameLength - minNameWidth
hostnameWant := maxHostnameLength - minHostnameWidth
lastLoginWant := maxLastLoginLength - minLastLoginWidth
tagsWant := maxTagsLength - minTagsWidth
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
if totalWant > 0 {
// Distribute proportionally
nameExtra := (nameWant * remainingWidth) / totalWant
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
nameWidth += nameExtra
hostnameWidth += hostnameExtra
lastLoginWidth += lastLoginExtra
tagsWidth += tagsExtra
}
}
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Get ping status indicator
statusIndicator := m.getPingStatusIndicator(host.Name)
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
statusIndicator + " " + host.Name,
host.Hostname,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 13 lines minimum to preserve essential UI elements
reservedHeight := 13
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 3 rows for basic usability
// Even in very small terminals, we want to show at least header + 2 hosts
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// Add one extra line to prevent the last host from being hidden
// This compensates for table rendering quirks in bubble tea
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Use dynamic column width calculation
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
// Create new columns with updated widths and sort indicators
nameTitle := "Name"
lastLoginTitle := "Last Login"
// Add sort indicators based on current sort mode
switch m.sortMode {
case SortByName:
nameTitle += " ↓"
case SortByLastUsed:
lastLoginTitle += " ↓"
}
columns := []table.Column{
{Title: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: userWidth}, // Commented to save space
// {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: lastLoginTitle, Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Legacy functions for compatibility
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
func calculateNameColumnWidth(hosts []config.SSHHost) int {
@@ -90,172 +344,3 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
return maxLength
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 13 lines minimum to preserve essential UI elements
reservedHeight := 13
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 3 rows for basic usability
// Even in very small terminals, we want to show at least header + 2 hosts
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// Add one extra line to prevent the last host from being hidden
// This compensates for table rendering quirks in bubble tea
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Calculate base column widths
nameWidth := calculateNameColumnWidth(hostsToShow)
tagsWidth := calculateTagsColumnWidth(hostsToShow)
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
// Fixed column widths
hostnameWidth := 25
// userWidth := 12 // Commented to save space
// portWidth := 6 // Commented to save space
// Calculate total width needed for all columns
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth
// Available width (accounting for table borders and padding)
availableWidth := m.width - 4 // 4 chars for borders and padding
// If the table is too wide, scale down the variable columns proportionally
if totalWidth > availableWidth {
excessWidth := totalWidth - availableWidth
variableColumnsWidth := totalVariableWidth
if variableColumnsWidth > 0 {
// Reduce variable columns proportionally
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
lastLoginReduction := excessWidth - nameReduction - tagsReduction
nameWidth = max(8, nameWidth-nameReduction)
tagsWidth = max(8, tagsWidth-tagsReduction)
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
}
}
// Create new columns with updated widths and sort indicators
nameTitle := "Name"
lastLoginTitle := "Last Login"
// Add sort indicators based on current sort mode
switch m.sortMode {
case SortByName:
nameTitle += " ↓"
case SortByLastUsed:
lastLoginTitle += " ↓"
}
columns := []table.Column{
{Title: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: userWidth}, // Commented to save space
// {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: lastLoginTitle, Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}