Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions pkg/ping/ping.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
14 changes: 14 additions & 0 deletions pkg/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions pkg/tui/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
17 changes: 17 additions & 0 deletions pkg/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,16 @@ type Model struct {

// Active overlay component
ActiveComponent components.Component

PingResults map[string]*PingResult
}

// Init initializes the Bubble Tea application state and returns initial commands.
func (m *Model) Init() tea.Cmd {
return tea.Batch(
tea.SetWindowTitle("tuSSHi"),
textinput.Blink,
m.PingAll(),
)
}

Expand All @@ -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
Expand All @@ -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.
Expand Down
135 changes: 135 additions & 0 deletions pkg/tui/ping.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions pkg/tui/style/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
51 changes: 51 additions & 0 deletions pkg/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "●")
})
}
14 changes: 13 additions & 1 deletion pkg/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading
Loading