mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb5d18f8e | |||
| 44ffa0c31d | |||
| edf61049fc | |||
| 67987e6242 | |||
| e1efef4680 | |||
| 42387eb1fa | |||
| 6577002e2b |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -69,9 +69,9 @@ jobs:
|
||||
mkdir -p dist
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||
else
|
||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||
fi
|
||||
|
||||
- name: Create archive
|
||||
@@ -113,12 +113,22 @@ jobs:
|
||||
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||
ls -la ./release/
|
||||
|
||||
- name: Check if pre-release
|
||||
id: check_prerelease
|
||||
run: |
|
||||
if [[ "${GITHUB_REF#refs/tags/}" == *"-beta"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-alpha"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-rc"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
|
||||
generate_release_notes: true
|
||||
name: ${{ github.ref_name }}${{ steps.check_prerelease.outputs.is_prerelease == 'true' && ' (Pre-release)' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
23
README.md
23
README.md
@@ -243,6 +243,29 @@ sshm --version
|
||||
sshm --help
|
||||
```
|
||||
|
||||
### Backup Configuration
|
||||
|
||||
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
|
||||
|
||||
**Backup Location:**
|
||||
- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set)
|
||||
- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`)
|
||||
|
||||
**Key Features:**
|
||||
- Automatic backup before any modification
|
||||
- One backup per file (overwrites previous backup)
|
||||
- Stored separately to avoid SSH Include conflicts
|
||||
- Easy manual recovery if needed
|
||||
|
||||
**Quick Recovery:**
|
||||
```bash
|
||||
# Unix/Linux/macOS
|
||||
cp ~/.config/sshm/backups/config.backup ~/.ssh/config
|
||||
|
||||
# Windows
|
||||
copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config"
|
||||
```
|
||||
|
||||
### Configuration File Options
|
||||
|
||||
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
||||
|
||||
@@ -2,7 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/ui"
|
||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/ui"
|
||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/ui"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
9
go.mod
9
go.mod
@@ -1,4 +1,4 @@
|
||||
module sshm
|
||||
module github.com/Gu1llaum-3/sshm
|
||||
|
||||
go 1.23.1
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,7 +29,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
||||
16
go.sum
16
go.sum
@@ -49,15 +49,19 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -40,6 +40,36 @@ func GetDefaultSSHConfigPath() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetSSHMBackupDir returns the SSHM backup directory
|
||||
func GetSSHMBackupDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var configDir string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
// Use %APPDATA%/sshm on Windows
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData != "" {
|
||||
configDir = filepath.Join(appData, "sshm")
|
||||
} else {
|
||||
configDir = filepath.Join(homeDir, ".config", "sshm")
|
||||
}
|
||||
default:
|
||||
// Use XDG Base Directory specification
|
||||
xdgConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfigDir != "" {
|
||||
configDir = filepath.Join(xdgConfigDir, "sshm")
|
||||
} else {
|
||||
configDir = filepath.Join(homeDir, ".config", "sshm")
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "backups"), nil
|
||||
}
|
||||
|
||||
// GetSSHDirectory returns the .ssh directory path
|
||||
func GetSSHDirectory() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -67,9 +97,23 @@ func ensureSSHDirectory() error {
|
||||
// configMutex protects SSH config file operations from race conditions
|
||||
var configMutex sync.Mutex
|
||||
|
||||
// backupConfig creates a backup of the SSH config file
|
||||
// backupConfig creates a backup of the SSH config file in ~/.config/sshm/backups/
|
||||
func backupConfig(configPath string) error {
|
||||
backupPath := configPath + ".backup"
|
||||
// Get backup directory and ensure it exists
|
||||
backupDir, err := GetSSHMBackupDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get backup directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
// Create simple backup filename (overwrites previous backup)
|
||||
filename := filepath.Base(configPath)
|
||||
backupPath := filepath.Join(backupDir, filename+".backup")
|
||||
|
||||
// Copy file
|
||||
src, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -82,10 +126,14 @@ func backupConfig(configPath string) error {
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
if _, err = io.Copy(dst, src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set appropriate permissions
|
||||
return os.Chmod(backupPath, 0600)
|
||||
}
|
||||
|
||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||
func ParseSSHConfig() ([]SSHHost, error) {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
|
||||
@@ -452,6 +452,122 @@ Include *.conf
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupConfigToSSHMDirectory(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Override the home directory for this test
|
||||
originalHome := os.Getenv("HOME")
|
||||
if originalHome == "" {
|
||||
originalHome = os.Getenv("USERPROFILE") // Windows
|
||||
}
|
||||
|
||||
// Set test home directory
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create a test SSH config file
|
||||
sshDir := filepath.Join(tempDir, ".ssh")
|
||||
err := os.MkdirAll(sshDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create .ssh directory: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
configContent := `Host test-host
|
||||
HostName test.example.com
|
||||
User testuser
|
||||
`
|
||||
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
// Test backup creation
|
||||
err = backupConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backupConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify backup directory was created
|
||||
backupDir, err := GetSSHMBackupDir()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSSHMBackupDir() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
t.Errorf("Backup directory was not created: %s", backupDir)
|
||||
}
|
||||
|
||||
// Verify backup file was created
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
backupFile := files[0]
|
||||
expectedName := "config.backup"
|
||||
if backupFile.Name() != expectedName {
|
||||
t.Errorf("Backup file has unexpected name: got %s, want %s", backupFile.Name(), expectedName)
|
||||
}
|
||||
|
||||
// Verify backup content
|
||||
backupContent, err := os.ReadFile(filepath.Join(backupDir, backupFile.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup file: %v", err)
|
||||
}
|
||||
|
||||
if string(backupContent) != configContent {
|
||||
t.Errorf("Backup content doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
// Test that subsequent backups overwrite the previous one
|
||||
newConfigContent := `Host test-host-updated
|
||||
HostName updated.example.com
|
||||
User updateduser
|
||||
`
|
||||
|
||||
err = os.WriteFile(configPath, []byte(newConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update config file: %v", err)
|
||||
}
|
||||
|
||||
// Create second backup
|
||||
err = backupConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Second backupConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify still only one backup file exists
|
||||
files, err = os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory after second backup: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected still 1 backup file after overwrite, got %d", len(files))
|
||||
}
|
||||
|
||||
// Verify backup content was updated
|
||||
if len(files) > 0 {
|
||||
backupContent, err := os.ReadFile(filepath.Join(backupDir, files[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read updated backup file: %v", err)
|
||||
}
|
||||
|
||||
if string(backupContent) != newConfigContent {
|
||||
t.Errorf("Updated backup content doesn't match new config content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHostInAllConfigs(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
212
internal/connectivity/ping.go
Normal file
212
internal/connectivity/ping.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PingStatus represents the connectivity status of an SSH host
|
||||
type PingStatus int
|
||||
|
||||
const (
|
||||
StatusUnknown PingStatus = iota
|
||||
StatusConnecting
|
||||
StatusOnline
|
||||
StatusOffline
|
||||
)
|
||||
|
||||
func (s PingStatus) String() string {
|
||||
switch s {
|
||||
case StatusUnknown:
|
||||
return "unknown"
|
||||
case StatusConnecting:
|
||||
return "connecting"
|
||||
case StatusOnline:
|
||||
return "online"
|
||||
case StatusOffline:
|
||||
return "offline"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// HostPingResult represents the result of pinging a host
|
||||
type HostPingResult struct {
|
||||
HostName string
|
||||
Status PingStatus
|
||||
Error error
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// PingManager manages SSH connectivity checks for multiple hosts
|
||||
type PingManager struct {
|
||||
results map[string]*HostPingResult
|
||||
mutex sync.RWMutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewPingManager creates a new ping manager with the specified timeout
|
||||
func NewPingManager(timeout time.Duration) *PingManager {
|
||||
return &PingManager{
|
||||
results: make(map[string]*HostPingResult),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current status for a host
|
||||
func (pm *PingManager) GetStatus(hostName string) PingStatus {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
if result, exists := pm.results[hostName]; exists {
|
||||
return result.Status
|
||||
}
|
||||
return StatusUnknown
|
||||
}
|
||||
|
||||
// GetResult returns the complete result for a host
|
||||
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
result, exists := pm.results[hostName]
|
||||
return result, exists
|
||||
}
|
||||
|
||||
// updateStatus updates the status for a host
|
||||
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
pm.results[hostName] = &HostPingResult{
|
||||
HostName: hostName,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingHost performs an SSH connectivity check for a single host
|
||||
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
|
||||
start := time.Now()
|
||||
|
||||
// Mark as connecting
|
||||
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
|
||||
|
||||
// Determine the actual hostname and port
|
||||
hostname := host.Hostname
|
||||
if hostname == "" {
|
||||
hostname = host.Name
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Try to establish a TCP connection first (faster than SSH handshake)
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
|
||||
if err != nil {
|
||||
duration := time.Since(start)
|
||||
pm.updateStatus(host.Name, StatusOffline, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: StatusOffline,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// If TCP connection succeeds, try SSH handshake
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: host.User,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
|
||||
Timeout: time.Second * 2, // Short timeout for handshake
|
||||
}
|
||||
|
||||
// We don't need to authenticate, just check if SSH is responding
|
||||
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
|
||||
if sshConn != nil {
|
||||
sshConn.Close()
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Even if SSH handshake fails, if we got a TCP connection, consider it online
|
||||
// This handles cases where authentication fails but the host is reachable
|
||||
status := StatusOnline
|
||||
if err != nil && isConnectionError(err) {
|
||||
status = StatusOffline
|
||||
}
|
||||
|
||||
pm.updateStatus(host.Name, status, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingAllHosts pings all hosts concurrently and returns a channel of results
|
||||
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
|
||||
resultChan := make(chan *HostPingResult, len(hosts))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, host := range hosts {
|
||||
wg.Add(1)
|
||||
go func(h config.SSHHost) {
|
||||
defer wg.Done()
|
||||
result := pm.PingHost(ctx, h)
|
||||
select {
|
||||
case resultChan <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
// Close the channel when all goroutines are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
return resultChan
|
||||
}
|
||||
|
||||
// isConnectionError determines if an error is a connection-related error
|
||||
func isConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
connectionErrors := []string{
|
||||
"connection refused",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"timeout",
|
||||
"connection timed out",
|
||||
}
|
||||
|
||||
for _, connErr := range connectionErrors {
|
||||
if strings.Contains(strings.ToLower(errStr), connErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
)
|
||||
|
||||
// ConnectionHistory represents the history of SSH connections
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
@@ -62,6 +62,10 @@ func (m *helpModel) View() string {
|
||||
" ",
|
||||
m.styles.HelpText.Render("switch focus"),
|
||||
" ",
|
||||
m.styles.FocusedLabel.Render("p"),
|
||||
" ",
|
||||
m.styles.HelpText.Render("ping all"),
|
||||
" ",
|
||||
m.styles.FocusedLabel.Render("f"),
|
||||
" ",
|
||||
m.styles.HelpText.Render("port forward"),
|
||||
|
||||
@@ -2,7 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
@@ -73,6 +74,7 @@ type Model struct {
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
historyManager *history.HistoryManager
|
||||
pingManager *connectivity.PingManager
|
||||
sortMode SortMode
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
|
||||
305
internal/ui/search_test.go
Normal file
305
internal/ui/search_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// createTestModel creates a model with test data for testing
|
||||
func createTestModel() Model {
|
||||
hosts := []config.SSHHost{
|
||||
{Name: "server1", Hostname: "server1.example.com", User: "user1"},
|
||||
{Name: "server2", Hostname: "server2.example.com", User: "user2"},
|
||||
{Name: "server3", Hostname: "server3.example.com", User: "user3"},
|
||||
{Name: "web-server", Hostname: "web.example.com", User: "webuser"},
|
||||
{Name: "db-server", Hostname: "db.example.com", User: "dbuser"},
|
||||
}
|
||||
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
filteredHosts: hosts,
|
||||
searchInput: textinput.New(),
|
||||
table: table.New(),
|
||||
searchMode: false,
|
||||
ready: true,
|
||||
width: 80,
|
||||
height: 24,
|
||||
styles: NewStyles(80),
|
||||
}
|
||||
|
||||
// Initialize table with test data
|
||||
m.updateTableColumns()
|
||||
m.updateTableRows()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func TestSearchModeToggle(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Initially should not be in search mode
|
||||
if m.searchMode {
|
||||
t.Error("Model should not start in search mode")
|
||||
}
|
||||
|
||||
// Simulate pressing "/" to enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Should now be in search mode
|
||||
if !m.searchMode {
|
||||
t.Error("Model should be in search mode after pressing '/'")
|
||||
}
|
||||
|
||||
// The search input should be focused
|
||||
if !m.searchInput.Focused() {
|
||||
t.Error("Search input should be focused in search mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFiltering(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "server" in search
|
||||
for _, char := range "server" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should filter to only hosts containing "server"
|
||||
expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"}
|
||||
if len(m.filteredHosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts))
|
||||
}
|
||||
|
||||
// Check that all filtered hosts contain "server"
|
||||
for _, host := range m.filteredHosts {
|
||||
found := false
|
||||
for _, expected := range expectedHosts {
|
||||
if host.Name == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Unexpected host in filtered results: %s", host.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFilteringSpecific(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" in search
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should filter to only hosts containing "web"
|
||||
if len(m.filteredHosts) != 1 {
|
||||
t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts))
|
||||
}
|
||||
|
||||
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" {
|
||||
t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchClearReturnToOriginal(t *testing.T) {
|
||||
m := createTestModel()
|
||||
originalHostCount := len(m.hosts)
|
||||
|
||||
// Enter search mode and type something
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" in search
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should have filtered results
|
||||
if len(m.filteredHosts) >= originalHostCount {
|
||||
t.Error("Search should have filtered down the results")
|
||||
}
|
||||
|
||||
// Clear the search by simulating backspace
|
||||
for i := 0; i < 3; i++ { // "web" is 3 characters
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should return to all hosts
|
||||
if len(m.filteredHosts) != originalHostCount {
|
||||
t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorPositionAfterFiltering(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Move cursor down to position 2 (third item)
|
||||
m.table.SetCursor(2)
|
||||
initialCursor := m.table.Cursor()
|
||||
|
||||
if initialCursor != 2 {
|
||||
t.Errorf("Expected cursor at position 2, got %d", initialCursor)
|
||||
}
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" - this will filter to only 1 result
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Cursor should be reset to 0 since filtered results has only 1 item
|
||||
// and cursor position 2 would be out of bounds
|
||||
if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 {
|
||||
t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabSwitchBetweenSearchAndTable(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode")
|
||||
}
|
||||
|
||||
// Press Tab to switch to table
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.searchMode {
|
||||
t.Error("Should not be in search mode after Tab")
|
||||
}
|
||||
|
||||
// Press Tab again to switch back to search
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode after second Tab")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterExitsSearchMode(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode")
|
||||
}
|
||||
|
||||
// Press Enter to exit search mode
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyEnter}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.searchMode {
|
||||
t.Error("Should not be in search mode after Enter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) {
|
||||
m := createTestModel()
|
||||
originalHostCount := len(m.hosts)
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// At this point, filteredHosts should still be the same as the original hosts
|
||||
// because entering search mode should not trigger filtering with empty input
|
||||
if len(m.filteredHosts) != originalHostCount {
|
||||
t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByHostname(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Search by hostname part "example.com"
|
||||
searchTerm := "example.com"
|
||||
for _, char := range searchTerm {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// All hosts should match since they all have "example.com" in hostname
|
||||
if len(m.filteredHosts) != len(m.hosts) {
|
||||
t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByUser(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Search by user "user1"
|
||||
searchTerm := "user1"
|
||||
for _, char := range searchTerm {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Only server1 should match
|
||||
if len(m.filteredHosts) != 1 {
|
||||
t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts))
|
||||
}
|
||||
|
||||
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" {
|
||||
t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
)
|
||||
|
||||
// sortHosts sorts hosts according to the current sort mode
|
||||
@@ -57,6 +57,12 @@ func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the user
|
||||
if strings.Contains(strings.ToLower(host.User), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
|
||||
@@ -3,12 +3,266 @@ package ui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
@@ -26,10 +28,14 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||
styles := NewStyles(80) // Default width
|
||||
|
||||
// Initialize ping manager with 5 second timeout
|
||||
pingManager := connectivity.NewPingManager(5 * time.Second)
|
||||
|
||||
// Create the model with default sorting by name
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
historyManager: historyManager,
|
||||
pingManager: pingManager,
|
||||
sortMode: SortByName,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
@@ -46,21 +52,15 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hosts or tags..."
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 50
|
||||
ti.Width = 25
|
||||
|
||||
// Calculate optimal width for the Name column
|
||||
nameWidth := calculateNameColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Tags column
|
||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Last Login column
|
||||
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||
// Use dynamic column width calculation (will fallback to static if width not available)
|
||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
||||
|
||||
// Create table columns
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: 25},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
// {Title: "User", Width: 12}, // Commented to save space
|
||||
// {Title: "Port", Width: 6}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
@@ -70,6 +70,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Convert hosts to table rows
|
||||
var rows []table.Row
|
||||
for _, host := range sortedHosts {
|
||||
// Get ping status indicator
|
||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
@@ -90,7 +93,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
statusIndicator + " " + host.Name,
|
||||
host.Hostname,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Messages for SSH ping functionality
|
||||
type (
|
||||
pingResultMsg *connectivity.HostPingResult
|
||||
)
|
||||
|
||||
// startPingAllCmd creates a command to ping all hosts concurrently
|
||||
func (m Model) startPingAllCmd() tea.Cmd {
|
||||
if m.pingManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Batch(
|
||||
// Create individual ping commands for each host
|
||||
func() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, host := range m.hosts {
|
||||
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// listenForPingResultsCmd is no longer needed since we use individual ping commands
|
||||
|
||||
// pingSingleHostCmd creates a command to ping a single host
|
||||
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := pingManager.PingHost(ctx, host)
|
||||
return pingResultMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||
// Ping is now optional - use 'p' key to start ping
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +107,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case pingResultMsg:
|
||||
// Handle ping result - update table display
|
||||
if msg != nil {
|
||||
// Update the table to reflect the new ping status
|
||||
m.updateTableRows()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
@@ -317,6 +364,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
// Don't trigger filtering when entering search mode - wait for user input
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "tab":
|
||||
@@ -334,6 +382,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
// Don't trigger filtering when switching to search mode
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
@@ -396,7 +445,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Connect to the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
|
||||
// Record the connection in history
|
||||
if m.historyManager != nil {
|
||||
@@ -425,7 +474,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Edit the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
@@ -441,7 +490,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Show info for the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
@@ -495,19 +544,24 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Delete the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Ping all hosts
|
||||
return m, m.startPingAllCmd()
|
||||
}
|
||||
case "f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Port forwarding for the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewPortForward
|
||||
return m, textinput.Blink
|
||||
@@ -567,12 +621,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Update filtered hosts only if the search value has changed
|
||||
if m.searchInput.Value() != oldValue {
|
||||
currentCursor := m.table.Cursor()
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
// If the current cursor position is beyond the filtered results, reset to 0
|
||||
if currentCursor >= len(m.filteredHosts) && len(m.filteredHosts) > 0 {
|
||||
m.table.SetCursor(0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -69,3 +70,36 @@ func formatConfigFile(filePath string) string {
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
// getPingStatusIndicator returns a colored circle indicator based on ping status
|
||||
func (m *Model) getPingStatusIndicator(hostName string) string {
|
||||
if m.pingManager == nil {
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
|
||||
status := m.pingManager.GetStatus(hostName)
|
||||
switch status {
|
||||
case connectivity.StatusOnline:
|
||||
return "🟢" // Green circle for online
|
||||
case connectivity.StatusOffline:
|
||||
return "🔴" // Red circle for offline
|
||||
case connectivity.StatusConnecting:
|
||||
return "🟡" // Yellow circle for connecting
|
||||
default:
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostNameFromTableRow extracts the host name from the first column,
|
||||
// removing the ping status indicator
|
||||
func extractHostNameFromTableRow(firstColumn string) string {
|
||||
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
|
||||
// We need to remove the emoji and space to get just the hostname
|
||||
parts := strings.Fields(firstColumn)
|
||||
if len(parts) >= 2 {
|
||||
// Return everything after the first part (the emoji)
|
||||
return strings.Join(parts[1:], " ")
|
||||
}
|
||||
// Fallback: if there's no space, return the whole string
|
||||
return firstColumn
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (m Model) renderListView() string {
|
||||
// Add the help text
|
||||
var helpText string
|
||||
if !m.searchMode {
|
||||
helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit"
|
||||
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit"
|
||||
} else {
|
||||
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user