mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
4 Commits
1.4.0
...
1.5.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d6dfb04c | |||
| 6577002e2b | |||
| 65908256f4 | |||
| be3dcaa1cd |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -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 }}
|
||||
|
||||
7
go.mod
7
go.mod
@@ -28,7 +28,8 @@ 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/crypto v0.41.0 // 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
|
||||
)
|
||||
|
||||
8
go.sum
8
go.sum
@@ -49,15 +49,23 @@ 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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
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/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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=
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
// SSHHost represents an SSH host configuration
|
||||
type SSHHost struct {
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
SourceFile string // Path to the config file where this host is defined
|
||||
}
|
||||
|
||||
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||
@@ -209,9 +210,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
|
||||
}
|
||||
// Create new host
|
||||
currentHost = &SSHHost{
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
SourceFile: absPath, // Track which file this host comes from
|
||||
}
|
||||
// Clear pending tags for next host
|
||||
pendingTags = nil
|
||||
@@ -286,6 +288,16 @@ func processIncludeDirective(pattern string, baseConfigPath string, processedFil
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip backup files created by sshm (*.backup)
|
||||
if strings.HasSuffix(match, ".backup") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip markdown files (*.md)
|
||||
if strings.HasSuffix(match, ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively parse the included file
|
||||
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||
if err != nil {
|
||||
@@ -529,11 +541,7 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
||||
|
||||
// UpdateSSHHost updates an existing SSH host configuration
|
||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||
return UpdateSSHHostV2(oldName, newHost)
|
||||
}
|
||||
|
||||
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
||||
@@ -688,11 +696,7 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
|
||||
|
||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||
func DeleteSSHHost(hostName string) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return DeleteSSHHostFromFile(hostName, configPath)
|
||||
return DeleteSSHHostV2(hostName)
|
||||
}
|
||||
|
||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||
@@ -776,3 +780,115 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
return os.WriteFile(configPath, []byte(newContent), 0600)
|
||||
}
|
||||
|
||||
// FindHostInAllConfigs finds a host in all configuration files and returns the host with its source file
|
||||
func FindHostInAllConfigs(hostName string) (*SSHHost, error) {
|
||||
hosts, err := ParseSSHConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.Name == hostName {
|
||||
return &host, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("host '%s' not found in any configuration file", hostName)
|
||||
}
|
||||
|
||||
// GetAllConfigFiles returns all SSH config files (main + included files)
|
||||
func GetAllConfigFiles() ([]string, error) {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(configPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
|
||||
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
|
||||
if baseConfigPath == "" {
|
||||
// Fallback to default behavior
|
||||
return GetAllConfigFiles()
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(baseConfigPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
} // UpdateSSHHostV2 updates an existing SSH host configuration, searching in all config files
|
||||
func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the host in its source file
|
||||
newHost.SourceFile = existingHost.SourceFile
|
||||
return UpdateSSHHostInFile(oldName, newHost, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
|
||||
func DeleteSSHHostV2(hostName string) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(hostName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the host from its source file
|
||||
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
|
||||
func AddSSHHostWithFileSelection(host SSHHost, targetFile string) error {
|
||||
if targetFile == "" {
|
||||
// Use default file if none specified
|
||||
return AddSSHHost(host)
|
||||
}
|
||||
return AddSSHHostToFile(host, targetFile)
|
||||
}
|
||||
|
||||
// GetIncludedConfigFiles returns a list of config files that can be used for adding hosts
|
||||
func GetIncludedConfigFiles() ([]string, error) {
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out files that don't exist or can't be written to
|
||||
var writableFiles []string
|
||||
mainConfig, err := GetDefaultSSHConfigPath()
|
||||
if err == nil {
|
||||
writableFiles = append(writableFiles, mainConfig)
|
||||
}
|
||||
|
||||
for _, file := range allFiles {
|
||||
if file == mainConfig {
|
||||
continue // Already added
|
||||
}
|
||||
|
||||
// Check if file exists and is writable
|
||||
if info, err := os.Stat(file); err == nil && !info.IsDir() {
|
||||
writableFiles = append(writableFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
return writableFiles, nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestEnsureSSHDirectory(t *testing.T) {
|
||||
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
@@ -90,7 +90,7 @@ Host another-host
|
||||
HostName another.example.com
|
||||
User anotheruser
|
||||
`
|
||||
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
@@ -103,7 +103,7 @@ Host another-host
|
||||
User includeduser
|
||||
Port 2222
|
||||
`
|
||||
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
@@ -122,7 +122,7 @@ Host another-host
|
||||
User subuser
|
||||
IdentityFile ~/.ssh/sub_key
|
||||
`
|
||||
|
||||
|
||||
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
@@ -158,18 +158,30 @@ Host another-host
|
||||
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
case "included-host":
|
||||
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
||||
}
|
||||
if host.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, host.SourceFile)
|
||||
}
|
||||
case "sub-host":
|
||||
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
||||
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
||||
}
|
||||
if host.SourceFile != subConfig {
|
||||
t.Errorf("sub-host SourceFile incorrect: expected=%s, got=%s", subConfig, host.SourceFile)
|
||||
}
|
||||
case "another-host":
|
||||
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("another-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +198,7 @@ Host another-host
|
||||
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
|
||||
// Create config1 that includes config2
|
||||
config1 := filepath.Join(tempDir, "config1")
|
||||
config1Content := `Host host1
|
||||
@@ -194,7 +206,7 @@ func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||
|
||||
Include config2
|
||||
`
|
||||
|
||||
|
||||
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config1: %v", err)
|
||||
@@ -207,7 +219,7 @@ Include config2
|
||||
|
||||
Include config1
|
||||
`
|
||||
|
||||
|
||||
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config2: %v", err)
|
||||
@@ -247,7 +259,7 @@ Include config1
|
||||
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
|
||||
// Create main config file with non-existent include
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
@@ -258,7 +270,7 @@ Include non-existent-file.conf
|
||||
Host another-host
|
||||
HostName another.example.com
|
||||
`
|
||||
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
@@ -288,7 +300,7 @@ Host another-host
|
||||
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
|
||||
// Create config file with wildcard hosts
|
||||
configFile := filepath.Join(tempDir, "config")
|
||||
configContent := `# Wildcard patterns should be ignored
|
||||
@@ -311,7 +323,7 @@ Host another-real-server
|
||||
HostName another.example.com
|
||||
User anotheruser
|
||||
`
|
||||
|
||||
|
||||
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
@@ -365,3 +377,323 @@ Host another-real-server
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigExcludesBackupFiles(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file with include pattern
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include *.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create a regular config file
|
||||
regularConfig := filepath.Join(tempDir, "regular.conf")
|
||||
regularConfigContent := `Host regular-host
|
||||
HostName regular.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(regularConfig, []byte(regularConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create regular config: %v", err)
|
||||
}
|
||||
|
||||
// Create a backup file that should be excluded
|
||||
backupConfig := filepath.Join(tempDir, "regular.conf.backup")
|
||||
backupConfigContent := `Host backup-host
|
||||
HostName backup.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(backupConfig, []byte(backupConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create backup config: %v", err)
|
||||
}
|
||||
|
||||
// Parse the config file
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should only get main-host and regular-host, not backup-host
|
||||
expectedHosts := map[string]bool{
|
||||
"main-host": false,
|
||||
"regular-host": false,
|
||||
}
|
||||
|
||||
if len(hosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||
for _, host := range hosts {
|
||||
t.Logf("Found host: %s", host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if _, expected := expectedHosts[host.Name]; !expected {
|
||||
t.Errorf("Unexpected host found: %s (backup files should be excluded)", host.Name)
|
||||
} else {
|
||||
expectedHosts[host.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check that backup-host was not included
|
||||
for _, host := range hosts {
|
||||
if host.Name == "backup-host" {
|
||||
t.Error("backup-host should not be included (backup files should be excluded)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHostInAllConfigs(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
User includeduser
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Test finding host from main config
|
||||
host, err := GetSSHHostFromFile("main-host", mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSSHHostFromFile() error = %v", err)
|
||||
}
|
||||
if host.Name != "main-host" || host.Hostname != "example.com" {
|
||||
t.Errorf("main-host not found correctly: name=%s, hostname=%s", host.Name, host.Hostname)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
|
||||
// Test finding host from included config
|
||||
// Note: This tests the full parsing with includes
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
var includedHost *SSHHost
|
||||
for _, h := range hosts {
|
||||
if h.Name == "included-host" {
|
||||
includedHost = &h
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if includedHost == nil {
|
||||
t.Fatal("included-host not found")
|
||||
}
|
||||
if includedHost.Hostname != "included.example.com" || includedHost.User != "includeduser" {
|
||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s", includedHost.Hostname, includedHost.User)
|
||||
}
|
||||
if includedHost.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, includedHost.SourceFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFiles(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
err = os.WriteFile(includedConfig, []byte("Host included-host\n HostName included.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
subConfig := filepath.Join(subDir, "sub.conf")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Parse to populate the processed files map
|
||||
_, err = ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Note: GetAllConfigFiles() uses a fresh parse, so we test it indirectly
|
||||
// by checking that all files are found during parsing
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that hosts from all files are found
|
||||
sourceFiles := make(map[string]bool)
|
||||
for _, host := range hosts {
|
||||
sourceFiles[host.SourceFile] = true
|
||||
}
|
||||
|
||||
expectedFiles := []string{mainConfig, includedConfig, subConfig}
|
||||
for _, expectedFile := range expectedFiles {
|
||||
if !sourceFiles[expectedFile] {
|
||||
t.Errorf("Expected config file not found in SourceFile: %s", expectedFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFilesFromBase(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
subConfig := filepath.Join(subDir, "sub.conf")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Create an isolated config file that should not be included
|
||||
isolatedConfig := filepath.Join(tempDir, "isolated.conf")
|
||||
err = os.WriteFile(isolatedConfig, []byte("Host isolated-host\n HostName isolated.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create isolated config: %v", err)
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with main config as base
|
||||
files, err := GetAllConfigFilesFromBase(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
// Should find main config, included config, and sub config, but not isolated config
|
||||
expectedFiles := map[string]bool{
|
||||
mainConfig: false,
|
||||
includedConfig: false,
|
||||
subConfig: false,
|
||||
}
|
||||
|
||||
if len(files) != len(expectedFiles) {
|
||||
t.Errorf("Expected %d config files, got %d", len(expectedFiles), len(files))
|
||||
for i, file := range files {
|
||||
t.Logf("Found file %d: %s", i+1, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if _, expected := expectedFiles[file]; expected {
|
||||
expectedFiles[file] = true
|
||||
} else if file == isolatedConfig {
|
||||
t.Errorf("Isolated config file should not be included: %s", file)
|
||||
} else {
|
||||
t.Logf("Unexpected file found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all expected files were found
|
||||
for file, found := range expectedFiles {
|
||||
if !found {
|
||||
t.Errorf("Expected config file not found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with isolated config as base (should only return itself)
|
||||
isolatedFiles, err := GetAllConfigFilesFromBase(isolatedConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
if len(isolatedFiles) != 1 || isolatedFiles[0] != isolatedConfig {
|
||||
t.Errorf("Expected only isolated config file, got: %v", isolatedFiles)
|
||||
}
|
||||
|
||||
// Test with empty base config file path (should fallback to default behavior)
|
||||
defaultFiles, err := GetAllConfigFilesFromBase("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase('') error = %v", err)
|
||||
}
|
||||
|
||||
// Should behave like GetAllConfigFiles()
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFiles() error = %v", err)
|
||||
}
|
||||
|
||||
if len(defaultFiles) != len(allFiles) {
|
||||
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
|
||||
}
|
||||
}
|
||||
|
||||
212
internal/connectivity/ping.go
Normal file
212
internal/connectivity/ping.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"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,6 +7,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
@@ -16,6 +17,7 @@ type editFormModel struct {
|
||||
success bool
|
||||
styles Styles
|
||||
originalName string
|
||||
host *config.SSHHost // Store the original host with SourceFile
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
@@ -102,6 +104,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
originalName: hostName,
|
||||
host: host,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: width,
|
||||
@@ -201,6 +204,24 @@ func (m *editFormModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show source file information
|
||||
if m.host != nil && m.host.SourceFile != "" {
|
||||
b.WriteString("\n") // Ligne d'espace avant Config file
|
||||
|
||||
// Style for "Config file:" label in primary color
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ADD8")). // Primary color
|
||||
Bold(true)
|
||||
|
||||
// Style for the file path in white
|
||||
pathStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
|
||||
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
|
||||
b.WriteString(configInfo)
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
|
||||
162
internal/ui/file_selector.go
Normal file
162
internal/ui/file_selector.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type fileSelectorModel struct {
|
||||
files []string // Chemins absolus des fichiers
|
||||
displayNames []string // Noms d'affichage conviviaux
|
||||
selected int
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
title string
|
||||
}
|
||||
|
||||
type fileSelectorMsg struct {
|
||||
selectedFile string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// NewFileSelector creates a new file selector for choosing config files
|
||||
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
|
||||
files, err := config.GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
|
||||
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
|
||||
var files []string
|
||||
var err error
|
||||
|
||||
if baseConfigFile != "" {
|
||||
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
|
||||
} else {
|
||||
files, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// newFileSelectorFromFiles creates a file selector from a list of files
|
||||
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
|
||||
|
||||
// Convert absolute paths to more user-friendly names
|
||||
var displayNames []string
|
||||
homeDir, _ := config.GetSSHDirectory()
|
||||
|
||||
for _, file := range files {
|
||||
// Check if it's the main config file
|
||||
mainConfig, _ := config.GetDefaultSSHConfigPath()
|
||||
if file == mainConfig {
|
||||
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
|
||||
} else {
|
||||
// Try to make path relative to home/.ssh/
|
||||
if strings.HasPrefix(file, homeDir) {
|
||||
relPath, err := filepath.Rel(homeDir, file)
|
||||
if err == nil {
|
||||
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &fileSelectorModel{
|
||||
files: files,
|
||||
displayNames: displayNames,
|
||||
selected: 0,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
title: title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{cancelled: true}
|
||||
}
|
||||
|
||||
case "enter":
|
||||
selectedFile := ""
|
||||
if m.selected < len(m.files) {
|
||||
selectedFile = m.files[m.selected]
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{selectedFile: selectedFile}
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.selected < len(m.files)-1 {
|
||||
m.selected++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render(m.title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.files) == 0 {
|
||||
b.WriteString(m.styles.Error.Render("No SSH config files found."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i, displayName := range m.displayNames {
|
||||
if i == m.selected {
|
||||
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %s", displayName))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -91,6 +91,7 @@ func (m *infoFormModel) View() string {
|
||||
value string
|
||||
}{
|
||||
{"Host Name", m.host.Name},
|
||||
{"Config File", formatConfigFile(m.host.SourceFile)},
|
||||
{"Hostname/IP", m.host.Hostname},
|
||||
{"User", formatOptionalValue(m.host.User)},
|
||||
{"Port", formatOptionalValue(m.host.Port)},
|
||||
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/connectivity"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
@@ -38,6 +39,7 @@ const (
|
||||
ViewInfo
|
||||
ViewPortForward
|
||||
ViewHelp
|
||||
ViewFileSelector
|
||||
)
|
||||
|
||||
// PortForwardType defines the type of port forwarding
|
||||
@@ -72,16 +74,18 @@ type Model struct {
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
historyManager *history.HistoryManager
|
||||
pingManager *connectivity.PingManager
|
||||
sortMode SortMode
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
infoForm *infoFormModel
|
||||
portForwardForm *portForwardModel
|
||||
helpForm *helpModel
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
infoForm *infoFormModel
|
||||
portForwardForm *portForwardModel
|
||||
helpForm *helpModel
|
||||
fileSelectorForm *fileSelectorModel
|
||||
|
||||
// Terminal size and styles
|
||||
width int
|
||||
|
||||
@@ -9,6 +9,260 @@ import (
|
||||
"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,8 +3,10 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/connectivity"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
@@ -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,
|
||||
@@ -48,19 +54,13 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 50
|
||||
|
||||
// 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"
|
||||
"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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +100,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.helpForm.height = m.height
|
||||
m.helpForm.styles = m.styles
|
||||
}
|
||||
if m.fileSelectorForm != nil {
|
||||
m.fileSelectorForm.width = m.width
|
||||
m.fileSelectorForm.height = m.height
|
||||
m.fileSelectorForm.styles = m.styles
|
||||
}
|
||||
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:
|
||||
@@ -158,6 +210,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case fileSelectorMsg:
|
||||
if msg.cancelled {
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.fileSelectorForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// File selected: proceed to add form with selected file
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
||||
m.viewMode = ViewAdd
|
||||
m.fileSelectorForm = nil
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case infoFormEditMsg:
|
||||
// Switch from info to edit mode
|
||||
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||
@@ -257,6 +324,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.helpForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
var newForm *fileSelectorModel
|
||||
newForm, cmd = m.fileSelectorForm.Update(msg)
|
||||
m.fileSelectorForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewList:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
@@ -369,7 +443,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 {
|
||||
@@ -398,7 +472,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
|
||||
@@ -414,7 +488,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
|
||||
@@ -427,9 +501,40 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add a new host
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
// Check if there are multiple config files starting from the current base config
|
||||
var configFiles []string
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
// Use the specified config file as base
|
||||
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
||||
} else {
|
||||
// Use the default config file as base
|
||||
configFiles, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil || len(configFiles) <= 1 {
|
||||
// Only one config file (or error), go directly to add form
|
||||
var configFile string
|
||||
if len(configFiles) == 1 {
|
||||
configFile = configFiles[0]
|
||||
} else {
|
||||
configFile = m.configFile
|
||||
}
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
// Multiple config files, show file selector
|
||||
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Fallback to default behavior if file selector fails
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
m.fileSelectorForm = fileSelectorForm
|
||||
m.viewMode = ViewFileSelector
|
||||
}
|
||||
}
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "d":
|
||||
@@ -437,19 +542,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
|
||||
|
||||
@@ -2,6 +2,8 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/connectivity"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -55,3 +57,49 @@ func formatTimeAgo(t time.Time) string {
|
||||
return fmt.Sprintf("%d years ago", years)
|
||||
}
|
||||
}
|
||||
|
||||
// formatConfigFile formats a config file path for display
|
||||
func formatConfigFile(filePath string) string {
|
||||
if filePath == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
// Show just the filename and parent directory for readability
|
||||
parts := strings.Split(filePath, "/")
|
||||
if len(parts) >= 2 {
|
||||
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ func (m Model) View() string {
|
||||
if m.helpForm != nil {
|
||||
return m.helpForm.View()
|
||||
}
|
||||
case ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
return m.fileSelectorForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
@@ -70,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