Skip to content

Commit bff0a98

Browse files
committed
Merge branch 'main' into fix/shift-click-multi-column-sort
2 parents ef58902 + 5091ef6 commit bff0a98

33 files changed

Lines changed: 992 additions & 305 deletions

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Option to group all connection tabs in one window instead of separate windows per connection
13+
14+
## [0.27.1] - 2026-04-01
15+
16+
### Fixed
17+
18+
- Table queries incorrectly prefixed with connection username as schema name on non-schema databases (MySQL, MariaDB, ClickHouse, Redis, etc.), causing "Table 'username.table' doesn't exist" errors when opening a second table tab
19+
20+
## [0.27.0] - 2026-03-31
21+
22+
### Added
23+
1224
- Option to prompt for database password on every connection instead of saving to Keychain
1325
- Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields)
1426
- Multi-line support for Raw SQL filter field (Option+Enter for newline)
@@ -1117,7 +1129,9 @@ TablePro is a native macOS database client built with SwiftUI and AppKit, design
11171129
- Custom SQL query templates
11181130
- Performance optimized for large datasets
11191131

1120-
[Unreleased]: https://github.com/TableProApp/TablePro/compare/v0.26.0...HEAD
1132+
[Unreleased]: https://github.com/TableProApp/TablePro/compare/v0.27.1...HEAD
1133+
[0.27.1]: https://github.com/TableProApp/TablePro/compare/v0.27.0...v0.27.1
1134+
[0.27.0]: https://github.com/TableProApp/TablePro/compare/v0.26.0...v0.27.0
11211135
[0.26.0]: https://github.com/TableProApp/TablePro/compare/v0.25.0...v0.26.0
11221136
[0.25.0]: https://github.com/TableProApp/TablePro/compare/v0.24.2...v0.25.0
11231137
[0.24.2]: https://github.com/TableProApp/TablePro/compare/v0.24.1...v0.24.2

Plugins/EtcdDriverPlugin/EtcdHttpClient.swift

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -574,28 +574,31 @@ internal final class EtcdHttpClient: @unchecked Sendable {
574574

575575
group.addTask {
576576
let data: Data = try await withCheckedThrowingContinuation { continuation in
577-
self.lock.lock()
578-
guard self.sessionGeneration == generation, let currentSession = self.session else {
579-
self.lock.unlock()
580-
continuation.resume(throwing: EtcdError.notConnected)
581-
return
582-
}
583-
let task = currentSession.dataTask(with: request) { data, _, error in
584-
if let error {
585-
// URLError.cancelled is expected when we cancel after timeout
586-
if (error as? URLError)?.code == .cancelled {
587-
continuation.resume(returning: data ?? Data())
588-
} else {
589-
continuation.resume(throwing: error)
577+
let result: (session: URLSession, task: URLSessionDataTask)? = self.lock.withLock {
578+
guard self.sessionGeneration == generation, let currentSession = self.session else {
579+
return nil
580+
}
581+
let dataTask = currentSession.dataTask(with: request) { data, _, error in
582+
if let error {
583+
// URLError.cancelled is expected when we cancel after timeout
584+
if (error as? URLError)?.code == .cancelled {
585+
continuation.resume(returning: data ?? Data())
586+
} else {
587+
continuation.resume(throwing: error)
588+
}
589+
return
590590
}
591-
return
591+
continuation.resume(returning: data ?? Data())
592592
}
593-
continuation.resume(returning: data ?? Data())
593+
self.currentTask = dataTask
594+
return (currentSession, dataTask)
594595
}
595-
self.currentTask = task
596-
self.lock.unlock()
597-
collectedData.setTask(task)
598-
task.resume()
596+
guard let result else {
597+
continuation.resume(throwing: EtcdError.notConnected)
598+
return
599+
}
600+
collectedData.setTask(result.task)
601+
result.task.resume()
599602
}
600603
return Self.parseWatchEvents(from: data)
601604
}
@@ -1062,10 +1065,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
10621065
return
10631066
}
10641067

1065-
guard let identity = identityRef as? SecIdentity else {
1066-
completionHandler(.cancelAuthenticationChallenge, nil)
1067-
return
1068-
}
1068+
let identity = unsafeBitCast(identityRef, to: SecIdentity.self)
10691069
let credential = URLCredential(
10701070
identity: identity,
10711071
certificates: nil,
@@ -1122,7 +1122,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
11221122
}
11231123

11241124
// Export to PKCS#12
1125-
var exportItems: CFArray?
1125+
let exportItems: CFArray? = nil
11261126
guard let identity = createIdentity(certificate: cert, privateKey: privateKey) else {
11271127
return nil
11281128
}

TablePro.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2093,7 +2093,7 @@
20932093
CODE_SIGN_IDENTITY = "Apple Development";
20942094
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
20952095
CODE_SIGN_STYLE = Automatic;
2096-
CURRENT_PROJECT_VERSION = 52;
2096+
CURRENT_PROJECT_VERSION = 54;
20972097
DEAD_CODE_STRIPPING = YES;
20982098
DEVELOPMENT_TEAM = D7HJ5TFYCU;
20992099
ENABLE_APP_SANDBOX = NO;
@@ -2118,7 +2118,7 @@
21182118
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
21192119
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
21202120
MACOSX_DEPLOYMENT_TARGET = 14.0;
2121-
MARKETING_VERSION = 0.26.0;
2121+
MARKETING_VERSION = 0.27.1;
21222122
OTHER_LDFLAGS = (
21232123
"-Wl,-w",
21242124
"-force_load",
@@ -2165,7 +2165,7 @@
21652165
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
21662166
CODE_SIGN_STYLE = Automatic;
21672167
COPY_PHASE_STRIP = YES;
2168-
CURRENT_PROJECT_VERSION = 52;
2168+
CURRENT_PROJECT_VERSION = 54;
21692169
DEAD_CODE_STRIPPING = YES;
21702170
DEPLOYMENT_POSTPROCESSING = YES;
21712171
DEVELOPMENT_TEAM = D7HJ5TFYCU;
@@ -2191,7 +2191,7 @@
21912191
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
21922192
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
21932193
MACOSX_DEPLOYMENT_TARGET = 14.0;
2194-
MARKETING_VERSION = 0.26.0;
2194+
MARKETING_VERSION = 0.27.1;
21952195
OTHER_LDFLAGS = (
21962196
"-Wl,-w",
21972197
"-force_load",

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ extension AppDelegate {
316316

317317
private func openNewConnectionWindow(for connection: DatabaseConnection) {
318318
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
319-
if hadExistingMain {
319+
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
320320
NSWindow.allowsAutomaticWindowTabbing = false
321321
}
322322
let payload = EditorTabPayload(connectionId: connection.id)

TablePro/AppDelegate+FileOpen.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ extension AppDelegate {
176176
}
177177

178178
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
179-
if hadExistingMain {
179+
if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs {
180180
NSWindow.allowsAutomaticWindowTabbing = false
181181
}
182182

@@ -258,7 +258,7 @@ extension AppDelegate {
258258
fileOpenLogger.info("Installed plugin '\(entry.name)' from Finder")
259259

260260
UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab")
261-
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
261+
NotificationCenter.default.post(name: .openSettingsWindow, object: nil)
262262
} catch {
263263
fileOpenLogger.error("Plugin install failed: \(error.localizedDescription)")
264264
AlertHelper.showErrorSheet(

TablePro/AppDelegate+WindowConfig.swift

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@ extension AppDelegate {
8080
} catch {
8181
windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)")
8282

83-
for window in NSApp.windows where self.isMainWindow(window) {
83+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
8484
window.close()
8585
}
86-
self.openWelcomeWindow()
86+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
87+
self.openWelcomeWindow()
88+
}
8789
}
8890
}
8991
}
@@ -251,18 +253,27 @@ extension AppDelegate {
251253
// If no code opened this window (pendingId is nil), this is a
252254
// SwiftUI WindowGroup state restoration — not a window we created.
253255
// Hide it (orderOut, not close) to break the close→restore loop.
256+
// Exception: if the window is already part of a tab group, it was
257+
// attached by our addTabbedWindow call — not a restoration orphan.
258+
// Ordering it out would crash NSWindowStackController.
254259
if pendingId == nil && !isAutoReconnecting {
255260
configuredWindows.insert(windowId)
261+
if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 {
262+
// Already in a tab group — leave it alone
263+
return
264+
}
256265
window.orderOut(nil)
257266
return
258267
}
259268

260269
let existingIdentifier = NSApp.windows
261270
.first { $0 !== window && isMainWindow($0) && $0.isVisible }?
262271
.tabbingIdentifier
272+
let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs }
263273
let resolvedIdentifier = TabbingIdentifierResolver.resolve(
264274
pendingConnectionId: pendingId,
265-
existingIdentifier: existingIdentifier
275+
existingIdentifier: existingIdentifier,
276+
groupAllConnections: groupAll
266277
)
267278
window.tabbingIdentifier = resolvedIdentifier
268279
configuredWindows.insert(windowId)
@@ -273,10 +284,25 @@ extension AppDelegate {
273284

274285
// Explicitly attach to existing tab group — automatic tabbing
275286
// doesn't work when tabbingIdentifier is set after window creation.
276-
if let existingWindow = NSApp.windows.first(where: {
277-
$0 !== window && isMainWindow($0) && $0.isVisible
278-
&& $0.tabbingIdentifier == resolvedIdentifier
279-
}) {
287+
let matchingWindow: NSWindow?
288+
if groupAll {
289+
// When grouping all connections, attach to any visible main window
290+
// and normalize all existing windows' tabbingIdentifiers so future
291+
// windows also match (not just the first one found).
292+
let existingMainWindows = NSApp.windows.filter {
293+
$0 !== window && isMainWindow($0) && $0.isVisible
294+
}
295+
for existing in existingMainWindows {
296+
existing.tabbingIdentifier = resolvedIdentifier
297+
}
298+
matchingWindow = existingMainWindows.first
299+
} else {
300+
matchingWindow = NSApp.windows.first {
301+
$0 !== window && isMainWindow($0) && $0.isVisible
302+
&& $0.tabbingIdentifier == resolvedIdentifier
303+
}
304+
}
305+
if let existingWindow = matchingWindow {
280306
let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow
281307
targetWindow.addTabbedWindow(window, ordered: .above)
282308
window.makeKeyAndOrderFront(nil)
@@ -339,18 +365,21 @@ extension AppDelegate {
339365
window.close()
340366
}
341367
} catch is CancellationError {
342-
for window in NSApp.windows where self.isMainWindow(window) {
368+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
343369
window.close()
344370
}
345-
self.openWelcomeWindow()
371+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
372+
self.openWelcomeWindow()
373+
}
346374
} catch {
347375
windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)")
348376

349-
for window in NSApp.windows where self.isMainWindow(window) {
377+
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
350378
window.close()
351379
}
352-
353-
self.openWelcomeWindow()
380+
if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) {
381+
self.openWelcomeWindow()
382+
}
354383
}
355384
}
356385
}

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ protocol DatabaseDriver: AnyObject {
160160
/// Protocol for drivers that support schema/search_path switching.
161161
/// Eliminates repeated as? casting chains in DatabaseManager.
162162
protocol SchemaSwitchable: DatabaseDriver {
163-
var currentSchema: String { get }
164-
var escapedSchema: String { get }
163+
var currentSchema: String? { get }
164+
var escapedSchema: String? { get }
165165
func switchSchema(to schema: String) async throws
166166
}
167167

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -208,44 +208,9 @@ final class DatabaseManager {
208208
}
209209

210210
// Run post-connect actions declared by the plugin
211-
let postConnectActions = PluginMetadataRegistry.shared.snapshot(
212-
forTypeId: connection.type.pluginTypeId
213-
)?.postConnectActions ?? []
214-
215-
for action in postConnectActions {
216-
switch action {
217-
case .selectDatabaseFromLastSession:
218-
// Restore saved database (e.g. MSSQL) only when no explicit database is configured
219-
if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
220-
let adapter = driver as? PluginDriverAdapter,
221-
let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) {
222-
do {
223-
try await adapter.switchDatabase(to: savedDb)
224-
activeSessions[connection.id]?.currentDatabase = savedDb
225-
} catch {
226-
Self.logger.warning("Failed to restore saved database '\(savedDb, privacy: .public)' for \(connection.id): \(error.localizedDescription, privacy: .public)")
227-
}
228-
}
229-
case .selectDatabaseFromConnectionField(let fieldId):
230-
// Select database from a connection field (e.g. Redis database index).
231-
// Check additionalFields first, then legacy dedicated properties, then
232-
// fall back to parsing the main database field.
233-
let initialDb: Int
234-
if let fieldValue = resolvedConnection.additionalFields[fieldId], let parsed = Int(fieldValue) {
235-
initialDb = parsed
236-
} else if fieldId == "redisDatabase", let legacy = resolvedConnection.redisDatabase {
237-
initialDb = legacy
238-
} else if let fallback = Int(resolvedConnection.database) {
239-
initialDb = fallback
240-
} else {
241-
initialDb = 0
242-
}
243-
if initialDb != 0 {
244-
try? await (driver as? PluginDriverAdapter)?.switchDatabase(to: String(initialDb))
245-
}
246-
activeSessions[connection.id]?.currentDatabase = String(initialDb)
247-
}
248-
}
211+
await executePostConnectActions(
212+
for: connection, resolvedConnection: resolvedConnection, driver: driver
213+
)
249214

250215
// Batch all session mutations into a single write to fire objectWillChange once
251216
if var session = activeSessions[connection.id] {
@@ -301,6 +266,47 @@ final class DatabaseManager {
301266
}
302267
}
303268

269+
private func executePostConnectActions(
270+
for connection: DatabaseConnection,
271+
resolvedConnection: DatabaseConnection,
272+
driver: DatabaseDriver
273+
) async {
274+
let postConnectActions = PluginMetadataRegistry.shared.snapshot(
275+
forTypeId: connection.type.pluginTypeId
276+
)?.postConnectActions ?? []
277+
278+
for action in postConnectActions {
279+
switch action {
280+
case .selectDatabaseFromLastSession:
281+
if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
282+
let adapter = driver as? PluginDriverAdapter,
283+
let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) {
284+
do {
285+
try await adapter.switchDatabase(to: savedDb)
286+
activeSessions[connection.id]?.currentDatabase = savedDb
287+
} catch {
288+
Self.logger.warning("Failed to restore saved database '\(savedDb, privacy: .public)' for \(connection.id): \(error.localizedDescription, privacy: .public)")
289+
}
290+
}
291+
case .selectDatabaseFromConnectionField(let fieldId):
292+
let initialDb: Int
293+
if let fieldValue = resolvedConnection.additionalFields[fieldId], let parsed = Int(fieldValue) {
294+
initialDb = parsed
295+
} else if fieldId == "redisDatabase", let legacy = resolvedConnection.redisDatabase {
296+
initialDb = legacy
297+
} else if let fallback = Int(resolvedConnection.database) {
298+
initialDb = fallback
299+
} else {
300+
initialDb = 0
301+
}
302+
if initialDb != 0 {
303+
try? await (driver as? PluginDriverAdapter)?.switchDatabase(to: String(initialDb))
304+
}
305+
activeSessions[connection.id]?.currentDatabase = String(initialDb)
306+
}
307+
}
308+
}
309+
304310
/// Switch to an existing session
305311
func switchToSession(_ sessionId: UUID) {
306312
guard activeSessions[sessionId] != nil else { return }
@@ -1010,8 +1016,9 @@ final class DatabaseManager {
10101016
// Query the actual constraint name from pg_constraint
10111017
let escapedTable = tableName.replacingOccurrences(of: "'", with: "''")
10121018
let schema: String
1013-
if let schemaDriver = driver as? SchemaSwitchable {
1014-
schema = schemaDriver.escapedSchema
1019+
if let schemaDriver = driver as? SchemaSwitchable,
1020+
let escaped = schemaDriver.escapedSchema {
1021+
schema = escaped
10151022
} else {
10161023
schema = "public"
10171024
}

0 commit comments

Comments
 (0)