mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
6 Commits
1.5.0-beta
...
1.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb5d18f8e | |||
| 44ffa0c31d | |||
| edf61049fc | |||
| 67987e6242 | |||
| e1efef4680 | |||
| 42387eb1fa |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -69,9 +69,9 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
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
|
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
|
fi
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -243,6 +243,29 @@ sshm --version
|
|||||||
sshm --help
|
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
|
### 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:
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sshm/internal/ui"
|
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sshm/internal/ui"
|
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/ui"
|
"github.com/Gu1llaum-3/sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module sshm
|
module github.com/Gu1llaum-3/sshm
|
||||||
|
|
||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.6
|
github.com/charmbracelet/bubbletea v1.3.6
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -28,7 +29,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/crypto v0.41.0 // indirect
|
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -53,18 +53,14 @@ 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/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 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.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.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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -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
|
// GetSSHDirectory returns the .ssh directory path
|
||||||
func GetSSHDirectory() (string, error) {
|
func GetSSHDirectory() (string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -67,9 +97,23 @@ func ensureSSHDirectory() error {
|
|||||||
// configMutex protects SSH config file operations from race conditions
|
// configMutex protects SSH config file operations from race conditions
|
||||||
var configMutex sync.Mutex
|
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 {
|
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)
|
src, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -82,8 +126,12 @@ func backupConfig(configPath string) error {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
|
|
||||||
_, err = io.Copy(dst, src)
|
if _, err = io.Copy(dst, src); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate permissions
|
||||||
|
return os.Chmod(backupPath, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||||
|
|||||||
@@ -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) {
|
func TestFindHostInAllConfigs(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package connectivity
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionHistory represents the history of SSH connections
|
// ConnectionHistory represents the history of SSH connections
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/validation"
|
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/validation"
|
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/connectivity"
|
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||||
"sshm/internal/history"
|
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
|||||||
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"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sortHosts sorts hosts according to the current sort mode
|
// sortHosts sorts hosts according to the current sort mode
|
||||||
@@ -57,6 +57,12 @@ func (m Model) filterHosts(query string) []config.SSHHost {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check the user
|
||||||
|
if strings.Contains(strings.ToLower(host.User), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check the tags
|
// Check the tags
|
||||||
for _, tag := range host.Tags {
|
for _, tag := range host.Tags {
|
||||||
if strings.Contains(strings.ToLower(tag), query) {
|
if strings.Contains(strings.ToLower(tag), query) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/history"
|
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/connectivity"
|
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||||
"sshm/internal/history"
|
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -52,7 +52,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Search hosts or tags..."
|
ti.Placeholder = "Search hosts or tags..."
|
||||||
ti.CharLimit = 50
|
ti.CharLimit = 50
|
||||||
ti.Width = 50
|
ti.Width = 25
|
||||||
|
|
||||||
// Use dynamic column width calculation (will fallback to static if width not available)
|
// Use dynamic column width calculation (will fallback to static if width not available)
|
||||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||||
"sshm/internal/connectivity"
|
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -364,6 +364,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.updateTableStyles()
|
m.updateTableStyles()
|
||||||
m.table.Blur()
|
m.table.Blur()
|
||||||
m.searchInput.Focus()
|
m.searchInput.Focus()
|
||||||
|
// Don't trigger filtering when entering search mode - wait for user input
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
@@ -381,6 +382,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.updateTableStyles()
|
m.updateTableStyles()
|
||||||
m.table.Blur()
|
m.table.Blur()
|
||||||
m.searchInput.Focus()
|
m.searchInput.Focus()
|
||||||
|
// Don't trigger filtering when switching to search mode
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -619,12 +621,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
// Update filtered hosts only if the search value has changed
|
// Update filtered hosts only if the search value has changed
|
||||||
if m.searchInput.Value() != oldValue {
|
if m.searchInput.Value() != oldValue {
|
||||||
|
currentCursor := m.table.Cursor()
|
||||||
if m.searchInput.Value() != "" {
|
if m.searchInput.Value() != "" {
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
} else {
|
} else {
|
||||||
m.filteredHosts = m.sortHosts(m.hosts)
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
}
|
}
|
||||||
m.updateTableRows()
|
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 {
|
} else {
|
||||||
m.table, cmd = m.table.Update(msg)
|
m.table, cmd = m.table.Update(msg)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sshm/internal/connectivity"
|
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user