fix: use line numbers to prevent deleting all duplicate SSH hosts when removing one

This commit is contained in:
2026-01-04 21:34:09 +01:00
parent def2b4fa8d
commit 8f780e288c
4 changed files with 71 additions and 25 deletions

View File

@@ -25,6 +25,7 @@ type SSHHost struct {
RequestTTY string // Request TTY (yes, no, force, auto) RequestTTY string // Request TTY (yes, no, force, auto)
Tags []string Tags []string
SourceFile string // Path to the config file where this host is defined 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 // Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field aliasNames []string `json:"-"` // Do not serialize this field
@@ -212,8 +213,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
var currentHost *SSHHost var currentHost *SSHHost
var pendingTags []string var pendingTags []string
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() { for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
// Ignore empty lines // Ignore empty lines
@@ -300,6 +303,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
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 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 // 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 // DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error { 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 // DeleteSSHHostFromFile deletes an SSH host from a specific config file
func DeleteSSHHostFromFile(hostName, configPath string) error { 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() configMutex.Lock()
defer configMutex.Unlock() defer configMutex.Unlock()
@@ -1365,11 +1379,13 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
hostFound := false hostFound := false
for i < len(lines) { for i < len(lines) {
currentLineNumber := i + 1 // Convert 0-indexed to 1-indexed
line := strings.TrimSpace(lines[i]) line := strings.TrimSpace(lines[i])
// Check for tags comment followed by Host // Check for tags comment followed by Host
if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1]) 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 // Check if this is a Host line that contains our target host
if strings.HasPrefix(nextLine, "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 hostFound = true
if isMultiHost && len(hostNames) > 1 { if isMultiHost && len(hostNames) > 1 {
@@ -1423,7 +1442,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else { } else {
// Single host or last host in multi-host block, delete entire block // Single host or last host in multi-host block, delete entire block
// Skip tags comment and Host line // Skip tags comment and Host line
@@ -1439,7 +1463,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ 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 hostFound = true
if isMultiHost && len(hostNames) > 1 { if isMultiHost && len(hostNames) > 1 {
@@ -1494,7 +1526,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else { } else {
// Single host, delete entire block // Single host, delete entire block
// Skip Host line // Skip Host line
@@ -1510,7 +1547,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ 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 // 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 // Find the host to determine which file it's in
existingHost, err := FindHostInAllConfigs(hostName) existingHost, err := FindHostInAllConfigs(hostName)
if err != nil { if err != nil {
return err return err
} }
// Delete the host from its source file // Delete the host from its source file using line number if provided
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile) return DeleteSSHHostFromFileWithLine(hostName, existingHost.SourceFile, targetLineNumber)
} }
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file // AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file

View File

@@ -74,14 +74,14 @@ type Model struct {
filteredHosts []config.SSHHost filteredHosts []config.SSHHost
searchMode bool searchMode bool
deleteMode bool deleteMode bool
deleteHost string deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
historyManager *history.HistoryManager historyManager *history.HistoryManager
pingManager *connectivity.PingManager pingManager *connectivity.PingManager
sortMode SortMode sortMode SortMode
configFile string // Path to the SSH config file configFile string // Path to the SSH config file
// Application configuration // Application configuration
appConfig *config.AppConfig appConfig *config.AppConfig
// Version update information // Version update information
updateInfo *version.UpdateInfo updateInfo *version.UpdateInfo

View File

@@ -452,7 +452,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.deleteMode { if m.deleteMode {
// Exit delete mode // Exit delete mode
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -508,15 +508,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else if m.deleteMode { } else if m.deleteMode {
// Confirm deletion // Confirm deletion
var err error var err error
if m.configFile != "" { if m.deleteHost != nil {
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile) err = config.DeleteSSHHostWithLine(*m.deleteHost)
} else {
err = config.DeleteSSHHost(m.deleteHost)
} }
if err != nil { if err != nil {
// Could display an error message here // Could display an error message here
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -533,7 +531,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if parseErr != nil { if parseErr != nil {
// Could display an error message here // Could display an error message here
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -548,7 +546,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableRows() m.updateTableRows()
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} else { } else {
@@ -673,11 +671,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d": case "d":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Delete the selected host // Delete the selected host
selected := m.table.SelectedRow() cursor := m.table.Cursor()
if len(selected) > 0 { if cursor >= 0 && cursor < len(m.filteredHosts) {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column // Get the host at the cursor position (which corresponds to filteredHosts index)
targetHost := &m.filteredHosts[cursor]
m.deleteMode = true m.deleteMode = true
m.deleteHost = hostName m.deleteHost = targetHost
m.table.Blur() m.table.Blur()
return m, nil return m, nil
} }

View File

@@ -144,7 +144,11 @@ func (m Model) renderListView() string {
func (m Model) renderDeleteConfirmation() string { func (m Model) renderDeleteConfirmation() string {
// Remove emojis (uncertain width depending on terminal) to stabilize the frame // Remove emojis (uncertain width depending on terminal) to stabilize the frame
title := "DELETE SSH HOST" 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." action := "This action cannot be undone."
help := "Enter: confirm • Esc: cancel" help := "Enter: confirm • Esc: cancel"