Skip to content

Commit 2f9f5be

Browse files
authored
feat(connections): show a full-window connecting surface with named stages (#2053)
1 parent 55afffb commit 2f9f5be

32 files changed

Lines changed: 847 additions & 92 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- Redshift external schemas now list their tables. Spectrum, federated query, cross-database, and datashare schemas showed up empty because their tables are not in the standard catalog.
2424
- External schemas are marked in the sidebar, and their tables show an external icon. External tables open read-only, because Redshift rejects `UPDATE` and `DELETE` on them.
2525

26+
### Changed
27+
28+
- Connecting to a database now fills the window instead of framing a spinner with an empty sidebar and inspector, and it names the step it is on: opening the tunnel, negotiating encryption, authenticating, preparing the session. PostgreSQL, CockroachDB, Redshift, ClickHouse, and Redis report their own handshake steps; the rest report the steps around the driver. A step that stalls says so rather than spinning silently.
29+
- A connection that fails now shows the database's own error wherever the connect started, including from a link or a database file, and offers Copy Details. Those routes used to drop the real message and report only that the connection had closed.
30+
- Opening a table or query from a link now opens its window straight away, so a slow connect has somewhere to report progress and a failed one has somewhere to explain itself.
31+
- A saved pre-connect script no longer runs when the app reopens a session on its own. The window waits with a Connect button, which asks before running it.
32+
2633
### Fixed
2734

35+
- A dropped SSH tunnel that ran out of reconnect attempts left the window spinning forever with no way back. It now reports what happened and offers to try again.
36+
- A brief tunnel reconnect no longer closes your tabs. The window used to tear down the whole session on the blip, taking unsaved query edits with it.
37+
- The sidebar's filter field no longer sits over an empty sidebar while a connection is still being established.
2838
- The JSON view of a result now follows the grid: rows come in the order you sorted them, a column filter leaves its rows out, and hidden columns stay hidden. It used to show every row in fetch order, including rows the filter had removed, and columns you had hidden.
2939
- Copy JSON in the JSON view and Copy as JSON in the grid now produce the same output for the same rows.
3040
- The JSON view now updates when you change a column filter without running a new query.

Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
195195
// MARK: - Connection
196196

197197
func connect() async throws {
198+
try await connect(reportingStage: { _ in })
199+
}
200+
201+
func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {
198202
let urlConfig = URLSessionConfiguration.default
199203
urlConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
200204
urlConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
@@ -221,6 +225,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
221225
throw ClickHouseError.connectionFailed
222226
}
223227

228+
report(.preparingSession)
224229
if let result = try? await executeRaw("SELECT version()"),
225230
let versionStr = result.rows.first?.first?.asText {
226231
_serverVersion = versionStr

Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ final class LibPQDriverCore: @unchecked Sendable {
2020

2121
var onPostConnect: (@Sendable () async -> Void)?
2222

23+
/// Set by `LibPQBackedDriver` for the span of one connect, so every driver built on this
24+
/// core reports its handshake steps without having to thread a parameter through its own
25+
/// `connect()` and duplicate the setup each one does around it.
26+
var stageReporter: ConnectionStageReporter?
27+
2328
var serverVersion: String? { libpqConnection?.serverVersion() }
2429
var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 }
2530

@@ -47,7 +52,7 @@ final class LibPQDriverCore: @unchecked Sendable {
4752
suppressServerSideCancel: singleConnectionMode
4853
)
4954

50-
try await pqConn.connect()
55+
try await pqConn.connect(reportingStage: stageReporter ?? { _ in })
5156
libpqConnection = pqConn
5257

5358
switch await probeSchema(pqConn, query: PostgreSQLSchemaQueries.currentSchema) {
@@ -193,6 +198,14 @@ extension LibPQBackedDriver {
193198
try await core.connect()
194199
}
195200

201+
/// Routes back through `connect()` rather than calling the core directly, so a driver that
202+
/// overrides `connect()` to probe catalogs or remap errors still runs its own version.
203+
func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {
204+
core.stageReporter = report
205+
defer { core.stageReporter = nil }
206+
try await connect()
207+
}
208+
196209
func disconnect() {
197210
core.disconnect()
198211
}

Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
162162

163163
// MARK: - Connection Management
164164

165-
func connect() async throws {
165+
func connect(reportingStage report: @escaping ConnectionStageReporter = { _ in }) async throws {
166166
stateLock.lock()
167167
_isConnectCancelled = false
168168
stateLock.unlock()
@@ -172,7 +172,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
172172
on: queue,
173173
cancellationCheck: { [weak self] in self?.isConnectCancelled ?? true }
174174
) { [self] in
175-
try performConnect()
175+
try performConnect(reportingStage: report)
176176
}
177177
} onCancel: {
178178
cancelConnect()
@@ -191,7 +191,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
191191
return _isConnectCancelled
192192
}
193193

194-
private func performConnect() throws {
194+
private func performConnect(reportingStage report: @escaping ConnectionStageReporter) throws {
195195
guard let connection = buildConnectionString().withCString({ PQconnectStart($0) }) else {
196196
throw LibPQPluginError.connectionFailed
197197
}
@@ -205,7 +205,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
205205
throw connectionError(from: connection)
206206
}
207207

208-
try pollUntilConnected(connection)
208+
try pollUntilConnected(connection, reportingStage: report)
209209
configureEstablishedConnection(connection)
210210

211211
stateLock.lock()
@@ -215,12 +215,17 @@ final class LibPQPluginConnection: @unchecked Sendable {
215215
adopted = true
216216
}
217217

218-
private func pollUntilConnected(_ connection: OpaquePointer) throws {
218+
private func pollUntilConnected(
219+
_ connection: OpaquePointer,
220+
reportingStage report: @escaping ConnectionStageReporter
221+
) throws {
219222
let deadline = PQgetCurrentTimeUSec() + Self.connectTimeoutMicroseconds
220223
var status = PGRES_POLLING_WRITING
224+
var lastHandshakeStatus: ConnStatusType?
221225

222226
while true {
223227
try checkConnectCancellation()
228+
reportHandshakeStage(of: connection, last: &lastHandshakeStatus, report: report)
224229

225230
switch status {
226231
case PGRES_POLLING_OK:
@@ -250,6 +255,28 @@ final class LibPQPluginConnection: @unchecked Sendable {
250255
}
251256
}
252257

258+
/// `PGRES_POLLING_*` only says whether the socket wants a read or a write, so it cannot tell
259+
/// a TLS handshake from an authentication exchange. `PQstatus` can, and reading it costs one
260+
/// pointer dereference per poll slice.
261+
private func reportHandshakeStage(
262+
of connection: OpaquePointer,
263+
last: inout ConnStatusType?,
264+
report: ConnectionStageReporter
265+
) {
266+
let current = PQstatus(connection)
267+
guard current != last else { return }
268+
last = current
269+
270+
switch current {
271+
case CONNECTION_SSL_STARTUP:
272+
report(.negotiatingEncryption)
273+
case CONNECTION_AWAITING_RESPONSE, CONNECTION_AUTH_OK:
274+
report(.authenticating)
275+
default:
276+
break
277+
}
278+
}
279+
253280
private func checkConnectCancellation() throws {
254281
guard isConnectCancelled else { return }
255282
throw CancellationError()

Plugins/RedisDriverPlugin/RedisPluginConnection.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,15 @@ final class RedisPluginConnection: @unchecked Sendable {
165165

166166
// MARK: - Connection Management
167167

168-
func connect() async throws {
168+
func connect(reportingStage report: @escaping ConnectionStageReporter = { _ in }) async throws {
169169
#if canImport(CRedis)
170170
_ = Self.initOnce
171171
try await pluginDispatchAsync(on: queue) { [self] in
172172
logger.debug("Connecting to Redis at \(self.host):\(self.port)")
173173

174-
try openContextSync(selectDatabase: database)
174+
try openContextSync(selectDatabase: database, reportingStage: report)
175175

176+
report(.preparingSession)
176177
do {
177178
let pingReply = try executeCommandSync(["PING"])
178179
if case .error(let msg) = pingReply {
@@ -398,7 +399,10 @@ private extension RedisPluginConnection {
398399
}
399400
}
400401

401-
func openContextSync(selectDatabase dbIndex: Int) throws {
402+
func openContextSync(
403+
selectDatabase dbIndex: Int,
404+
reportingStage report: ConnectionStageReporter = { _ in }
405+
) throws {
402406
let connectTimeout = timeval(tv_sec: 10, tv_usec: 0)
403407
guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else {
404408
logger.error("Failed to create Redis context")
@@ -423,8 +427,12 @@ private extension RedisPluginConnection {
423427

424428
do {
425429
if sslConfig.isEnabled {
430+
report(.negotiatingEncryption)
426431
try connectSSL(ctx)
427432
}
433+
if let password, !password.isEmpty {
434+
report(.authenticating)
435+
}
428436
try authenticateSync()
429437
if dbIndex != 0 {
430438
let reply = try executeCommandSync(["SELECT", String(dbIndex)])

Plugins/RedisDriverPlugin/RedisPluginDriver.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
6060
// MARK: - Connection Management
6161

6262
func connect() async throws {
63+
try await connect(reportingStage: { _ in })
64+
}
65+
66+
func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {
6367
let sslConfig = config.ssl
6468
let redisDb = Int(config.additionalFields["redisDatabase"] ?? "") ?? Int(config.database) ?? 0
6569

@@ -72,7 +76,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
7276
sslConfig: sslConfig
7377
)
7478

75-
try await conn.connect()
79+
try await conn.connect(reportingStage: report)
7680
redisConnection = conn
7781
}
7882

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
/// A step a connection attempt passes through, reported as it starts so a window can say what
4+
/// it is waiting on instead of showing an unexplained spinner.
5+
///
6+
/// Left non-frozen so it can gain steps without an ABI bump, and `custom` carries an
7+
/// engine-specific step that does not earn a shared case.
8+
public enum ConnectionStage: Sendable, Equatable {
9+
case resolvingTunnel
10+
case runningPreConnectScript
11+
case awaitingCredentials
12+
case openingConnection
13+
case negotiatingEncryption
14+
case authenticating
15+
case preparingSession
16+
case custom(String)
17+
}
18+
19+
/// Called on whatever thread the stage is observed from, so it must be cheap and must not
20+
/// assume the main actor. It is deliberately synchronous: the deepest call sites are C poll
21+
/// loops where a suspension point would change the timing of the connect itself.
22+
public typealias ConnectionStageReporter = @Sendable (ConnectionStage) -> Void

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
7575
var capabilities: PluginCapabilities { get }
7676

7777
func connect() async throws
78+
func connect(reportingStage report: @escaping ConnectionStageReporter) async throws
7879
func disconnect()
7980
func ping() async throws
8081

@@ -208,6 +209,12 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
208209
public extension PluginDatabaseDriver {
209210
var capabilities: PluginCapabilities { [] }
210211

212+
/// A driver that cannot see inside its own connect keeps the plain path. The app still
213+
/// reports the stages either side of this call, so the window is never blank.
214+
func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {
215+
try await connect()
216+
}
217+
211218
func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] }
212219

213220
/// Engines whose partitions are metadata on one table object, rather than

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ protocol DatabaseDriver: AnyObject, Sendable {
2828
/// Connect to the database
2929
func connect() async throws
3030

31+
/// Connect while reporting the steps this driver can see from inside its own handshake.
32+
func connectReporting(stage report: @escaping ConnectionStageReporter) async throws
33+
3134
/// Disconnect from the database
3235
func disconnect()
3336

@@ -240,6 +243,10 @@ extension DatabaseDriver {
240243
/// Override in drivers that support version querying
241244
var serverVersion: String? { nil }
242245

246+
func connectReporting(stage report: @escaping ConnectionStageReporter) async throws {
247+
try await connect()
248+
}
249+
243250
var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { nil }
244251

245252
func beginTransaction(mode: PluginTransactionAccessMode) async throws {

0 commit comments

Comments
 (0)