Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
122 changes: 114 additions & 8 deletions internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
})
}()
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/ui/hint_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
24 changes: 16 additions & 8 deletions internal/adapters/ui/server_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
133 changes: 133 additions & 0 deletions internal/adapters/ui/server_form_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading