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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Oracle Database support via OCI (Oracle Call Interface)
- CockroachDB database support (PostgreSQL wire-compatible via LibPQ)
- Add database and schema switching for PostgreSQL connections via ⌘K

### Fixed

- Fix memory leak where session state objects were recreated on every tab open due to SwiftUI `@State` init trap, causing 785MB usage at 5 tabs with 734MB retained after closing
- Fix per-cell field editor allocation in DataGrid creating 180+ NSTextView instances instead of sharing one
- Fix NSEvent monitor not removed on all popover dismissal paths in connection switcher
- Fix race condition in FreeTDS `disconnect()` where `dbproc` was set to nil without holding the lock
- Fix data race in `MainContentCoordinator.deinit` reading `nonisolated(unsafe)` flags from arbitrary threads
- Fix JSON encoding and file I/O blocking the main thread in TabStateStorage

## [0.14.0] - 2026-03-05

### Added
Expand Down
18 changes: 12 additions & 6 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

private static let databaseURLSchemes: Set<String> = [
"postgresql", "postgres", "mysql", "mariadb", "sqlite",
"mongodb", "redis", "rediss", "redshift"
"mongodb", "redis", "rediss", "redshift", "cockroachdb"
]

func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
Expand Down Expand Up @@ -391,16 +391,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor
private func handlePostConnectionActions(_ parsed: ParsedConnectionURL, connectionId: UUID) {
Task { @MainActor in
// Allow SwiftUI to finish processing the new connection before acting on it
try? await Task.sleep(for: .milliseconds(300))

// Switch schema if specified (PostgreSQL/Redshift only)
if let schema = parsed.schema,
parsed.type == .postgresql || parsed.type == .redshift {
parsed.type == .postgresql || parsed.type == .redshift || parsed.type == .cockroachdb {
NotificationCenter.default.post(
name: .switchSchemaFromURL,
object: nil,
userInfo: ["connectionId": connectionId, "schema": schema]
)
// Wait for schema switch to propagate through SwiftUI state before opening table
try? await Task.sleep(for: .milliseconds(500))
}

Expand All @@ -416,7 +418,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {

// Apply filter after table loads
if parsed.filterColumn != nil || parsed.filterCondition != nil {
try? await Task.sleep(for: .milliseconds(800))
// Wait for table data to load before applying filter via notification
try? await Task.sleep(for: .milliseconds(500))
NotificationCenter.default.post(
name: .applyURLFilter,
object: nil,
Expand Down Expand Up @@ -503,10 +506,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {

private func scheduleWelcomeWindowSuppression() {
Task { @MainActor [weak self] in
// Single check after a short delay for window creation
// Wait for SwiftUI to create the main window after file-open triggers connection
try? await Task.sleep(for: .milliseconds(300))
self?.closeWelcomeWindowIfMainExists()
// One final check after windows settle
// Second check after windows fully settle (animations, state restoration)
try? await Task.sleep(for: .milliseconds(700))
guard let self else { return }
self.closeWelcomeWindowIfMainExists()
Expand Down Expand Up @@ -536,6 +539,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

private func postSQLFilesWhenReady(urls: [URL]) {
Task { @MainActor [weak self] in
// Brief delay to let the main window become key after connection completes
try? await Task.sleep(for: .milliseconds(100))
if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) {
Self.logger.warning("postSQLFilesWhenReady: no key main window, posting anyway")
Expand Down Expand Up @@ -768,7 +772,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

private func configureWelcomeWindow() {
// Wait for SwiftUI to create the welcome window, then configure it
// SwiftUI creates the welcome window asynchronously after app launch.
// Poll up to 5 times (250ms total) waiting for it to appear so we can
// configure AppKit-level style properties (hide miniaturize/zoom buttons, etc.).
Task { @MainActor [weak self] in
for _ in 0 ..< 5 {
guard let self else { return }
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "cockroachdb.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions TablePro/Assets.xcassets/oracle-icon.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "oracle.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
1 change: 1 addition & 0 deletions TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 41 additions & 4 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ struct ContentView: View {
@State private var connectionToDelete: DatabaseConnection?
@State private var showDeleteConfirmation = false
@State private var hasLoaded = false
@State private var rightPanelState = RightPanelState()
@State private var rightPanelState: RightPanelState?
@State private var sessionState: SessionStateFactory.SessionState?
@State private var inspectorContext = InspectorContext.empty
@State private var windowTitle: String
/// Per-window sidebar selection (independent of other window-tabs)
Expand Down Expand Up @@ -110,6 +111,15 @@ struct ContentView: View {
currentSession = DatabaseManager.shared.activeSessions[connectionId]
columnVisibility = currentSession != nil ? .all : .detailOnly
if let session = currentSession {
if rightPanelState == nil {
rightPanelState = RightPanelState()
}
if sessionState == nil {
sessionState = SessionStateFactory.create(
connection: session.connection,
payload: payload
)
}
AppState.shared.isConnected = true
AppState.shared.isReadOnly = session.connection.isReadOnly
AppState.shared.isMongoDB = session.connection.type == .mongodb
Expand All @@ -132,12 +142,26 @@ struct ContentView: View {
guard let newSession = sessions[sid] else {
// Session was removed (disconnected)
if currentSession?.id == sid {
rightPanelState?.teardown()
rightPanelState = nil
sessionState?.coordinator.teardown()
sessionState = nil
currentSession = nil
columnVisibility = .detailOnly
AppState.shared.isConnected = false
AppState.shared.isReadOnly = false
AppState.shared.isMongoDB = false
AppState.shared.isRedis = false

// Close all native tab windows for this connection and
// force AppKit to deallocate them instead of pooling.
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
DispatchQueue.main.async {
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
window.isReleasedWhenClosed = true
window.close()
}
}
}
return
}
Expand All @@ -146,6 +170,15 @@ struct ContentView: View {
return
}
currentSession = newSession
if rightPanelState == nil {
rightPanelState = RightPanelState()
}
if sessionState == nil {
sessionState = SessionStateFactory.create(
connection: newSession.connection,
payload: payload
)
}
AppState.shared.isConnected = true
AppState.shared.isReadOnly = newSession.connection.isReadOnly
AppState.shared.isMongoDB = newSession.connection.type == .mongodb
Expand All @@ -172,7 +205,7 @@ struct ContentView: View {

@ViewBuilder
private var mainContent: some View {
if let currentSession = currentSession {
if let currentSession = currentSession, let rightPanelState, let sessionState {
NavigationSplitView(columnVisibility: $columnVisibility) {
// MARK: - Sidebar (Left) - Table Browser
VStack(spacing: 0) {
Expand Down Expand Up @@ -204,7 +237,12 @@ struct ContentView: View {
pendingDeletes: sessionPendingDeletesBinding,
tableOperationOptions: sessionTableOperationOptionsBinding,
inspectorContext: $inspectorContext,
rightPanelState: rightPanelState
rightPanelState: rightPanelState,
tabManager: sessionState.tabManager,
changeManager: sessionState.changeManager,
filterStateManager: sessionState.filterStateManager,
toolbarState: sessionState.toolbarState,
coordinator: sessionState.coordinator
)
.inspector(isPresented: Bindable(rightPanelState).isPresented) {
UnifiedRightPanelView(
Expand All @@ -216,7 +254,6 @@ struct ContentView: View {
.frame(minWidth: 280, maxWidth: 500)
.inspectorColumnWidth(min: 280, ideal: 320, max: 500)
}
.id(currentSession.id)
}
.navigationTitle(windowTitle)
.navigationSubtitle(currentSession.connection.name)
Expand Down
14 changes: 12 additions & 2 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ final class SQLCompletionProvider {
"ENGINE", "CHARSET", "COLLATE", "COMMENT",
"AUTO_INCREMENT", "ROW_FORMAT", "DEFAULT CHARSET",
])
case .postgresql, .redshift:
case .postgresql, .redshift, .cockroachdb:
items += filterKeywords([
"TABLESPACE", "INHERITS", "PARTITION BY",
"WITH", "WITHOUT OIDS",
Expand Down Expand Up @@ -491,7 +491,7 @@ final class SQLCompletionProvider {
"BINARY", "VARBINARY",
]

case .postgresql, .redshift:
case .postgresql, .redshift, .cockroachdb:
types += [
"BIGSERIAL", "SERIAL", "SMALLSERIAL",
"DOUBLE PRECISION", "MONEY",
Expand All @@ -512,6 +512,16 @@ final class SQLCompletionProvider {
"ROWVERSION", "HIERARCHYID",
]

case .oracle:
types += [
"NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE",
"VARCHAR2", "NVARCHAR2", "NCHAR", "NCLOB",
"CLOB", "LONG", "RAW", "LONG RAW", "BFILE",
"TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE",
"INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND",
"ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY",
]

case .sqlite:
types += [
"BLOB",
Expand Down
14 changes: 9 additions & 5 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ struct SQLStatementGenerator {
/// Get placeholder syntax for the database type
private func placeholder(at index: Int) -> String {
switch databaseType {
case .postgresql, .redshift:
case .postgresql, .redshift, .cockroachdb:
return "$\(index + 1)" // PostgreSQL uses $1, $2, etc.
case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql:
return "?" // MySQL, MariaDB, SQLite, MongoDB, and MSSQL use ?
case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle:
return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, and Oracle use ?
}
}

Expand Down Expand Up @@ -297,7 +297,9 @@ struct SQLStatementGenerator {
sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) LIMIT 1"
case .mssql:
sql = "UPDATE TOP (1) \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)"
case .postgresql, .redshift, .mongodb, .redis:
case .oracle:
sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1"
case .postgresql, .redshift, .cockroachdb, .mongodb, .redis:
sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)"
}

Expand Down Expand Up @@ -371,7 +373,9 @@ struct SQLStatementGenerator {
sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) LIMIT 1"
case .mssql:
sql = "DELETE TOP (1) FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)"
case .postgresql, .redshift, .mongodb, .redis:
case .oracle:
sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) AND ROWNUM = 1"
case .postgresql, .redshift, .cockroachdb, .mongodb, .redis:
sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)"
}

Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/Database/COracle/COracle.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// COracle.h
// TablePro
//
// Umbrella header for Oracle OCI C bridge.
// Requires Oracle Instant Client headers in include/.
//

#include "include/oci_stub.h"
Loading