diff --git a/.golangci.yml b/.golangci.yml index 96daa92..27f1907 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -22,6 +22,7 @@ linters: - path: _test\.go linters: - dupl # Exclude duplicate check from tests + - goconst # Exclude constant check from tests issues: max-issues-per-linter: 0 diff --git a/ROADMAP.md b/ROADMAP.md index b3a103e..bf6fbaf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ **Goal:** Avoid standard 30-second SSH timeouts by knowing if a server is online before connecting. -- [ ] Pool of non-blocking background goroutines performing quick TCP handshakes (e.g., `net.DialTimeout` with a 1-2s limit on the target `HostName:Port`). -- [ ] Dynamic Bubble Tea messages (`PingResultMsg`) to update the TUI model reactively. -- [ ] Interactive status column displaying **Online (Green with latency in ms)** or **Offline (Red)**. +- [x] Pool of non-blocking background goroutines performing quick TCP handshakes (e.g., `net.DialTimeout` with a 1-2s limit on the target `HostName:Port`). +- [x] Dynamic Bubble Tea messages (`PingResultMsg`) to update the TUI model reactively. +- [x] Interactive status column displaying **Online (Green with latency in ms)** or **Offline (Red)**. ### Native Tagging via Lossless Comments diff --git a/pkg/ping/ping.go b/pkg/ping/ping.go new file mode 100644 index 0000000..5ee50c0 --- /dev/null +++ b/pkg/ping/ping.go @@ -0,0 +1,39 @@ +// Package ping implements a non-blocking TCP ping function. +package ping + +import ( + "net" + "strings" + "time" +) + +// Result holds the raw outcome of a connection handshake. +type Result struct { + Online bool + Latency float64 +} + +// Ping performs a quick TCP handshake against a target and port. +func Ping(target, port string, timeout time.Duration) Result { + if port == "" { + port = "22" + } + var address string + if strings.Contains(target, ":") && !strings.HasPrefix(target, "[") { + address = "[" + target + "]:" + port + } else { + address = net.JoinHostPort(target, port) + } + + start := time.Now() + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return Result{Online: false} + } + _ = conn.Close() + + return Result{ + Online: true, + Latency: float64(time.Since(start).Microseconds()) / 1000.0, + } +} diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 32f3250..08cb073 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -22,6 +22,8 @@ const ( addConfigCmd = "addconf, add-config" renameConfigCmd = "mvconf, rename-config" deleteConfigCmd = "rmconf, delete-config" + pingCmd = "p, ping" + pingAllCmd = "P, pingall" ) // helpOptions centralizes all interactive command shortcuts and their help text @@ -30,6 +32,8 @@ var helpOptions = []components.HelpOption{ {Shortcut: editCmd, Description: "Edit selected connection"}, {Shortcut: deleteCmd, Description: "Delete selected connection"}, {Shortcut: moveCmd, Description: "Move connection to a file/tab"}, + {Shortcut: pingCmd, Description: "Ping selected connection"}, + {Shortcut: pingAllCmd, Description: "Ping all connections"}, {Shortcut: addConfigCmd, Description: "Add a new config file"}, {Shortcut: renameConfigCmd, Description: "Rename a config file"}, {Shortcut: deleteConfigCmd, Description: "Delete empty config file"}, @@ -153,6 +157,16 @@ func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { case matchesCommand(cmd, helpCmd): action = commands.Help() + case matchesCommand(cmd, pingAllCmd): + return m, m.PingAll() + + case matchesCommand(cmd, pingCmd): + if len(m.Filtered) > 0 { + selected := m.Filtered[m.SelectedIndex] + return m, m.PingHost(selected) + } + return m, nil + case matchesCommand(cmd, addConfigCmd): action = commands.AddConfig(m.Manager, parts) diff --git a/pkg/tui/keybinds.go b/pkg/tui/keybinds.go index 2691a3c..e4b2acc 100644 --- a/pkg/tui/keybinds.go +++ b/pkg/tui/keybinds.go @@ -46,6 +46,19 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "l", "right": m.navigateTabs(1) + case "p": + if len(m.Filtered) > 0 { + selected := m.Filtered[m.SelectedIndex] + if m.PingResults == nil { + m.PingResults = make(map[string]*PingResult) + } + m.PingResults[selected.Alias] = &PingResult{Pending: true} + return m, m.PingHost(selected) + } + + case "P": + return m, m.PingAll() + case "/": m.Mode = ModeSearch m.SearchInput.SetValue("") diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 6b7fdaa..e1dce52 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -57,6 +57,8 @@ type Model struct { // Active overlay component ActiveComponent components.Component + + PingResults map[string]*PingResult } // Init initializes the Bubble Tea application state and returns initial commands. @@ -64,6 +66,7 @@ func (m *Model) Init() tea.Cmd { return tea.Batch( tea.SetWindowTitle("tuSSHi"), textinput.Blink, + m.PingAll(), ) } @@ -82,6 +85,7 @@ func NewModel(mgr *config.Manager) *Model { Mode: ModeNormal, SearchInput: searchIn, CommandInput: cmdIn, + PingResults: make(map[string]*PingResult), } m.Reload() return m @@ -95,6 +99,19 @@ func (m *Model) Reload() { } m.Hosts = m.Manager.GetHosts() + // prune ping results for hosts that no longer exist + if m.PingResults != nil { + activeAliases := make(map[string]bool) + for _, h := range m.Hosts { + activeAliases[h.Alias] = true + } + for alias := range m.PingResults { + if !activeAliases[alias] { + delete(m.PingResults, alias) + } + } + } + m.Tabs = []string{tabAll} for _, f := range m.Manager.FileOrder { // Filter out the "config" file as it can't be renamed or deleted and just creates confusion. diff --git a/pkg/tui/ping.go b/pkg/tui/ping.go new file mode 100644 index 0000000..4cce141 --- /dev/null +++ b/pkg/tui/ping.go @@ -0,0 +1,135 @@ +package tui + +import ( + "fmt" + "strings" + "time" + "tusshi/pkg/config" + "tusshi/pkg/ping" + "tusshi/pkg/tui/style" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// PingResult represents the status and latency of a single host connection test. +type PingResult struct { + Online bool + Latency float64 + Pending bool +} + +// PingResultMsg carries the outcome of a background ping check. +type PingResultMsg struct { + Alias string + Online bool + Latency float64 +} + +// Limit concurrency to 15 to avoid fd exhaustion or firewall bans. +var pingSemaphore = make(chan struct{}, 15) + +// PingHost performs a non-blocking TCP dial check against the host. +// It resolves the target hostname and defaults the port to 22. +func (m *Model) PingHost(h *config.Host) tea.Cmd { + return func() tea.Msg { + pingSemaphore <- struct{}{} + defer func() { <-pingSemaphore }() + + target := h.Name + if target == "" { + target = h.Alias + } + + res := ping.Ping(target, h.Port, 1500*time.Millisecond) + + return PingResultMsg{ + Alias: h.Alias, + Online: res.Online, + Latency: res.Latency, + } + } +} + +// PingAll returns a batch command to ping all active hosts in the TUI list. +// It pre-allocates and clears prior results, setting them to pending. +func (m *Model) PingAll() tea.Cmd { + if len(m.Hosts) == 0 { + return nil + } + + var cmds []tea.Cmd + for _, h := range m.Hosts { + if h.IsWildcard { + continue + } + if m.PingResults == nil { + m.PingResults = make(map[string]*PingResult) + } + m.PingResults[h.Alias] = &PingResult{Pending: true} + cmds = append(cmds, m.PingHost(h)) + } + return tea.Batch(cmds...) +} + +// renderStatusCell outputs a formatted Status string for the host table row. +// Blends row active backgrounds and column width limits correctly. +func (m *Model) renderStatusCell(alias string, rowActive bool, width int) string { + res, exists := m.PingResults[alias] + isSmall := width < 10 + + if !exists { + text := "Pending" + if isSmall { + text = "○" + } + return renderCell(text, style.Muted, rowActive, width) + } + if res.Pending { + text := "Checking..." + if isSmall { + text = "○" + } + return renderCell(text, style.Muted, rowActive, width) + } + if res.Online { + text := fmt.Sprintf("Online (%.0fms)", res.Latency) + if isSmall { + text = fmt.Sprintf("● %.0fms", res.Latency) + } + return renderCell(text, style.StatusOnline, rowActive, width) + } + text := "Offline" + if isSmall { + text = "ø" + } + return renderCell(text, style.StatusOffline, rowActive, width) +} + +// renderCell renders padded text, preserving background styling for selected rows. +func renderCell(text string, cellStyle lipgloss.Style, rowActive bool, width int) string { + if len(text) > width { + text = truncate(text, width) + } + visibleLen := len(text) + + finalStyle := cellStyle + if rowActive { + finalStyle = finalStyle.Background(lipgloss.Color("237")) + } + + styled := finalStyle.Render(text) + + padLen := max(0, width-visibleLen) + var padding string + if padLen > 0 { + padSpace := strings.Repeat(" ", padLen) + if rowActive { + padding = lipgloss.NewStyle().Background(lipgloss.Color("237")).Render(padSpace) + } else { + padding = padSpace + } + } + + return styled + padding +} diff --git a/pkg/tui/style/styles.go b/pkg/tui/style/styles.go index abc7ca1..1b9513a 100644 --- a/pkg/tui/style/styles.go +++ b/pkg/tui/style/styles.go @@ -84,4 +84,12 @@ var ( Muted = lipgloss.NewStyle(). Foreground(theme.Global.Muted) + + StatusOnline = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF00")). + Bold(true) + + StatusOffline = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Bold(true) ) diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go index 36b6d5c..25257e5 100644 --- a/pkg/tui/tui_test.go +++ b/pkg/tui/tui_test.go @@ -158,3 +158,54 @@ func TestTUIPrimaryConfigHiding(t *testing.T) { assert.NotContains(t, m.Tabs, primaryPath) }) } + +// TestTUIPing verifies that the background ping structures work correctly. +func TestTUIPing(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + + content := ` +Host srv-1 + HostName localhost + Port 9999 +` + err := os.WriteFile(primaryPath, []byte(content), 0600) + assert.NoError(t, err) + + mgr := config.NewManager(primaryPath) + err = mgr.Load() + assert.NoError(t, err) + + m := NewModel(mgr) + + t.Run("PingAll initialization", func(t *testing.T) { + cmd := m.PingAll() + assert.NotNil(t, cmd) + assert.Contains(t, m.PingResults, "srv-1") + assert.True(t, m.PingResults["srv-1"].Pending) + }) + + t.Run("PingResultMsg processing", func(t *testing.T) { + updatedModel, cmd := m.Update(PingResultMsg{ + Alias: "srv-1", + Online: true, + Latency: 12.34, + }) + assert.Nil(t, cmd) + m = updatedModel.(*Model) + assert.False(t, m.PingResults["srv-1"].Pending) + assert.True(t, m.PingResults["srv-1"].Online) + assert.Equal(t, 12.34, m.PingResults["srv-1"].Latency) + }) + + t.Run("render status cell", func(t *testing.T) { + cell := m.renderStatusCell("srv-1", false, 15) + assert.Contains(t, cell, "Online") + assert.Contains(t, cell, "12ms") + }) + + t.Run("render status cell small screen", func(t *testing.T) { + cell := m.renderStatusCell("srv-1", false, 4) + assert.Contains(t, cell, "●") + }) +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 25f2df5..9b5f0dd 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -29,7 +29,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.AlertText = "SSH session disconnected." } m.Reload() - return m, tea.ClearScreen + return m, tea.Batch(tea.ClearScreen, m.PingAll()) + + case PingResultMsg: + if m.PingResults == nil { + m.PingResults = make(map[string]*PingResult) + } + m.PingResults[msg.Alias] = &PingResult{ + Online: msg.Online, + Latency: msg.Latency, + Pending: false, + } + return m, nil case tea.KeyMsg: // Reset temporary notifications on keypress @@ -43,6 +54,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { activeCmd, done := m.ActiveComponent.Update(msg) if done { m.ActiveComponent = nil + return m, tea.Batch(activeCmd, m.PingAll()) } return m, activeCmd } diff --git a/pkg/tui/view.go b/pkg/tui/view.go index 46c466a..3d83613 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -16,7 +16,7 @@ func stripANSI(s string) string { // View renders the TUSSHI TUI interface based on state and window constraints. func (m *Model) View() string { - if m.Width < 25 || m.Height < 10 { + if m.Width < 40 || m.Height < 20 { return "Terminal is too small." } diff --git a/pkg/tui/view_table.go b/pkg/tui/view_table.go index 4d29a9c..fe25c53 100644 --- a/pkg/tui/view_table.go +++ b/pkg/tui/view_table.go @@ -3,7 +3,10 @@ package tui import ( "fmt" "strings" + "tusshi/pkg/config" "tusshi/pkg/tui/style" + + "github.com/charmbracelet/lipgloss" ) // renderTable draws the formatted column grid displaying active connections. @@ -13,60 +16,88 @@ func (m *Model) renderTable(width, maxHeight int) string { } var headerRow, dividerRow string - var wAlias, wName, wUser, wPort, wConfig int + var wAlias, wName, wUser, wPort, wStatus, wConfig int // adaptive column allocation to prevent overflow at small terminal widths switch { - case width >= 61: + case width >= 85: + wTotal := max(width-14, 10) + wAlias = int(float64(wTotal) * 0.15) + wName = int(float64(wTotal) * 0.30) + wUser = int(float64(wTotal) * 0.12) + wPort = int(float64(wTotal) * 0.08) + wConfig = int(float64(wTotal) * 0.23) + wStatus = wTotal - wAlias - wName - wUser - wPort - wConfig + + headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-*s", + wAlias, "ALIAS", + wName, "NAME / ADDRESS", + wUser, "USER", + wPort, "PORT", + wConfig, "CONFIG", + wStatus, "STATUS", + ) + dividerRow = fmt.Sprintf(" %s %s %s %s %s %s", + strings.Repeat("─", wAlias), + strings.Repeat("─", wName), + strings.Repeat("─", wUser), + strings.Repeat("─", wPort), + strings.Repeat("─", wConfig), + strings.Repeat("─", wStatus), + ) + case width >= 65: wTotal := max(width-12, 10) wAlias = int(float64(wTotal) * 0.20) - wName = int(float64(wTotal) * 0.30) + wName = int(float64(wTotal) * 0.35) wUser = int(float64(wTotal) * 0.15) - wPort = int(float64(wTotal) * 0.10) - wConfig = wTotal - wAlias - wName - wUser - wPort + wConfig = int(float64(wTotal) * 0.18) + wStatus = wTotal - wAlias - wName - wUser - wConfig headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s", wAlias, "ALIAS", wName, "NAME / ADDRESS", wUser, "USER", - wPort, "PORT", wConfig, "CONFIG", + wStatus, "STATUS", ) dividerRow = fmt.Sprintf(" %s %s %s %s %s", strings.Repeat("─", wAlias), strings.Repeat("─", wName), strings.Repeat("─", wUser), - strings.Repeat("─", wPort), strings.Repeat("─", wConfig), + strings.Repeat("─", wStatus), ) - case width >= 41: + case width >= 45: wTotal := max(width-8, 10) wAlias = int(float64(wTotal) * 0.30) - wUser = int(float64(wTotal) * 0.20) - wName = wTotal - wAlias - wUser + wStatus = 6 + wName = wTotal - wAlias - wStatus headerRow = fmt.Sprintf(" %-*s %-*s %-*s", wAlias, "ALIAS", wName, "NAME / ADDRESS", - wUser, "USER", + wStatus, "STATUS", ) dividerRow = fmt.Sprintf(" %s %s %s", strings.Repeat("─", wAlias), strings.Repeat("─", wName), - strings.Repeat("─", wUser), + strings.Repeat("─", wStatus), ) default: wTotal := max(width-6, 10) - wAlias = int(float64(wTotal) * 0.40) - wName = wTotal - wAlias + wAlias = int(float64(wTotal) * 0.35) + wStatus = 2 + wName = wTotal - wAlias - wStatus - headerRow = fmt.Sprintf(" %-*s %-*s", + headerRow = fmt.Sprintf(" %-*s %-*s %-*s", wAlias, "ALIAS", wName, "NAME / ADDRESS", + wStatus, "S", ) - dividerRow = fmt.Sprintf(" %s %s", + dividerRow = fmt.Sprintf(" %s %s %s", strings.Repeat("─", wAlias), strings.Repeat("─", wName), + strings.Repeat("─", wStatus), ) } @@ -88,60 +119,111 @@ func (m *Model) renderTable(width, maxHeight int) string { for idx := startIndex; idx < len(m.Filtered) && len(rows) < maxHeight; idx++ { h := m.Filtered[idx] - alias := truncate(h.Alias, wAlias) - name := truncate(h.Name, wName) - - var rowLine string - switch { - case width >= 61: - user := truncate(h.User, wUser) - port := h.Port - if port == "" { - port = "22" - } - cfgNickname := strings.TrimSuffix(GetTabLabel(h.SourceFile), ".conf") - cfgNickname = strings.TrimSuffix(cfgNickname, "config") - cfgNickname = truncate(cfgNickname, wConfig) - - rowLine = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s", - wAlias, alias, - wName, name, - wUser, user, - wPort, port, - wConfig, cfgNickname, - ) - case width >= 41: - user := truncate(h.User, wUser) - rowLine = fmt.Sprintf(" %-*s %-*s %-*s", - wAlias, alias, - wName, name, - wUser, user, - ) - default: - rowLine = fmt.Sprintf(" %-*s %-*s", - wAlias, alias, - wName, name, - ) + rows = append(rows, m.renderRow(h, idx, wAlias, wName, wUser, wPort, wStatus, wConfig)) + } + + return strings.Join(rows, "\n") +} + +// renderRow constructs a formatted row, applying specific colors for the status column +// and blending background colors correctly when the row is active/selected. +func (m *Model) renderRow(h *config.Host, idx int, wAlias, wName, wUser, wPort, wStatus, wConfig int) string { + rowActive := idx == m.SelectedIndex + + var cells []string + + // Alias cell + alias := truncate(h.Alias, wAlias) + var aliasStyle lipgloss.Style + if rowActive { + aliasStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + } else { + aliasStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + } + cells = append(cells, renderCell(alias, aliasStyle, rowActive, wAlias)) + + // Name cell + name := truncate(h.Name, wName) + var nameStyle lipgloss.Style + if rowActive { + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + } else { + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) + } + cells = append(cells, renderCell(name, nameStyle, rowActive, wName)) + + // User cell + if wUser > 0 { + user := truncate(h.User, wUser) + var userStyle lipgloss.Style + if rowActive { + userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + } else { + userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) } + cells = append(cells, renderCell(user, userStyle, rowActive, wUser)) + } - if idx == m.SelectedIndex { - rowLine = "❯ " + rowLine[2:] - rows = append(rows, style.RowActive.Render(rowLine)) + // Port cell + if wPort > 0 { + port := h.Port + if port == "" { + port = "22" + } + port = truncate(port, wPort) + var portStyle lipgloss.Style + if rowActive { + portStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) } else { - rowLine = " " + rowLine[2:] - rows = append(rows, style.RowInactive.Render(rowLine)) + portStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) } + cells = append(cells, renderCell(port, portStyle, rowActive, wPort)) } - return strings.Join(rows, "\n") + // Config cell + if wConfig > 0 { + cfgNickname := strings.TrimSuffix(GetTabLabel(h.SourceFile), ".conf") + cfgNickname = strings.TrimSuffix(cfgNickname, "config") + cfgNickname = truncate(cfgNickname, wConfig) + var cfgStyle lipgloss.Style + if rowActive { + cfgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + } else { + cfgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + } + cells = append(cells, renderCell(cfgNickname, cfgStyle, rowActive, wConfig)) + } + + // Status cell + if wStatus > 0 { + statusCell := m.renderStatusCell(h.Alias, rowActive, wStatus) + cells = append(cells, statusCell) + } + + rowContent := strings.Join(cells, " ") + + prefix := " " + if rowActive { + prefix = "❯ " + } + + var prefixStyle lipgloss.Style + if rowActive { + prefixStyle = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("#FF5500")).Bold(true) + } else { + prefixStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + } + + return prefixStyle.Render(prefix) + rowContent } func truncate(s string, w int) string { - if len(s) > w { + runes := []rune(s) + if len(runes) > w { if w > 3 { - return s[:w-3] + "..." + return string(runes[:w-3]) + "..." } - return s[:w] + return string(runes[:w]) } return s }