diff --git a/README.md b/README.md index 4b50d7f..498ebf1 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ make run | Enter | SSH into selected server | | c | Copy SSH command to clipboard | | g | Ping selected server | +| G | Ping all servers | | r | Refresh background data | | a | Add server | | e | Edit server | diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..9e43b1d 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -66,6 +66,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'g': t.handlePingSelected() return nil + case 'G': + t.handlePingAll() + return nil case 'r': t.handleRefreshBackground() return nil @@ -234,22 +237,38 @@ func (t *tui) handleFormCancel() { t.returnToMain() } +const ( + statusUp = "up" + statusDown = "down" + statusChecking = "checking" +) + func (t *tui) handlePingSelected() { if server, ok := t.serverList.GetSelectedServer(); ok { alias := server.Alias + // Set checking status + server.PingStatus = statusChecking + t.pingStatuses[alias] = server + t.updateServerListWithPingStatus() + t.showStatusTemp(fmt.Sprintf("Pinging %s…", alias)) go func() { up, dur, err := t.serverService.Ping(server) t.app.QueueUpdateDraw(func() { - if err != nil { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B") - return - } - if up { - t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0") - } else { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B") + // Update ping status + if ps, ok := t.pingStatuses[alias]; ok { + if err != nil || !up { + ps.PingStatus = statusDown + ps.PingLatency = 0 + t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B") + } else { + ps.PingStatus = statusUp + ps.PingLatency = dur + t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0") + } + t.pingStatuses[alias] = ps + t.updateServerListWithPingStatus() } }) }() @@ -406,6 +425,93 @@ func (t *tui) returnToMain() { t.app.SetRoot(t.root, true) } +func (t *tui) updateServerListWithPingStatus() { + // Get current server list + query := "" + if t.searchVisible { + query = t.searchBar.InputField.GetText() + } + servers, _ := t.serverService.ListServers(query) + sortServersForUI(servers, t.sortMode) + + // Update ping status for each server + for i := range servers { + if ps, ok := t.pingStatuses[servers[i].Alias]; ok { + servers[i].PingStatus = ps.PingStatus + servers[i].PingLatency = ps.PingLatency + } + } + + t.serverList.UpdateServers(servers) +} + +func (t *tui) handlePingAll() { + query := "" + if t.searchVisible { + query = t.searchBar.InputField.GetText() + } + servers, err := t.serverService.ListServers(query) + if err != nil { + t.showStatusTempColor(fmt.Sprintf("Failed to get servers: %v", err), "#FF6B6B") + return + } + + if len(servers) == 0 { + t.showStatusTemp("No servers to ping") + return + } + + t.showStatusTemp(fmt.Sprintf("Pinging all %d servers…", len(servers))) + + // Clear existing statuses + t.pingStatuses = make(map[string]domain.Server) + + // Set all servers to checking status + for _, server := range servers { + s := server + s.PingStatus = statusChecking + t.pingStatuses[s.Alias] = s + } + t.updateServerListWithPingStatus() + + // Ping all servers concurrently + for _, server := range servers { + go func(srv domain.Server) { + up, dur, err := t.serverService.Ping(srv) + t.app.QueueUpdateDraw(func() { + if ps, ok := t.pingStatuses[srv.Alias]; ok { + if err != nil || !up { + ps.PingStatus = statusDown + ps.PingLatency = 0 + } else { + ps.PingStatus = statusUp + ps.PingLatency = dur + } + t.pingStatuses[srv.Alias] = ps + t.updateServerListWithPingStatus() + } + }) + }(server) + } + + // Show completion status after 3 seconds + go func() { + time.Sleep(3 * time.Second) + t.app.QueueUpdateDraw(func() { + upCount := 0 + downCount := 0 + for _, ps := range t.pingStatuses { + if ps.PingStatus == statusUp { + upCount++ + } else if ps.PingStatus == statusDown { + downCount++ + } + } + t.showStatusTempColor(fmt.Sprintf("Ping completed: %d UP, %d DOWN", upCount, downCount), "#A0FFA0") + }) + }() +} + // showStatusTemp displays a temporary message in the status bar (default green) and then restores the default text. func (t *tui) showStatusTemp(msg string) { if t.statusBar == nil { diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go index de94973..9710976 100644 --- a/internal/adapters/ui/hint_bar.go +++ b/internal/adapters/ui/hint_bar.go @@ -22,6 +22,6 @@ import ( func NewHintBar() *tview.TextView { hint := tview.NewTextView().SetDynamicColors(true) hint.SetBackgroundColor(tcell.Color233) - hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") + hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g/G Ping (All) • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") return hint } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..497550b 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -2039,22 +2039,30 @@ func (sf *ServerForm) serversDiffer(a, b domain.Server) bool { valB := reflect.ValueOf(b) typeA := valA.Type() - // Fields to skip during comparison (lazyssh metadata fields) + // Special fields to skip that don't have tags skipFields := map[string]bool{ - "Aliases": true, // Computed field - "LastSeen": true, // Metadata field - "PinnedAt": true, // Metadata field - "SSHCount": true, // Metadata field + "Aliases": true, // Computed field (derived from Host) } // Iterate through all fields for i := 0; i < valA.NumField(); i++ { fieldA := valA.Field(i) fieldB := valB.Field(i) - fieldName := typeA.Field(i).Name + field := typeA.Field(i) + fieldName := field.Name - // Skip unexported fields and metadata fields - if !fieldA.CanInterface() || skipFields[fieldName] { + // Skip unexported fields + if !fieldA.CanInterface() { + continue + } + + // Skip special fields + if skipFields[fieldName] { + continue + } + + // Check for lazyssh struct tags to skip metadata and transient fields + if tag := field.Tag.Get("lazyssh"); tag == "metadata" || tag == "transient" { continue } diff --git a/internal/adapters/ui/server_form_test.go b/internal/adapters/ui/server_form_test.go new file mode 100644 index 0000000..b7b5ffd --- /dev/null +++ b/internal/adapters/ui/server_form_test.go @@ -0,0 +1,133 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "testing" + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +func TestServersDifferIgnoresTransientFields(t *testing.T) { + sf := &ServerForm{} + + // Create two servers with identical config but different transient fields + server1 := domain.Server{ + Alias: "test-server", + Host: "example.com", + User: "testuser", + Port: 22, + PingStatus: "up", + PingLatency: 100 * time.Millisecond, + LastSeen: time.Now(), + PinnedAt: time.Now(), + SSHCount: 5, + } + + server2 := domain.Server{ + Alias: "test-server", + Host: "example.com", + User: "testuser", + Port: 22, + PingStatus: "down", // Different transient field + PingLatency: 200 * time.Millisecond, // Different transient field + LastSeen: time.Now().Add(1 * time.Hour), // Different metadata field + PinnedAt: time.Now().Add(2 * time.Hour), // Different metadata field + SSHCount: 10, // Different metadata field + } + + // Should not detect differences since only transient/metadata fields differ + if sf.serversDiffer(server1, server2) { + t.Error("serversDiffer should ignore transient and metadata fields") + } + + // Now change a real config field + server2.Port = 2222 + + // Should detect the difference now + if !sf.serversDiffer(server1, server2) { + t.Error("serversDiffer should detect differences in non-transient fields") + } +} + +func TestServersDifferDetectsRealChanges(t *testing.T) { + sf := &ServerForm{} + + server1 := domain.Server{ + Alias: "test-server", + Host: "example.com", + User: "testuser", + Port: 22, + } + + testCases := []struct { + name string + modify func(*domain.Server) + expect bool + }{ + { + name: "No changes", + modify: func(s *domain.Server) {}, + expect: false, + }, + { + name: "Changed Host", + modify: func(s *domain.Server) { s.Host = "different.com" }, + expect: true, + }, + { + name: "Changed User", + modify: func(s *domain.Server) { s.User = "otheruser" }, + expect: true, + }, + { + name: "Changed Port", + modify: func(s *domain.Server) { s.Port = 2222 }, + expect: true, + }, + { + name: "Added IdentityFile", + modify: func(s *domain.Server) { s.IdentityFiles = []string{"~/.ssh/id_rsa"} }, + expect: true, + }, + { + name: "Changed ProxyJump", + modify: func(s *domain.Server) { s.ProxyJump = "jumphost" }, + expect: true, + }, + { + name: "Changed only PingStatus (transient)", + modify: func(s *domain.Server) { s.PingStatus = "checking" }, + expect: false, + }, + { + name: "Changed only LastSeen (metadata)", + modify: func(s *domain.Server) { s.LastSeen = time.Now().Add(1 * time.Hour) }, + expect: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server2 := server1 // Copy + tc.modify(&server2) + result := sf.serversDiffer(server1, server2) + if result != tc.expect { + t.Errorf("Expected %v but got %v for test case %s", tc.expect, result, tc.name) + } + }) + } +} diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index b175014..8fc22ab 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -25,6 +25,7 @@ type ServerList struct { servers []domain.Server onSelection func(domain.Server) onSelectionChange func(domain.Server) + currentWidth int } func NewServerList() *ServerList { @@ -55,23 +56,63 @@ func (sl *ServerList) build() { } func (sl *ServerList) UpdateServers(servers []domain.Server) { + // Save current selection before clearing + currentIdx := sl.List.GetCurrentItem() + var currentAlias string + if currentIdx >= 0 && currentIdx < len(sl.servers) { + currentAlias = sl.servers[currentIdx].Alias + } + sl.servers = servers sl.List.Clear() + // Get current width + _, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled + sl.currentWidth = width + + newSelectedIdx := -1 for i := range servers { - primary, secondary := formatServerLine(servers[i]) + primary, secondary := formatServerLine(servers[i], width) idx := i sl.List.AddItem(primary, secondary, 0, func() { if sl.onSelection != nil { sl.onSelection(sl.servers[idx]) } }) + // Track the new index of previously selected server + if currentAlias != "" && servers[i].Alias == currentAlias { + newSelectedIdx = i + } } if sl.List.GetItemCount() > 0 { - sl.List.SetCurrentItem(0) - if sl.onSelectionChange != nil { - sl.onSelectionChange(sl.servers[0]) + // Restore previous selection if found, otherwise keep first item + if newSelectedIdx >= 0 { + sl.List.SetCurrentItem(newSelectedIdx) + if sl.onSelectionChange != nil { + sl.onSelectionChange(sl.servers[newSelectedIdx]) + } + } else { + sl.List.SetCurrentItem(0) + if sl.onSelectionChange != nil { + sl.onSelectionChange(sl.servers[0]) + } + } + } +} + +// RefreshDisplay re-renders the list with current width +func (sl *ServerList) RefreshDisplay() { + _, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled + if width != sl.currentWidth { + sl.currentWidth = width + // Save current selection + currentIdx := sl.List.GetCurrentItem() + // Re-render + sl.UpdateServers(sl.servers) + // Restore selection + if currentIdx >= 0 && currentIdx < sl.List.GetItemCount() { + sl.List.SetCurrentItem(currentIdx) } } } diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index d8ca0aa..2dc9cc8 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -20,7 +20,7 @@ import ( ) func DefaultStatusText() string { - return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" + return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g/G[-] Ping (All) • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" } func NewStatusBar() *tview.TextView { diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 6046071..f633d88 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -18,6 +18,7 @@ import ( "github.com/gdamore/tcell/v2" "go.uber.org/zap" + "github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/ports" "github.com/rivo/tview" ) @@ -48,6 +49,8 @@ type tui struct { sortMode SortMode searchVisible bool + + pingStatuses map[string]domain.Server // stores ping status for each server } func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App { @@ -57,6 +60,7 @@ func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit s serverService: ss, version: version, commit: commit, + pingStatuses: make(map[string]domain.Server), } } @@ -127,6 +131,14 @@ func (t *tui) buildLayout() *tui { func (t *tui) bindEvents() *tui { t.root.SetInputCapture(t.handleGlobalKeys) + + // Handle window resize + t.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + // Refresh server list display on resize + t.serverList.RefreshDisplay() + return false + }) + return t } diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 95a3996..5d5a6b2 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -78,12 +78,102 @@ func pinnedIcon(pinnedAt time.Time) string { return "📌" // pinned } -func formatServerLine(s domain.Server) (primary, secondary string) { +func formatServerLine(s domain.Server, width int) (primary, secondary string) { icon := cellPad(pinnedIcon(s.PinnedAt), 2) - // Use a consistent color for alias; the icon reflects pinning - primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) + + // Build main content + mainText := fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", + icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) + + // Format ping status (4 chars max for value) + pingIndicator := "" + if s.PingStatus != "" { + switch s.PingStatus { + case statusUp: + if s.PingLatency > 0 { + ms := s.PingLatency.Milliseconds() + var statusText string + if ms < 100 { + statusText = fmt.Sprintf("%dms", ms) // e.g., "57ms" + } else { + // Format as #.#s for >= 100ms + seconds := float64(ms) / 1000.0 + statusText = fmt.Sprintf("%.1fs", seconds) // e.g., "0.3s", "1.5s" + } + // Ensure exactly 4 chars + statusText = fmt.Sprintf("%-4s", statusText) + pingIndicator = fmt.Sprintf("[#4AF626]● %s[-]", statusText) + } else { + pingIndicator = "[#4AF626]● UP [-]" + } + case statusDown: + pingIndicator = "[#FF6B6B]● DOWN[-]" + case statusChecking: + pingIndicator = "[#FFB86C]● ... [-]" + } + } + + // Calculate padding for right alignment + if pingIndicator != "" && width > 0 { + // Strip color codes to calculate real length + mainTextLen := len(stripSimpleColors(mainText)) + indicatorLen := 6 // "● XXXX" is always 6 display chars + + // Calculate padding needed + switch { + case width > 80: + // Wide screen: show full indicator + paddingLen := width - mainTextLen - indicatorLen // No margin, stick to right edge + if paddingLen < 1 { + paddingLen = 1 + } + padding := strings.Repeat(" ", paddingLen) + primary = fmt.Sprintf("%s%s%s", mainText, padding, pingIndicator) + case width > 60: + // Medium screen: show only dot + simplePingIndicator := "" + switch s.PingStatus { + case statusUp: + simplePingIndicator = "[#4AF626]●[-]" + case statusDown: + simplePingIndicator = "[#FF6B6B]●[-]" + case statusChecking: + simplePingIndicator = "[#FFB86C]●[-]" + } + paddingLen := width - mainTextLen - 1 // 1 for dot, no margin + if paddingLen < 1 { + paddingLen = 1 + } + padding := strings.Repeat(" ", paddingLen) + primary = fmt.Sprintf("%s%s%s", mainText, padding, simplePingIndicator) + default: + // Narrow screen: no ping indicator + primary = mainText + } + } else { + primary = mainText + } + secondary = "" - return + return primary, secondary +} + +// stripSimpleColors removes basic tview color codes for length calculation +func stripSimpleColors(s string) string { + result := s + // Remove color tags like [#FFFFFF] or [-] + for { + start := strings.Index(result, "[") + if start == -1 { + break + } + end := strings.Index(result[start:], "]") + if end == -1 { + break + } + result = result[:start] + result[start+end+1:] + } + return result } func humanizeDuration(t time.Time) string { diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index c23b301..a4d508c 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -24,9 +24,11 @@ type Server struct { Port int IdentityFiles []string Tags []string - LastSeen time.Time - PinnedAt time.Time - SSHCount int + LastSeen time.Time `lazyssh:"metadata"` + PinnedAt time.Time `lazyssh:"metadata"` + SSHCount int `lazyssh:"metadata"` + PingStatus string `lazyssh:"transient"` // "up", "down", "checking", or "" + PingLatency time.Duration `lazyssh:"transient"` // ping latency // Additional SSH config fields // Connection and proxy settings