From 0e9f10cd607cef27b3ebc45f84ce2074e0f03977 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 16 May 2026 20:37:34 +0700 Subject: [PATCH] fix(connection-form): move host selection to adjacent row after delete (#1293) --- CHANGELOG.md | 1 + .../Views/Connection/HostListFieldRow.swift | 29 +++++- .../Connection/HostListSelectionTests.swift | 92 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 TableProTests/Views/Connection/HostListSelectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 788742383..ee4e173b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267) - AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash. - MongoDB: the connection form now shows a Username field. It was hidden for databases where authentication is optional, so connections to auth-enabled servers saved with no credentials and every query failed with "requires authentication" even though the connection looked healthy. +- MongoDB: deleting a host from the multi-host editor now keeps the list interactive. The previous row stayed unselectable until the form lost and regained focus, matching `NSTableView` behavior of moving selection to the adjacent row. (#1293) - SQL import dropped statements when the database executed them slower than the file was parsed, so a re-imported export could fail with errors like "relation does not exist". The parser now waits for each statement to be consumed before reading more. (#1264) - SQL import ignored the database dialect, so PostgreSQL dumps with dollar-quoted function bodies were split at semicolons inside the body. (#1264) - SQL export emitted `DROP TABLE` for views, materialized views, and foreign tables, so re-importing failed with "is not a table". It now emits `DROP VIEW`, `DROP MATERIALIZED VIEW`, or `DROP FOREIGN TABLE` to match the object. (#1264) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index a2b752bbf..9ed37c439 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -10,6 +10,27 @@ struct HostEntry: Identifiable { var value: String } +/// Selection arithmetic for `HostListFieldRow`. +/// +/// `List(selection:)` with an empty `Set` after a row deletion leaves SwiftUI's +/// selection state stuck, so subsequent clicks on remaining rows do not register +/// until the parent view forces a redraw. This mirrors `NSTableView` HIG +/// behaviour by moving selection to the row that takes the removed row's place. +enum HostListSelection { + static func nextSelection( + afterRemoving removedIds: Set, + from entries: [HostEntry] + ) -> Set { + guard let firstRemoveIndex = entries.firstIndex(where: { removedIds.contains($0.id) }) else { + return [] + } + let remaining = entries.filter { !removedIds.contains($0.id) } + guard !remaining.isEmpty else { return [] } + let nextIndex = min(firstRemoveIndex, remaining.count - 1) + return [remaining[nextIndex].id] + } +} + struct HostListFieldRow: View { let label: String let placeholder: String @@ -116,11 +137,17 @@ struct HostListFieldRow: View { private func removeSelected() { guard !selectedId.isEmpty, entries.count > 1 else { return } + let nextSelection = HostListSelection.nextSelection( + afterRemoving: selectedId, + from: entries + ) entries.removeAll { selectedId.contains($0.id) } if entries.isEmpty { entries.append(HostEntry(value: "")) + selectedId = [] + } else { + selectedId = nextSelection } - selectedId = [] syncValue() } diff --git a/TableProTests/Views/Connection/HostListSelectionTests.swift b/TableProTests/Views/Connection/HostListSelectionTests.swift new file mode 100644 index 000000000..e785c6ea0 --- /dev/null +++ b/TableProTests/Views/Connection/HostListSelectionTests.swift @@ -0,0 +1,92 @@ +// +// HostListSelectionTests.swift +// TableProTests +// +// Regression coverage for issue #1293: deleting a host row must move +// selection to the adjacent row so the list stays interactive. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Host list selection after delete") +struct HostListSelectionTests { + @Test("removing middle row selects row that takes its place") + func removeMiddle() { + let first = HostEntry(value: "a") + let middle = HostEntry(value: "b") + let last = HostEntry(value: "c") + let result = HostListSelection.nextSelection( + afterRemoving: [middle.id], + from: [first, middle, last] + ) + #expect(result == [last.id]) + } + + @Test("removing last row selects new last row") + func removeLast() { + let first = HostEntry(value: "a") + let middle = HostEntry(value: "b") + let last = HostEntry(value: "c") + let result = HostListSelection.nextSelection( + afterRemoving: [last.id], + from: [first, middle, last] + ) + #expect(result == [middle.id]) + } + + @Test("removing first row selects new first row") + func removeFirst() { + let first = HostEntry(value: "a") + let second = HostEntry(value: "b") + let result = HostListSelection.nextSelection( + afterRemoving: [first.id], + from: [first, second] + ) + #expect(result == [second.id]) + } + + @Test("removing only entry returns empty selection") + func removeOnly() { + let only = HostEntry(value: "a") + let result = HostListSelection.nextSelection( + afterRemoving: [only.id], + from: [only] + ) + #expect(result.isEmpty) + } + + @Test("removing multiple selects first remaining at removal index") + func removeMultiple() { + let first = HostEntry(value: "a") + let middle = HostEntry(value: "b") + let last = HostEntry(value: "c") + let result = HostListSelection.nextSelection( + afterRemoving: [middle.id, last.id], + from: [first, middle, last] + ) + #expect(result == [first.id]) + } + + @Test("removing nothing returns empty selection") + func removeNothing() { + let only = HostEntry(value: "a") + let result = HostListSelection.nextSelection( + afterRemoving: [], + from: [only] + ) + #expect(result.isEmpty) + } + + @Test("selection ids that no longer exist are treated as no-op") + func staleSelection() { + let only = HostEntry(value: "a") + let stale = UUID() + let result = HostListSelection.nextSelection( + afterRemoving: [stale], + from: [only] + ) + #expect(result.isEmpty) + } +}