mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3dcaa1cd |
@@ -13,14 +13,15 @@ import (
|
|||||||
|
|
||||||
// SSHHost represents an SSH host configuration
|
// SSHHost represents an SSH host configuration
|
||||||
type SSHHost struct {
|
type SSHHost struct {
|
||||||
Name string
|
Name string
|
||||||
Hostname string
|
Hostname string
|
||||||
User string
|
User string
|
||||||
Port string
|
Port string
|
||||||
Identity string
|
Identity string
|
||||||
ProxyJump string
|
ProxyJump string
|
||||||
Options string
|
Options string
|
||||||
Tags []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
|
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||||
@@ -209,9 +210,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
|
|||||||
}
|
}
|
||||||
// Create new host
|
// Create new host
|
||||||
currentHost = &SSHHost{
|
currentHost = &SSHHost{
|
||||||
Name: value,
|
Name: value,
|
||||||
Port: "22", // Default port
|
Port: "22", // Default port
|
||||||
Tags: pendingTags, // Assign pending tags to this host
|
Tags: pendingTags, // Assign pending tags to this host
|
||||||
|
SourceFile: absPath, // Track which file this host comes from
|
||||||
}
|
}
|
||||||
// Clear pending tags for next host
|
// Clear pending tags for next host
|
||||||
pendingTags = nil
|
pendingTags = nil
|
||||||
@@ -286,6 +288,16 @@ func processIncludeDirective(pattern string, baseConfigPath string, processedFil
|
|||||||
continue
|
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
|
// Recursively parse the included file
|
||||||
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -529,11 +541,7 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
|||||||
|
|
||||||
// UpdateSSHHost updates an existing SSH host configuration
|
// UpdateSSHHost updates an existing SSH host configuration
|
||||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||||
configPath, err := GetDefaultSSHConfigPath()
|
return UpdateSSHHostV2(oldName, newHost)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
// 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
|
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||||
func DeleteSSHHost(hostName string) error {
|
func DeleteSSHHost(hostName string) error {
|
||||||
configPath, err := GetDefaultSSHConfigPath()
|
return DeleteSSHHostV2(hostName)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return DeleteSSHHostFromFile(hostName, configPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
// 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")
|
newContent := strings.Join(newLines, "\n")
|
||||||
return os.WriteFile(configPath, []byte(newContent), 0600)
|
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) {
|
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create main config file
|
// Create main config file
|
||||||
mainConfig := filepath.Join(tempDir, "config")
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
mainConfigContent := `Host main-host
|
mainConfigContent := `Host main-host
|
||||||
@@ -90,7 +90,7 @@ Host another-host
|
|||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
User anotheruser
|
User anotheruser
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
@@ -103,7 +103,7 @@ Host another-host
|
|||||||
User includeduser
|
User includeduser
|
||||||
Port 2222
|
Port 2222
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create included config: %v", err)
|
t.Fatalf("Failed to create included config: %v", err)
|
||||||
@@ -122,7 +122,7 @@ Host another-host
|
|||||||
User subuser
|
User subuser
|
||||||
IdentityFile ~/.ssh/sub_key
|
IdentityFile ~/.ssh/sub_key
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create sub config: %v", err)
|
t.Fatalf("Failed to create sub config: %v", err)
|
||||||
@@ -158,18 +158,30 @@ Host another-host
|
|||||||
if host.Hostname != "example.com" || host.User != "mainuser" {
|
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||||
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
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":
|
case "included-host":
|
||||||
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
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)
|
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":
|
case "sub-host":
|
||||||
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
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)
|
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":
|
case "another-host":
|
||||||
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
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) {
|
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create config1 that includes config2
|
// Create config1 that includes config2
|
||||||
config1 := filepath.Join(tempDir, "config1")
|
config1 := filepath.Join(tempDir, "config1")
|
||||||
config1Content := `Host host1
|
config1Content := `Host host1
|
||||||
@@ -194,7 +206,7 @@ func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
|||||||
|
|
||||||
Include config2
|
Include config2
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config1: %v", err)
|
t.Fatalf("Failed to create config1: %v", err)
|
||||||
@@ -207,7 +219,7 @@ Include config2
|
|||||||
|
|
||||||
Include config1
|
Include config1
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config2: %v", err)
|
t.Fatalf("Failed to create config2: %v", err)
|
||||||
@@ -247,7 +259,7 @@ Include config1
|
|||||||
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create main config file with non-existent include
|
// Create main config file with non-existent include
|
||||||
mainConfig := filepath.Join(tempDir, "config")
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
mainConfigContent := `Host main-host
|
mainConfigContent := `Host main-host
|
||||||
@@ -258,7 +270,7 @@ Include non-existent-file.conf
|
|||||||
Host another-host
|
Host another-host
|
||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
@@ -288,7 +300,7 @@ Host another-host
|
|||||||
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create config file with wildcard hosts
|
// Create config file with wildcard hosts
|
||||||
configFile := filepath.Join(tempDir, "config")
|
configFile := filepath.Join(tempDir, "config")
|
||||||
configContent := `# Wildcard patterns should be ignored
|
configContent := `# Wildcard patterns should be ignored
|
||||||
@@ -311,7 +323,7 @@ Host another-real-server
|
|||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
User anotheruser
|
User anotheruser
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config: %v", err)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type editFormModel struct {
|
type editFormModel struct {
|
||||||
@@ -16,6 +17,7 @@ type editFormModel struct {
|
|||||||
success bool
|
success bool
|
||||||
styles Styles
|
styles Styles
|
||||||
originalName string
|
originalName string
|
||||||
|
host *config.SSHHost // Store the original host with SourceFile
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
configFile string
|
configFile string
|
||||||
@@ -102,6 +104,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
|
|||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
originalName: hostName,
|
originalName: hostName,
|
||||||
|
host: host,
|
||||||
configFile: configFile,
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
width: width,
|
width: width,
|
||||||
@@ -201,6 +204,24 @@ func (m *editFormModel) View() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
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")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
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()
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ func (m *infoFormModel) View() string {
|
|||||||
value string
|
value string
|
||||||
}{
|
}{
|
||||||
{"Host Name", m.host.Name},
|
{"Host Name", m.host.Name},
|
||||||
|
{"Config File", formatConfigFile(m.host.SourceFile)},
|
||||||
{"Hostname/IP", m.host.Hostname},
|
{"Hostname/IP", m.host.Hostname},
|
||||||
{"User", formatOptionalValue(m.host.User)},
|
{"User", formatOptionalValue(m.host.User)},
|
||||||
{"Port", formatOptionalValue(m.host.Port)},
|
{"Port", formatOptionalValue(m.host.Port)},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const (
|
|||||||
ViewInfo
|
ViewInfo
|
||||||
ViewPortForward
|
ViewPortForward
|
||||||
ViewHelp
|
ViewHelp
|
||||||
|
ViewFileSelector
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortForwardType defines the type of port forwarding
|
// PortForwardType defines the type of port forwarding
|
||||||
@@ -76,12 +77,13 @@ type Model struct {
|
|||||||
configFile string // Path to the SSH config file
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
// View management
|
// View management
|
||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addForm *addFormModel
|
addForm *addFormModel
|
||||||
editForm *editFormModel
|
editForm *editFormModel
|
||||||
infoForm *infoFormModel
|
infoForm *infoFormModel
|
||||||
portForwardForm *portForwardModel
|
portForwardForm *portForwardModel
|
||||||
helpForm *helpModel
|
helpForm *helpModel
|
||||||
|
fileSelectorForm *fileSelectorModel
|
||||||
|
|
||||||
// Terminal size and styles
|
// Terminal size and styles
|
||||||
width int
|
width int
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.helpForm.height = m.height
|
m.helpForm.height = m.height
|
||||||
m.helpForm.styles = m.styles
|
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
|
return m, nil
|
||||||
|
|
||||||
case addFormSubmitMsg:
|
case addFormSubmitMsg:
|
||||||
@@ -158,6 +163,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
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:
|
case infoFormEditMsg:
|
||||||
// Switch from info to edit mode
|
// Switch from info to edit mode
|
||||||
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
@@ -257,6 +277,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.helpForm = newForm
|
m.helpForm = newForm
|
||||||
return m, cmd
|
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:
|
case ViewList:
|
||||||
// Handle list view keys
|
// Handle list view keys
|
||||||
return m.handleListViewKeys(msg)
|
return m.handleListViewKeys(msg)
|
||||||
@@ -427,9 +454,40 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Add a new host
|
// Check if there are multiple config files starting from the current base config
|
||||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
var configFiles []string
|
||||||
m.viewMode = ViewAdd
|
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
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
case "d":
|
case "d":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,3 +56,16 @@ func formatTimeAgo(t time.Time) string {
|
|||||||
return fmt.Sprintf("%d years ago", years)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func (m Model) View() string {
|
|||||||
if m.helpForm != nil {
|
if m.helpForm != nil {
|
||||||
return m.helpForm.View()
|
return m.helpForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewFileSelector:
|
||||||
|
if m.fileSelectorForm != nil {
|
||||||
|
return m.fileSelectorForm.View()
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
return m.renderListView()
|
return m.renderListView()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user