From 8f780e288c247a43bfc6e7b47385701709ae45a3 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sun, 4 Jan 2026 21:34:09 +0100 Subject: [PATCH] fix: use line numbers to prevent deleting all duplicate SSH hosts when removing one --- internal/config/ssh.go | 62 +++++++++++++++++++++++++++++++++++------- internal/ui/model.go | 4 +-- internal/ui/update.go | 24 ++++++++-------- internal/ui/view.go | 6 +++- 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 792a0cd..9b13d50 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -25,6 +25,7 @@ type SSHHost struct { RequestTTY string // Request TTY (yes, no, force, auto) Tags []string SourceFile string // Path to the config file where this host is defined + LineNumber int // Line number in the source file where this host block starts (1-indexed) // Temporary field to handle multiple aliases during parsing aliasNames []string `json:"-"` // Do not serialize this field @@ -212,8 +213,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ var currentHost *SSHHost var pendingTags []string scanner := bufio.NewScanner(file) + lineNumber := 0 for scanner.Scan() { + lineNumber++ line := strings.TrimSpace(scanner.Text()) // Ignore empty lines @@ -300,6 +303,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ Port: "22", // Default port Tags: pendingTags, // Assign pending tags to this host SourceFile: absPath, // Track which file this host comes from + LineNumber: lineNumber, // Track the line number where Host declaration starts } // Store additional host names for later processing @@ -1334,11 +1338,21 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err // DeleteSSHHost removes an SSH host configuration from the config file func DeleteSSHHost(hostName string) error { - return DeleteSSHHostV2(hostName) + return DeleteSSHHostV2(hostName, 0) // Legacy: without line number +} + +// DeleteSSHHostWithLine deletes a specific SSH host by name and line number +func DeleteSSHHostWithLine(host SSHHost) error { + return DeleteSSHHostFromFileWithLine(host.Name, host.SourceFile, host.LineNumber) } // DeleteSSHHostFromFile deletes an SSH host from a specific config file func DeleteSSHHostFromFile(hostName, configPath string) error { + return DeleteSSHHostFromFileWithLine(hostName, configPath, 0) // Legacy: without line number +} + +// DeleteSSHHostFromFileWithLine deletes an SSH host from a specific config file at a specific line +func DeleteSSHHostFromFileWithLine(hostName, configPath string, targetLineNumber int) error { configMutex.Lock() defer configMutex.Unlock() @@ -1365,11 +1379,13 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { hostFound := false for i < len(lines) { + currentLineNumber := i + 1 // Convert 0-indexed to 1-indexed 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]) + nextLineNumber := i + 2 // The Host line is at i+1, so its 1-indexed number is i+2 // Check if this is a Host line that contains our target host if strings.HasPrefix(nextLine, "Host ") { @@ -1385,7 +1401,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { } } - if targetHostIndex != -1 { + // Only proceed if: + // 1. We found the host name + // 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match + if targetHostIndex != -1 && (targetLineNumber == 0 || nextLineNumber == targetLineNumber) { hostFound = true if isMultiHost && len(hostNames) > 1 { @@ -1423,7 +1442,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } else { // Single host or last host in multi-host block, delete entire block // Skip tags comment and Host line @@ -1439,7 +1463,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } } } @@ -1459,7 +1488,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { } } - if targetHostIndex != -1 { + // Only proceed if: + // 1. We found the host name + // 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match + if targetHostIndex != -1 && (targetLineNumber == 0 || currentLineNumber == targetLineNumber) { hostFound = true if isMultiHost && len(hostNames) > 1 { @@ -1494,7 +1526,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } else { // Single host, delete entire block // Skip Host line @@ -1510,7 +1547,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error { i++ } - continue + // Copy remaining lines and break to prevent deleting other duplicates + for i < len(lines) { + newLines = append(newLines, lines[i]) + i++ + } + break } } } @@ -1593,15 +1635,15 @@ func UpdateSSHHostV2(oldName string, newHost SSHHost) error { } // DeleteSSHHostV2 removes an SSH host configuration, searching in all config files -func DeleteSSHHostV2(hostName string) error { +func DeleteSSHHostV2(hostName string, targetLineNumber int) 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) + // Delete the host from its source file using line number if provided + return DeleteSSHHostFromFileWithLine(hostName, existingHost.SourceFile, targetLineNumber) } // AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file diff --git a/internal/ui/model.go b/internal/ui/model.go index da6e7e9..bb809a0 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -74,14 +74,14 @@ type Model struct { filteredHosts []config.SSHHost searchMode bool deleteMode bool - deleteHost string + deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) historyManager *history.HistoryManager pingManager *connectivity.PingManager sortMode SortMode configFile string // Path to the SSH config file // Application configuration - appConfig *config.AppConfig + appConfig *config.AppConfig // Version update information updateInfo *version.UpdateInfo diff --git a/internal/ui/update.go b/internal/ui/update.go index 828f1ff..a7c4aee 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -452,7 +452,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.deleteMode { // Exit delete mode m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -508,15 +508,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if m.deleteMode { // Confirm deletion var err error - if m.configFile != "" { - err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile) - } else { - err = config.DeleteSSHHost(m.deleteHost) + if m.deleteHost != nil { + err = config.DeleteSSHHostWithLine(*m.deleteHost) } if err != nil { // Could display an error message here m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -533,7 +531,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if parseErr != nil { // Could display an error message here m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } @@ -548,7 +546,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateTableRows() m.deleteMode = false - m.deleteHost = "" + m.deleteHost = nil m.table.Focus() return m, nil } else { @@ -673,11 +671,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "d": if !m.searchMode && !m.deleteMode { // Delete the selected host - selected := m.table.SelectedRow() - if len(selected) > 0 { - hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column + cursor := m.table.Cursor() + if cursor >= 0 && cursor < len(m.filteredHosts) { + // Get the host at the cursor position (which corresponds to filteredHosts index) + targetHost := &m.filteredHosts[cursor] + m.deleteMode = true - m.deleteHost = hostName + m.deleteHost = targetHost m.table.Blur() return m, nil } diff --git a/internal/ui/view.go b/internal/ui/view.go index 9f5f94e..0d6e8a6 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -144,7 +144,11 @@ func (m Model) renderListView() string { func (m Model) renderDeleteConfirmation() string { // Remove emojis (uncertain width depending on terminal) to stabilize the frame title := "DELETE SSH HOST" - question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost) + hostName := "" + if m.deleteHost != nil { + hostName = m.deleteHost.Name + } + question := fmt.Sprintf("Are you sure you want to delete host '%s'?", hostName) action := "This action cannot be undone." help := "Enter: confirm • Esc: cancel"