Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion TablePro/Views/Connection/HostListFieldRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>,
from entries: [HostEntry]
) -> Set<UUID> {
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
Expand Down Expand Up @@ -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()
}

Expand Down
92 changes: 92 additions & 0 deletions TableProTests/Views/Connection/HostListSelectionTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading