diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 2932f5c..cd61757 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -22,6 +22,9 @@ type SSHHost struct { Options string Tags []string SourceFile string // Path to the config file where this host is defined + + // Temporary field to handle multiple aliases during parsing + aliasNames []string `json:"-"` // Do not serialize this field } // GetDefaultSSHConfigPath returns the default SSH config path for the current platform @@ -258,20 +261,49 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ // New host, save previous one if it exists if currentHost != nil { hosts = append(hosts, *currentHost) + + // Handle aliases: create duplicate hosts for each alias + if len(currentHost.aliasNames) > 0 { + for _, aliasName := range currentHost.aliasNames { + aliasHost := *currentHost // Copy the host + aliasHost.Name = aliasName + aliasHost.aliasNames = nil // Clear temporary field + hosts = append(hosts, aliasHost) + } + } } + + // Parse multiple host names from the Host line + hostNames := strings.Fields(value) + // Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts - if strings.ContainsAny(value, "*?") { + var validHostNames []string + for _, hostName := range hostNames { + if !strings.ContainsAny(hostName, "*?") { + validHostNames = append(validHostNames, hostName) + } + } + + if len(validHostNames) == 0 { currentHost = nil pendingTags = nil continue } - // Create new host + + // For multiple hosts, we create the first one normally + // and will duplicate it for others after parsing the block currentHost = &SSHHost{ - Name: value, - Port: "22", // Default port - Tags: pendingTags, // Assign pending tags to this host - SourceFile: absPath, // Track which file this host comes from + Name: validHostNames[0], // First name as reference + Port: "22", // Default port + Tags: pendingTags, // Assign pending tags to this host + SourceFile: absPath, // Track which file this host comes from } + + // Store additional host names for later processing + if len(validHostNames) > 1 { + currentHost.aliasNames = validHostNames[1:] + } + // Clear pending tags for next host pendingTags = nil case "hostname": @@ -310,6 +342,18 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ // Add the last host if it exists if currentHost != nil { hosts = append(hosts, *currentHost) + + // Handle aliases: create duplicate hosts for each alias + if len(currentHost.aliasNames) > 0 { + for _, aliasName := range currentHost.aliasNames { + aliasHost := *currentHost // Copy the host + aliasHost.Name = aliasName + aliasHost.aliasNames = nil // Clear temporary field + hosts = append(hosts, aliasHost) + } + } + // Clear the temporary field from the original + currentHost.aliasNames = nil } return hosts, scanner.Err() @@ -624,6 +668,37 @@ func UpdateSSHHost(oldName string, newHost SSHHost) error { return UpdateSSHHostV2(oldName, newHost) } +// IsPartOfMultiHostDeclaration checks if a host is part of a multi-host declaration +func IsPartOfMultiHostDeclaration(hostName string, configPath string) (bool, []string, error) { + content, err := os.ReadFile(configPath) + if err != nil { + return false, nil, err + } + + scanner := bufio.NewScanner(strings.NewReader(string(content))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(strings.ToLower(line), "host ") { + // Extract host names (can be multiple hosts on one line) + hostPart := strings.TrimSpace(line[5:]) // Remove "host " + hostNames := strings.Fields(hostPart) + + // Check if our target host is in this Host declaration + for _, name := range hostNames { + if name == hostName { + if len(hostNames) > 1 { + return true, hostNames, nil + } + return false, hostNames, nil + } + } + } + } + + return false, nil, scanner.Err() +} + // UpdateSSHHostInFile updates an existing SSH host configuration in a specific file func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) error { configMutex.Lock() @@ -634,6 +709,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err return fmt.Errorf("failed to create backup: %w", err) } + // Check if this host is part of a multi-host declaration + isMultiHost, hostNames, err := IsPartOfMultiHostDeclaration(oldName, configPath) + if err != nil { + return fmt.Errorf("failed to check multi-host declaration: %w", err) + } + // Read the current config content, err := os.ReadFile(configPath) if err != nil { @@ -651,113 +732,271 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err // Check for tags comment followed by Host if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { nextLine := strings.TrimSpace(lines[i+1]) - if nextLine == "Host "+oldName { - // Found the host to update, skip the old configuration - hostFound = true - // Skip until we find the end of this host block (empty line or next Host) - i += 2 // Skip tags and Host line - for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { - i++ - } + // Check if this is a Host line that contains our target host + if strings.HasPrefix(nextLine, "Host ") { + hostPart := strings.TrimSpace(nextLine[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) - // Skip any trailing empty lines after the host block - for i < len(lines) && strings.TrimSpace(lines[i]) == "" { - i++ - } - - // Insert new configuration at this position - // Add empty line only if the previous line is not empty - if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { - newLines = append(newLines, "") - } - if len(newHost.Tags) > 0 { - newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) - } - newLines = append(newLines, "Host "+newHost.Name) - newLines = append(newLines, " HostName "+newHost.Hostname) - if newHost.User != "" { - newLines = append(newLines, " User "+newHost.User) - } - if newHost.Port != "" && newHost.Port != "22" { - newLines = append(newLines, " Port "+newHost.Port) - } - if newHost.Identity != "" { - newLines = append(newLines, " IdentityFile "+newHost.Identity) - } - if newHost.ProxyJump != "" { - newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) - } - // Write SSH options - if newHost.Options != "" { - options := strings.Split(newHost.Options, "\n") - for _, option := range options { - option = strings.TrimSpace(option) - if option != "" { - newLines = append(newLines, " "+option) - } + // Check if our target host is in this Host declaration + targetHostIndex := -1 + for idx, hostName := range foundHostNames { + if hostName == oldName { + targetHostIndex = idx + break } } - // Add empty line after the host configuration for separation - newLines = append(newLines, "") + if targetHostIndex != -1 { + hostFound = true - continue + if isMultiHost && len(hostNames) > 1 { + // Strategy: Remove old host from the line, add new host as separate entry + // Remove the old host name from the Host line + var remainingHosts []string + for idx, hostName := range foundHostNames { + if idx != targetHostIndex { + remainingHosts = append(remainingHosts, hostName) + } + } + + // Keep the tags comment + newLines = append(newLines, lines[i]) + + // Update the Host line with remaining hosts + if len(remainingHosts) > 0 { + newLines = append(newLines, "Host "+strings.Join(remainingHosts, " ")) + + // Copy the existing configuration for remaining hosts + i += 2 // Skip tags and original Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + newLines = append(newLines, lines[i]) + i++ + } + } else { + // No remaining hosts, skip the entire block + i += 2 // Skip tags and Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + } + + // Add the new host as a separate entry + newLines = append(newLines, "") + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + newLines = append(newLines, "") + + continue + } else { + // Simple case: only one host, replace entire block + // Skip until we find the end of this host block (empty line or next Host) + i += 2 // Skip tags and Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new configuration at this position + // Add empty line only if the previous line is not empty + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the host configuration for separation + newLines = append(newLines, "") + + continue + } + } } } // Check for Host line without tags - if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == oldName { - hostFound = true + if strings.HasPrefix(line, "Host ") { + hostPart := strings.TrimSpace(line[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) - // Skip until we find the end of this host block - i++ // Skip Host line - for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { - i++ - } - - // Skip any trailing empty lines after the host block - for i < len(lines) && strings.TrimSpace(lines[i]) == "" { - i++ - } - - // Insert new configuration - // Add empty line only if the previous line is not empty - if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { - newLines = append(newLines, "") - } - if len(newHost.Tags) > 0 { - newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) - } - newLines = append(newLines, "Host "+newHost.Name) - newLines = append(newLines, " HostName "+newHost.Hostname) - if newHost.User != "" { - newLines = append(newLines, " User "+newHost.User) - } - if newHost.Port != "" && newHost.Port != "22" { - newLines = append(newLines, " Port "+newHost.Port) - } - if newHost.Identity != "" { - newLines = append(newLines, " IdentityFile "+newHost.Identity) - } - if newHost.ProxyJump != "" { - newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) - } - // Write SSH options - if newHost.Options != "" { - options := strings.Split(newHost.Options, "\n") - for _, option := range options { - option = strings.TrimSpace(option) - if option != "" { - newLines = append(newLines, " "+option) - } + // Check if our target host is in this Host declaration + targetHostIndex := -1 + for idx, hostName := range foundHostNames { + if hostName == oldName { + targetHostIndex = idx + break } } - // Add empty line after the host configuration for separation - newLines = append(newLines, "") + if targetHostIndex != -1 { + hostFound = true - continue + if isMultiHost && len(hostNames) > 1 { + // Strategy: Remove old host from the line, add new host as separate entry + // Remove the old host name from the Host line + var remainingHosts []string + for idx, hostName := range foundHostNames { + if idx != targetHostIndex { + remainingHosts = append(remainingHosts, hostName) + } + } + + // Update the Host line with remaining hosts + if len(remainingHosts) > 0 { + newLines = append(newLines, "Host "+strings.Join(remainingHosts, " ")) + + // Copy the existing configuration for remaining hosts + i++ // Skip original Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + newLines = append(newLines, lines[i]) + i++ + } + } else { + // No remaining hosts, skip the entire block + i++ // Skip Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + } + + // Add the new host as a separate entry + newLines = append(newLines, "") + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + newLines = append(newLines, "") + + continue + } else { + // Simple case: only one host, replace entire block + // Skip until we find the end of this host block + i++ // Skip Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new configuration + // Add empty line only if the previous line is not empty + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + if len(newHost.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) + } + newLines = append(newLines, "Host "+newHost.Name) + newLines = append(newLines, " HostName "+newHost.Hostname) + if newHost.User != "" { + newLines = append(newLines, " User "+newHost.User) + } + if newHost.Port != "" && newHost.Port != "22" { + newLines = append(newLines, " Port "+newHost.Port) + } + if newHost.Identity != "" { + newLines = append(newLines, " IdentityFile "+newHost.Identity) + } + if newHost.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) + } + // Write SSH options + if newHost.Options != "" { + options := strings.Split(newHost.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the host configuration for separation + newLines = append(newLines, "") + + continue + } + } } // Keep other lines as-is @@ -789,6 +1028,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { return fmt.Errorf("failed to create backup: %w", err) } + // Check if this host is part of a multi-host declaration + isMultiHost, hostNames, err := IsPartOfMultiHostDeclaration(hostName, configPath) + if err != nil { + return fmt.Errorf("failed to check multi-host declaration: %w", err) + } + // Read the current config content, err := os.ReadFile(configPath) if err != nil { @@ -806,45 +1051,149 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { // Check for tags comment followed by Host if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { nextLine := strings.TrimSpace(lines[i+1]) - if nextLine == "Host "+hostName { - // Found the host to delete, skip the configuration - hostFound = true - // Skip tags comment and Host line - i += 2 + // Check if this is a Host line that contains our target host + if strings.HasPrefix(nextLine, "Host ") { + hostPart := strings.TrimSpace(nextLine[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) - // Skip until we find the end of this host block (empty line or next Host) - for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { - i++ + // Check if our target host is in this Host declaration + targetHostIndex := -1 + for idx, host := range foundHostNames { + if host == hostName { + targetHostIndex = idx + break + } } - // Skip any trailing empty lines after the host block - for i < len(lines) && strings.TrimSpace(lines[i]) == "" { - i++ - } + if targetHostIndex != -1 { + hostFound = true - continue + if isMultiHost && len(hostNames) > 1 { + // Remove the target host from the multi-host line + var remainingHosts []string + for idx, host := range foundHostNames { + if idx != targetHostIndex { + remainingHosts = append(remainingHosts, host) + } + } + + // Keep the tags comment + newLines = append(newLines, lines[i]) + + if len(remainingHosts) > 0 { + // Update the Host line with remaining hosts + newLines = append(newLines, "Host "+strings.Join(remainingHosts, " ")) + + // Copy the existing configuration for remaining hosts + i += 2 // Skip tags and original Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + newLines = append(newLines, lines[i]) + i++ + } + } else { + // No remaining hosts, skip the entire block + i += 2 // Skip tags and Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } else { + // Single host or last host in multi-host block, delete entire block + // Skip tags comment and Host line + i += 2 + + // Skip until we find the end of this host block (empty line or next Host) + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } + } } } // Check for Host line without tags - if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == hostName { - hostFound = true + if strings.HasPrefix(line, "Host ") { + hostPart := strings.TrimSpace(line[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) - // Skip Host line - i++ - - // Skip until we find the end of this host block - for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { - i++ + // Check if our target host is in this Host declaration + targetHostIndex := -1 + for idx, host := range foundHostNames { + if host == hostName { + targetHostIndex = idx + break + } } - // Skip any trailing empty lines after the host block - for i < len(lines) && strings.TrimSpace(lines[i]) == "" { - i++ - } + if targetHostIndex != -1 { + hostFound = true - continue + if isMultiHost && len(hostNames) > 1 { + // Remove the target host from the multi-host line + var remainingHosts []string + for idx, host := range foundHostNames { + if idx != targetHostIndex { + remainingHosts = append(remainingHosts, host) + } + } + + if len(remainingHosts) > 0 { + // Update the Host line with remaining hosts + newLines = append(newLines, "Host "+strings.Join(remainingHosts, " ")) + + // Copy the existing configuration for remaining hosts + i++ // Skip original Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + newLines = append(newLines, lines[i]) + i++ + } + } else { + // No remaining hosts, skip the entire block + i++ // Skip Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } else { + // Single host, delete entire block + // Skip Host line + i++ + + // Skip until we find the end of this host block + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + continue + } + } } // Keep other lines as-is @@ -1036,3 +1385,204 @@ func GetConfigFilesExcludingCurrent(hostName string, baseConfigFile string) ([]s return filteredFiles, nil } + +// UpdateMultiHostBlock updates a multi-host block configuration +func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSHHost, configPath string) error { + configMutex.Lock() + defer configMutex.Unlock() + + // Create backup before modification + if err := backupConfig(configPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Read the current config + content, err := os.ReadFile(configPath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + var newLines []string + i := 0 + blockFound := false + + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + + // Check for tags comment followed by Host + if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + + // Check if this is a Host line that contains any of our original hosts + if strings.HasPrefix(nextLine, "Host ") { + hostPart := strings.TrimSpace(nextLine[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) + + // Check if any of our original hosts are in this Host declaration + hasOriginalHost := false + for _, origHost := range originalHosts { + for _, foundHost := range foundHostNames { + if foundHost == origHost { + hasOriginalHost = true + break + } + } + if hasOriginalHost { + break + } + } + + if hasOriginalHost { + blockFound = true + + // Skip the old block entirely + i += 2 // Skip tags and Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new multi-host configuration + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + + // Add tags if present + if len(commonProperties.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(commonProperties.Tags, ", ")) + } + + // Add Host line with new host names + newLines = append(newLines, "Host "+strings.Join(newHosts, " ")) + + // Add common properties + newLines = append(newLines, " HostName "+commonProperties.Hostname) + if commonProperties.User != "" { + newLines = append(newLines, " User "+commonProperties.User) + } + if commonProperties.Port != "" && commonProperties.Port != "22" { + newLines = append(newLines, " Port "+commonProperties.Port) + } + if commonProperties.Identity != "" { + newLines = append(newLines, " IdentityFile "+commonProperties.Identity) + } + if commonProperties.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) + } + + // Write SSH options + if commonProperties.Options != "" { + options := strings.Split(commonProperties.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the block + newLines = append(newLines, "") + + continue + } + } + } + + // Check for Host line without tags (same logic) + if strings.HasPrefix(line, "Host ") { + hostPart := strings.TrimSpace(line[5:]) // Remove "Host " + foundHostNames := strings.Fields(hostPart) + + // Check if any of our original hosts are in this Host declaration + hasOriginalHost := false + for _, origHost := range originalHosts { + for _, foundHost := range foundHostNames { + if foundHost == origHost { + hasOriginalHost = true + break + } + } + if hasOriginalHost { + break + } + } + + if hasOriginalHost { + blockFound = true + + // Skip the old block entirely + i++ // Skip Host line + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { + i++ + } + + // Skip any trailing empty lines after the host block + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + + // Insert new multi-host configuration + if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { + newLines = append(newLines, "") + } + + // Add tags if present + if len(commonProperties.Tags) > 0 { + newLines = append(newLines, "# Tags: "+strings.Join(commonProperties.Tags, ", ")) + } + + // Add Host line with new host names + newLines = append(newLines, "Host "+strings.Join(newHosts, " ")) + + // Add common properties + newLines = append(newLines, " HostName "+commonProperties.Hostname) + if commonProperties.User != "" { + newLines = append(newLines, " User "+commonProperties.User) + } + if commonProperties.Port != "" && commonProperties.Port != "22" { + newLines = append(newLines, " Port "+commonProperties.Port) + } + if commonProperties.Identity != "" { + newLines = append(newLines, " IdentityFile "+commonProperties.Identity) + } + if commonProperties.ProxyJump != "" { + newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) + } + + // Write SSH options + if commonProperties.Options != "" { + options := strings.Split(commonProperties.Options, "\n") + for _, option := range options { + option = strings.TrimSpace(option) + if option != "" { + newLines = append(newLines, " "+option) + } + } + } + + // Add empty line after the block + newLines = append(newLines, "") + + continue + } + } + + // Keep other lines as-is + newLines = append(newLines, lines[i]) + i++ + } + + if !blockFound { + return fmt.Errorf("multi-host block not found") + } + + // Write back to file + newContent := strings.Join(newLines, "\n") + return os.WriteFile(configPath, []byte(newContent), 0600) +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 666551f..3eaad39 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -987,3 +987,458 @@ func TestMoveHostToFile(t *testing.T) { // Test that the component functions work for the move operation t.Log("MoveHostToFile() error handling works correctly") } + +func TestParseSSHConfigWithMultipleHostsOnSameLine(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `# Test multiple hosts on same line +Host local1 local2 + HostName ::1 + User myuser + +Host root-server + User root + HostName root.example.com + +Host web1 web2 web3 + HostName ::1 + User webuser + Port 8080 + +Host single-host + HostName single.example.com + User singleuser +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + hosts, err := ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should get 7 hosts: local1, local2, root-server, web1, web2, web3, single-host + expectedHosts := map[string]struct{}{ + "local1": {}, + "local2": {}, + "root-server": {}, + "web1": {}, + "web2": {}, + "web3": {}, + "single-host": {}, + } + + 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) + } + } + + hostMap := make(map[string]SSHHost) + for _, host := range hosts { + hostMap[host.Name] = host + } + + for expectedHostName := range expectedHosts { + if _, found := hostMap[expectedHostName]; !found { + t.Errorf("Expected host %s not found", expectedHostName) + } + } + + // Verify properties based on host name + if host, found := hostMap["local1"]; found { + if host.Hostname != "::1" || host.User != "myuser" { + t.Errorf("local1 properties incorrect: hostname=%s, user=%s", host.Hostname, host.User) + } + } + + if host, found := hostMap["local2"]; found { + if host.Hostname != "::1" || host.User != "myuser" { + t.Errorf("local2 properties incorrect: hostname=%s, user=%s", host.Hostname, host.User) + } + } + + if host, found := hostMap["web1"]; found { + if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" { + t.Errorf("web1 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + if host, found := hostMap["web2"]; found { + if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" { + t.Errorf("web2 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + if host, found := hostMap["web3"]; found { + if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" { + t.Errorf("web3 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + if host, found := hostMap["root-server"]; found { + if host.User != "root" || host.Hostname != "root.example.com" { + t.Errorf("root-server properties incorrect: user=%s, hostname=%s", host.User, host.Hostname) + } + } +} + +func TestUpdateSSHHostInFileWithMultiHost(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `# Test config with multi-host +Host web1 web2 web3 + HostName webserver.example.com + User webuser + Port 2222 + +Host database + HostName db.example.com + User dbuser +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + // Update web2 in the multi-host line + newHost := SSHHost{ + Name: "web2-updated", + Hostname: "newweb.example.com", + User: "newuser", + Port: "22", + } + + err = UpdateSSHHostInFile("web2", newHost, configFile) + if err != nil { + t.Fatalf("UpdateSSHHostInFile() error = %v", err) + } + + // Parse the updated config + hosts, err := ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should have: web1, web3, web2-updated, database + expectedHosts := []string{"web1", "web3", "web2-updated", "database"} + + hostMap := make(map[string]SSHHost) + for _, host := range hosts { + hostMap[host.Name] = host + } + + 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 _, expectedHostName := range expectedHosts { + if _, found := hostMap[expectedHostName]; !found { + t.Errorf("Expected host %s not found", expectedHostName) + } + } + + // Verify web1 and web3 still have original properties + if host, found := hostMap["web1"]; found { + if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" { + t.Errorf("web1 properties changed: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + if host, found := hostMap["web3"]; found { + if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" { + t.Errorf("web3 properties changed: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + // Verify web2-updated has new properties + if host, found := hostMap["web2-updated"]; found { + if host.Hostname != "newweb.example.com" || host.User != "newuser" || host.Port != "22" { + t.Errorf("web2-updated properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + // Verify database is unchanged + if host, found := hostMap["database"]; found { + if host.Hostname != "db.example.com" || host.User != "dbuser" { + t.Errorf("database properties changed: hostname=%s, user=%s", host.Hostname, host.User) + } + } +} + +func TestIsPartOfMultiHostDeclaration(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `Host single + HostName single.example.com + +Host multi1 multi2 multi3 + HostName multi.example.com + +Host another + HostName another.example.com +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + tests := []struct { + hostName string + expectedMulti bool + expectedHosts []string + }{ + {"single", false, []string{"single"}}, + {"multi1", true, []string{"multi1", "multi2", "multi3"}}, + {"multi2", true, []string{"multi1", "multi2", "multi3"}}, + {"multi3", true, []string{"multi1", "multi2", "multi3"}}, + {"another", false, []string{"another"}}, + {"nonexistent", false, nil}, + } + + for _, tt := range tests { + t.Run(tt.hostName, func(t *testing.T) { + isMulti, hostNames, err := IsPartOfMultiHostDeclaration(tt.hostName, configFile) + if err != nil { + t.Fatalf("IsPartOfMultiHostDeclaration() error = %v", err) + } + + if isMulti != tt.expectedMulti { + t.Errorf("Expected isMulti=%v, got %v", tt.expectedMulti, isMulti) + } + + if tt.expectedHosts == nil && hostNames != nil { + t.Errorf("Expected hostNames to be nil, got %v", hostNames) + } else if tt.expectedHosts != nil { + if len(hostNames) != len(tt.expectedHosts) { + t.Errorf("Expected %d hostNames, got %d", len(tt.expectedHosts), len(hostNames)) + } else { + for i, expectedHost := range tt.expectedHosts { + if i < len(hostNames) && hostNames[i] != expectedHost { + t.Errorf("Expected hostNames[%d]=%s, got %s", i, expectedHost, hostNames[i]) + } + } + } + } + }) + } +} + +func TestDeleteSSHHostFromFileWithMultiHost(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `# Test config with multi-host deletion +Host web1 web2 web3 + HostName webserver.example.com + User webuser + Port 2222 + +Host database + HostName db.example.com + User dbuser + +# Tags: production, critical +Host app1 app2 + HostName appserver.example.com + User appuser +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + // Test 1: Delete one host from multi-host block (should keep others) + err = DeleteSSHHostFromFile("web2", configFile) + if err != nil { + t.Fatalf("DeleteSSHHostFromFile() error = %v", err) + } + + // Parse the updated config + hosts, err := ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should have: web1, web3, database, app1, app2 (web2 removed) + expectedHosts := []string{"web1", "web3", "database", "app1", "app2"} + + hostMap := make(map[string]SSHHost) + for _, host := range hosts { + hostMap[host.Name] = host + } + + 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 _, expectedHostName := range expectedHosts { + if _, found := hostMap[expectedHostName]; !found { + t.Errorf("Expected host %s not found", expectedHostName) + } + } + + // Verify web2 is not present + if _, found := hostMap["web2"]; found { + t.Error("web2 should have been deleted") + } + + // Verify web1 and web3 still have original properties + if host, found := hostMap["web1"]; found { + if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" { + t.Errorf("web1 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + if host, found := hostMap["web3"]; found { + if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" { + t.Errorf("web3 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port) + } + } + + // Test 2: Delete one host from multi-host block with tags + err = DeleteSSHHostFromFile("app1", configFile) + if err != nil { + t.Fatalf("DeleteSSHHostFromFile() error = %v", err) + } + + // Parse again + hosts, err = ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should have: web1, web3, database, app2 (app1 removed) + expectedHosts = []string{"web1", "web3", "database", "app2"} + + hostMap = make(map[string]SSHHost) + for _, host := range hosts { + hostMap[host.Name] = host + } + + 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) + } + } + + // Verify app2 still has tags + if host, found := hostMap["app2"]; found { + if !contains(host.Tags, "production") || !contains(host.Tags, "critical") { + t.Errorf("app2 tags incorrect: %v", host.Tags) + } + } +} + +func TestUpdateMultiHostBlock(t *testing.T) { + tempDir := t.TempDir() + + configFile := filepath.Join(tempDir, "config") + configContent := `# Test config for multi-host block update +Host server1 server2 server3 + HostName cluster.example.com + User clusteruser + Port 2222 + +Host single + HostName single.example.com + User singleuser +` + + err := os.WriteFile(configFile, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + // Update the multi-host block + originalHosts := []string{"server1", "server2", "server3"} + newHosts := []string{"server1", "server4", "server5"} // Remove server2, server3 and add server4, server5 + commonProperties := SSHHost{ + Hostname: "newcluster.example.com", + User: "newuser", + Port: "22", + Tags: []string{"updated", "cluster"}, + } + + err = UpdateMultiHostBlock(originalHosts, newHosts, commonProperties, configFile) + if err != nil { + t.Fatalf("UpdateMultiHostBlock() error = %v", err) + } + + // Parse the updated config + hosts, err := ParseSSHConfigFile(configFile) + if err != nil { + t.Fatalf("ParseSSHConfigFile() error = %v", err) + } + + // Should have: server1, server4, server5, single + expectedHosts := []string{"server1", "server4", "server5", "single"} + + hostMap := make(map[string]SSHHost) + for _, host := range hosts { + hostMap[host.Name] = host + } + + 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) + } + } + + // Verify new hosts have updated properties + for _, hostName := range []string{"server1", "server4", "server5"} { + if host, found := hostMap[hostName]; found { + if host.Hostname != "newcluster.example.com" || host.User != "newuser" || host.Port != "22" { + t.Errorf("%s properties incorrect: hostname=%s, user=%s, port=%s", + hostName, host.Hostname, host.User, host.Port) + } + if !contains(host.Tags, "updated") || !contains(host.Tags, "cluster") { + t.Errorf("%s tags incorrect: %v", hostName, host.Tags) + } + } else { + t.Errorf("Expected host %s not found", hostName) + } + } + + // Verify single host is unchanged + if host, found := hostMap["single"]; found { + if host.Hostname != "single.example.com" || host.User != "singleuser" { + t.Errorf("single host properties changed: hostname=%s, user=%s", host.Hostname, host.User) + } + } + + // Verify old hosts are gone + for _, oldHost := range []string{"server2", "server3"} { + if _, found := hostMap[oldHost]; found { + t.Errorf("Old host %s should have been removed", oldHost) + } + } +} + +// Helper function to check if slice contains a string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index 35d0b6c..e14c37e 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "github.com/Gu1llaum-3/sshm/internal/config" @@ -11,20 +12,36 @@ import ( "github.com/charmbracelet/lipgloss" ) -type editFormModel struct { - inputs []textinput.Model - focused int - err string - success bool - styles Styles - originalName string - host *config.SSHHost // Store the original host with SourceFile - width int - height int - configFile string +const ( + focusAreaHosts = iota + focusAreaProperties +) + +type editFormSubmitMsg struct { + hostname string + err error } -// NewEditForm creates a new edit form model +type editFormCancelMsg struct{} + +type editFormModel struct { + hostInputs []textinput.Model // Support for multiple hosts + inputs []textinput.Model + focusArea int // 0=hosts, 1=properties + focused int + err string + success bool + styles Styles + originalName string + originalHosts []string // Store original host names for multi-host detection + host *config.SSHHost // Store the original host with SourceFile + configFile string // Configuration file path passed by user + actualConfigFile string // Actual config file to use (either configFile or host.SourceFile) + width int + height int +} + +// NewEditForm creates a new edit form model that supports both single and multi-host editing func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) { // Get the existing host configuration var host *config.SSHHost @@ -40,204 +57,343 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s return nil, err } - inputs := make([]textinput.Model, 8) + // Check if this host is part of a multi-host declaration + var actualConfigFile string + var hostNames []string + var isMulti bool - // Name input - inputs[nameInput] = textinput.New() - inputs[nameInput].Placeholder = "server-name" - inputs[nameInput].Focus() - inputs[nameInput].CharLimit = 50 - inputs[nameInput].Width = 30 - inputs[nameInput].SetValue(host.Name) + if configFile != "" { + actualConfigFile = configFile + } else { + actualConfigFile = host.SourceFile + } + + if actualConfigFile != "" { + isMulti, hostNames, err = config.IsPartOfMultiHostDeclaration(hostName, actualConfigFile) + if err != nil { + // If we can't determine multi-host status, treat as single host + isMulti = false + hostNames = []string{hostName} + } + } + + if !isMulti { + hostNames = []string{hostName} + } + + // Create host inputs + hostInputs := make([]textinput.Model, len(hostNames)) + for i, name := range hostNames { + hostInputs[i] = textinput.New() + hostInputs[i].Placeholder = "host-name" + hostInputs[i].SetValue(name) + if i == 0 { + hostInputs[i].Focus() + } + } + + inputs := make([]textinput.Model, 7) // Reduced from 8 since we removed nameInput // Hostname input - inputs[hostnameInput] = textinput.New() - inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com" - inputs[hostnameInput].CharLimit = 100 - inputs[hostnameInput].Width = 30 - inputs[hostnameInput].SetValue(host.Hostname) + inputs[0] = textinput.New() + inputs[0].Placeholder = "192.168.1.100 or example.com" + inputs[0].CharLimit = 100 + inputs[0].Width = 30 + inputs[0].SetValue(host.Hostname) // User input - inputs[userInput] = textinput.New() - inputs[userInput].Placeholder = "root" - inputs[userInput].CharLimit = 50 - inputs[userInput].Width = 30 - inputs[userInput].SetValue(host.User) + inputs[1] = textinput.New() + inputs[1].Placeholder = "root" + inputs[1].CharLimit = 50 + inputs[1].Width = 30 + inputs[1].SetValue(host.User) // Port input - inputs[portInput] = textinput.New() - inputs[portInput].Placeholder = "22" - inputs[portInput].CharLimit = 5 - inputs[portInput].Width = 30 - inputs[portInput].SetValue(host.Port) + inputs[2] = textinput.New() + inputs[2].Placeholder = "22" + inputs[2].CharLimit = 5 + inputs[2].Width = 30 + inputs[2].SetValue(host.Port) // Identity input - inputs[identityInput] = textinput.New() - inputs[identityInput].Placeholder = "~/.ssh/id_rsa" - inputs[identityInput].CharLimit = 200 - inputs[identityInput].Width = 50 - inputs[identityInput].SetValue(host.Identity) + inputs[3] = textinput.New() + inputs[3].Placeholder = "~/.ssh/id_rsa" + inputs[3].CharLimit = 200 + inputs[3].Width = 50 + inputs[3].SetValue(host.Identity) // ProxyJump input - inputs[proxyJumpInput] = textinput.New() - inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name" - inputs[proxyJumpInput].CharLimit = 200 - inputs[proxyJumpInput].Width = 50 - inputs[proxyJumpInput].SetValue(host.ProxyJump) + inputs[4] = textinput.New() + inputs[4].Placeholder = "jump-server" + inputs[4].CharLimit = 100 + inputs[4].Width = 30 + inputs[4].SetValue(host.ProxyJump) - // SSH Options input - inputs[optionsInput] = textinput.New() - inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" - inputs[optionsInput].CharLimit = 500 - inputs[optionsInput].Width = 70 - inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options)) + // Options input + inputs[5] = textinput.New() + inputs[5].Placeholder = "-o StrictHostKeyChecking=no" + inputs[5].CharLimit = 200 + inputs[5].Width = 50 + if host.Options != "" { + inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options)) + } // Tags input - inputs[tagsInput] = textinput.New() - inputs[tagsInput].Placeholder = "production, web, database" - inputs[tagsInput].CharLimit = 200 - inputs[tagsInput].Width = 50 + inputs[6] = textinput.New() + inputs[6].Placeholder = "production, web, database" + inputs[6].CharLimit = 200 + inputs[6].Width = 50 if len(host.Tags) > 0 { - inputs[tagsInput].SetValue(strings.Join(host.Tags, ", ")) + inputs[6].SetValue(strings.Join(host.Tags, ", ")) } return &editFormModel{ - inputs: inputs, - focused: nameInput, - originalName: hostName, - host: host, - configFile: configFile, - styles: styles, - width: width, - height: height, + hostInputs: hostInputs, + inputs: inputs, + focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing + focused: 0, + originalName: hostName, + originalHosts: hostNames, + host: host, + configFile: configFile, + actualConfigFile: actualConfigFile, + styles: styles, + width: width, + height: height, }, nil } -// Messages for communication with parent model -type editFormSubmitMsg struct { - hostname string - err error -} - -type editFormCancelMsg struct{} - func (m *editFormModel) Init() tea.Cmd { return textinput.Blink } -func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) { +// addHostInput adds a new empty host input +func (m *editFormModel) addHostInput() tea.Cmd { + newInput := textinput.New() + newInput.Placeholder = "host-name" + newInput.Focus() + + // Unfocus current input + if m.focusArea == focusAreaHosts && m.focused < len(m.hostInputs) { + m.hostInputs[m.focused].Blur() + } + + m.hostInputs = append(m.hostInputs, newInput) + m.focused = len(m.hostInputs) - 1 + + return textinput.Blink +} + +// deleteHostInput removes the currently focused host input +func (m *editFormModel) deleteHostInput() tea.Cmd { + if len(m.hostInputs) <= 1 || m.focusArea != focusAreaHosts { + return nil // Can't delete if only one host or not in host area + } + + // Remove the focused host input + m.hostInputs = append(m.hostInputs[:m.focused], m.hostInputs[m.focused+1:]...) + + // Adjust focus + if m.focused >= len(m.hostInputs) { + m.focused = len(m.hostInputs) - 1 + } + + // Focus the new current input + if len(m.hostInputs) > 0 { + m.hostInputs[m.focused].Focus() + } + + return nil +} + +// updateFocus updates the focus state based on current area and index +func (m *editFormModel) updateFocus() tea.Cmd { + // Blur all inputs first + for i := range m.hostInputs { + m.hostInputs[i].Blur() + } + for i := range m.inputs { + m.inputs[i].Blur() + } + + // Focus the appropriate input + if m.focusArea == focusAreaHosts { + if m.focused < len(m.hostInputs) { + m.hostInputs[m.focused].Focus() + } + } else { + if m.focused < len(m.inputs) { + m.inputs[m.focused].Focus() + } + } + + return textinput.Blink +} + +func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []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 editFormCancelMsg{} } + m.err = "" + m.success = false + return m, tea.Quit case "ctrl+s": // Allow submission from any field with Ctrl+S (Save) return m, m.submitEditForm() - case "tab", "shift+tab", "enter", "up", "down": - s := msg.String() + case "ctrl+a": + // Add a new host input + return m, m.addHostInput() - // Handle form submission - if s == "enter" && m.focused == len(m.inputs)-1 { - return m, m.submitEditForm() + case "ctrl+d": + // Delete the currently focused host (if more than one exists) + if m.focusArea == focusAreaHosts && len(m.hostInputs) > 1 { + return m, m.deleteHostInput() } - // Cycle inputs - if s == "up" || s == "shift+tab" { - m.focused-- - } else { - m.focused++ - } - - if m.focused > len(m.inputs)-1 { - m.focused = 0 - } else if m.focused < 0 { - m.focused = len(m.inputs) - 1 - } - - for i := range m.inputs { - if i == m.focused { - cmds = append(cmds, m.inputs[i].Focus()) - continue + case "tab", "shift+tab": + // Switch between host area and property area + if msg.String() == "shift+tab" { + if m.focusArea == focusAreaProperties { + m.focusArea = focusAreaHosts + m.focused = len(m.hostInputs) - 1 + } else { + m.focusArea = focusAreaProperties + m.focused = len(m.inputs) - 1 + } + } else { + if m.focusArea == focusAreaHosts { + m.focusArea = focusAreaProperties + m.focused = 0 + } else { + m.focusArea = focusAreaHosts + m.focused = 0 } - m.inputs[i].Blur() } + return m, m.updateFocus() - return m, tea.Batch(cmds...) + case "up", "down", "enter": + // Navigate within the current area + if m.focusArea == focusAreaHosts { + if msg.String() == "up" && m.focused > 0 { + m.focused-- + } else if msg.String() == "down" && m.focused < len(m.hostInputs)-1 { + m.focused++ + } else if msg.String() == "enter" { + // Submit form on enter + return m, m.submitEditForm() + } + } else { + if msg.String() == "up" && m.focused > 0 { + m.focused-- + } else if msg.String() == "down" && m.focused < len(m.inputs)-1 { + m.focused++ + } else if msg.String() == "enter" { + // Submit form on enter + return m, m.submitEditForm() + } + } + return m, m.updateFocus() } case editFormSubmitMsg: if msg.err != nil { m.err = msg.err.Error() + m.success = false } else { m.success = true m.err = "" - // Don't quit here, let parent handle the success } return m, nil } - // Update inputs - cmd := make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - m.inputs[i], cmd[i] = m.inputs[i].Update(msg) + // Update host inputs + hostCmd := make([]tea.Cmd, len(m.hostInputs)) + for i := range m.hostInputs { + m.hostInputs[i], hostCmd[i] = m.hostInputs[i].Update(msg) } - cmds = append(cmds, cmd...) + cmds = append(cmds, hostCmd...) + + // Update property inputs + propCmd := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], propCmd[i] = m.inputs[i].Update(msg) + } + cmds = append(cmds, propCmd...) return m, tea.Batch(cmds...) } func (m *editFormModel) View() string { - if m.success { - return "" - } - var b strings.Builder - b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration")) - b.WriteString("\n") + if m.success { + b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!")) + b.WriteString("\n\n") + b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back")) + return b.String() + } + + if m.err != "" { + b.WriteString(m.styles.Error.Render("Error: " + m.err)) + b.WriteString("\n\n") + } + + b.WriteString(m.styles.Header.Render("Edit SSH Host")) + b.WriteString("\n\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")) - + labelStyle := m.styles.FormField + pathStyle := m.styles.FormField configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile)) b.WriteString(configInfo) } + + b.WriteString("\n\n") + + // Host Names Section + b.WriteString(m.styles.FormTitle.Render("Host Names")) + b.WriteString("\n\n") + + for i, hostInput := range m.hostInputs { + hostStyle := m.styles.FormField + if m.focusArea == focusAreaHosts && m.focused == i { + hostStyle = m.styles.FocusedLabel + } + b.WriteString(hostStyle.Render(fmt.Sprintf("Host Name %d *", i+1))) + b.WriteString("\n") + b.WriteString(hostInput.View()) + b.WriteString("\n\n") + } + + // Properties Section + b.WriteString(m.styles.FormTitle.Render("Common Properties")) b.WriteString("\n\n") fields := []string{ - "Host Name *", "Hostname/IP *", "User", "Port", "Identity File", - "ProxyJump", + "Proxy Jump", "SSH Options", "Tags (comma-separated)", } for i, field := range fields { - b.WriteString(m.styles.FormField.Render(field)) + fieldStyle := m.styles.FormField + if m.focusArea == focusAreaProperties && m.focused == i { + fieldStyle = m.styles.FocusedLabel + } + b.WriteString(fieldStyle.Render(field)) b.WriteString("\n") b.WriteString(m.inputs[i].View()) b.WriteString("\n\n") @@ -248,75 +404,82 @@ func (m *editFormModel) View() string { b.WriteString("\n\n") } - b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel")) - b.WriteString("\n") - b.WriteString(m.styles.FormHelp.Render("* Required fields")) + // Show different help based on number of hosts + if len(m.hostInputs) > 1 { + b.WriteString(m.styles.FormHelp.Render("Tab: switch sections • ↑↓: navigate • Ctrl+A: add host • Ctrl+D: delete host")) + b.WriteString("\n") + } else { + b.WriteString(m.styles.FormHelp.Render("Tab: switch sections • ↑↓: navigate • Ctrl+A: add host")) + b.WriteString("\n") + } + b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields")) return b.String() } -// Standalone wrapper for edit form -type standaloneEditForm struct { - *editFormModel -} - -func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case editFormSubmitMsg: - if msg.err != nil { - m.editFormModel.err = msg.err.Error() - } else { - m.editFormModel.success = true - return m, tea.Quit - } - return m, nil - case editFormCancelMsg: - return m, tea.Quit - } - - newForm, cmd := m.editFormModel.Update(msg) - m.editFormModel = newForm - return m, cmd -} - -// RunEditForm provides backward compatibility for standalone edit form +// RunEditForm runs the edit form as a standalone program func RunEditForm(hostName string, configFile string) error { - styles := NewStyles(80) + styles := NewStyles(80) // Default width editForm, err := NewEditForm(hostName, styles, 80, 24, configFile) if err != nil { return err } - m := standaloneEditForm{editForm} - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(editForm, tea.WithAltScreen()) _, err = p.Run() - return err + if err != nil { + return err + } + + if editForm.err != "" { + return fmt.Errorf(editForm.err) + } + + return nil } func (m *editFormModel) submitEditForm() tea.Cmd { return func() tea.Msg { - // Get values - name := strings.TrimSpace(m.inputs[nameInput].Value()) - hostname := strings.TrimSpace(m.inputs[hostnameInput].Value()) - user := strings.TrimSpace(m.inputs[userInput].Value()) - port := strings.TrimSpace(m.inputs[portInput].Value()) - identity := strings.TrimSpace(m.inputs[identityInput].Value()) - proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) - options := strings.TrimSpace(m.inputs[optionsInput].Value()) + // Collect host names + var hostNames []string + for _, input := range m.hostInputs { + name := strings.TrimSpace(input.Value()) + if name != "" { + hostNames = append(hostNames, name) + } + } + + if len(hostNames) == 0 { + return editFormSubmitMsg{err: fmt.Errorf("at least one host name is required")} + } + + // Get property values using direct indices + hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput + user := strings.TrimSpace(m.inputs[1].Value()) // userInput + port := strings.TrimSpace(m.inputs[2].Value()) // portInput + identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput + proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput + options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput // Set defaults if port == "" { port = "22" } - // Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional - // Validate all fields - if err := validation.ValidateHost(name, hostname, port, identity); err != nil { - return editFormSubmitMsg{err: err} + // Validate hostname + if hostname == "" { + return editFormSubmitMsg{err: fmt.Errorf("hostname is required")} + } + + // Validate all host names + for _, hostName := range hostNames { + if err := validation.ValidateHost(hostName, hostname, port, identity); err != nil { + return editFormSubmitMsg{err: err} + } } // Parse tags - tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value()) + tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput var tags []string if tagsStr != "" { for _, tag := range strings.Split(tagsStr, ",") { @@ -327,25 +490,31 @@ func (m *editFormModel) submitEditForm() tea.Cmd { } } - // Create updated host configuration - host := config.SSHHost{ - Name: name, + // Create the common host configuration + commonHost := config.SSHHost{ Hostname: hostname, User: user, Port: port, Identity: identity, ProxyJump: proxyJump, - Options: config.ParseSSHOptionsFromCommand(options), + Options: options, Tags: tags, } - // Update the configuration var err error - if m.configFile != "" { - err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile) + if len(hostNames) == 1 && len(m.originalHosts) == 1 { + // Single host editing + commonHost.Name = hostNames[0] + if m.actualConfigFile != "" { + err = config.UpdateSSHHostInFile(m.originalName, commonHost, m.actualConfigFile) + } else { + err = config.UpdateSSHHost(m.originalName, commonHost) + } } else { - err = config.UpdateSSHHost(m.originalName, host) + // Multi-host editing or conversion from single to multi + err = config.UpdateMultiHostBlock(m.originalHosts, hostNames, commonHost, m.actualConfigFile) } - return editFormSubmitMsg{hostname: name, err: err} + + return editFormSubmitMsg{hostname: hostNames[0], err: err} } } diff --git a/internal/ui/update.go b/internal/ui/update.go index f633212..828f1ff 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -394,9 +394,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case ViewEdit: if m.editForm != nil { - var newForm *editFormModel - newForm, cmd = m.editForm.Update(msg) - m.editForm = newForm + var updatedModel tea.Model + updatedModel, cmd = m.editForm.Update(msg) + m.editForm = updatedModel.(*editFormModel) return m, cmd } case ViewMove: