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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Option+Return in the Quick Switcher opens the table in a new tab; right-click a result to open its structure, copy the name, or copy the query.
- Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab.
- `.psql` and `.pgsql` files now open in the SQL editor like `.sql`: Finder double-click, the open and save panels, and linked SQL folders all accept them. (#1641)
- Session restore now brings back each tab's applied sort, current page, cursor position, and column widths, along with the connection's active database and schema. Tabs save in the background every 30 seconds and a crash still recovers your last session and reopens its connections. (#1673)

### Changed

Expand All @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload.
- Switching PostgreSQL schemas now sets the search path to just the selected schema instead of also keeping "public" on it. Unqualified references to objects in "public", such as extension functions, need a "public." prefix while another schema is selected. (#1662)
- The inspector panel can now be resized freely by dragging its divider instead of being capped at a fixed width.
- TablePro now reopens your last session on launch by default instead of showing the welcome screen. Installs still on the previous default move over once; change it any time under Settings > General > Startup Behavior. (#1673)

### Fixed

Expand Down
9 changes: 9 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationWillTerminate(_ notification: Notification) {
persistOpenConnectionsForRecovery()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the recovery list before crashes can happen

Saving LastOpenConnections only from applicationWillTerminate does not support the crash/force-quit recovery this change adds: crashes and SIGKILL-style force quits never run this method, so the recovery list remains stale from a previous clean quit. For example, if the user opens a different connection after launch and the app crashes, the periodic tab files may be up to date but startup will not know to reopen that connection.

Useful? React with 👍 / 👎.

LinkedFolderWatcher.shared.stop()
SQLFolderWatcher.shared.stop()
SSHTunnelManager.shared.terminateAllProcessesSync()
CloudflareTunnelManager.shared.terminateAllProcessesSync()
}

private func persistOpenConnectionsForRecovery() {
var seen = Set<UUID>()
let connectionIds = MainContentCoordinator.activeCoordinators.values
.map(\.connectionId)
.filter { seen.insert($0).inserted }
LastOpenConnectionsStorage.shared.save(connectionIds: connectionIds)
}

@objc func handleSystemDidWake(_ notification: Notification) {
SQLFolderWatcher.shared.reload()
}
Expand Down
38 changes: 37 additions & 1 deletion TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,46 @@ internal final class AppLaunchCoordinator {
guard intents.isEmpty else { return }

let general = AppSettingsStorage.shared.loadGeneral()
if general.startupBehavior == .showWelcome {
switch general.startupBehavior {
case .showWelcome:
for window in NSApp.windows where Self.isMainWindow(window) {
window.close()
}
case .reopenLast:
reopenLastSessionIfArchiveMissing()
}
}

private func reopenLastSessionIfArchiveMissing() {
guard !NSApp.windows.contains(where: { Self.isMainWindow($0) }) else { return }

let connectionIds = LastOpenConnectionsStorage.shared.load()
guard !connectionIds.isEmpty else { return }

let connectionsById = Dictionary(
ConnectionStorage.shared.loadConnections().map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
var openedAny = false
for connectionId in connectionIds {
guard let connection = connectionsById[connectionId] else { continue }
WindowManager.shared.openTab(
payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault)
)
Comment on lines +151 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start connecting crash-recovered sessions

In the crash-recovery path where AppKit restored no windows, activeSessions is empty and this only creates a window payload. Unlike TabRouter.openConnection and TabWindowRestoration, nothing calls DatabaseManager.ensureConnected, so MainSplitViewController stays on ConnectingStateView and never builds MainContentView to run restoreFromDisk; recovered sessions after a crash/force-quit therefore sit on the spinner instead of reopening tabs. Please kick off the normal async connection path for each stored connection after opening the window.

Useful? React with 👍 / 👎.

openedAny = true
Task {
do {
try await DatabaseManager.shared.ensureConnected(connection)
} catch {
Self.logger.error(
"[restore] reopen connect failed for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
}
}

if openedAny {
WindowOpener.shared.orderOutWelcome()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// RestorationGroupRegistry.swift
// TablePro
//

import Foundation

@MainActor
enum RestorationGroupRegistry {
struct WindowGroup {
let tabs: [QueryTab]
let selectedTabId: UUID?
}

private static var groups: [UUID: WindowGroup] = [:]
private static let entryLifetime: Duration = .seconds(10)

static func register(_ group: WindowGroup, for payloadId: UUID) {
groups[payloadId] = group
Task { @MainActor in
try? await Task.sleep(for: entryLifetime)
groups.removeValue(forKey: payloadId)
}
}

static func consume(for payloadId: UUID?) -> WindowGroup? {
guard let payloadId else { return nil }
return groups.removeValue(forKey: payloadId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension TabPersistenceCoordinator {
clearSavedState()
} else {
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
saveNow(tabs: aggregatedTabs, selectedTabId: selectedId)
saveNow(windowedTabs: aggregatedTabs, selectedTabId: selectedId)
}
}

Expand All @@ -27,7 +27,7 @@ extension TabPersistenceCoordinator {
saveNowSync(tabs: [], selectedTabId: nil)
} else {
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
saveNowSync(tabs: aggregatedTabs, selectedTabId: selectedId)
saveNowSync(windowedTabs: aggregatedTabs, selectedTabId: selectedId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ internal struct RestoreResult {
let tabs: [QueryTab]
let selectedTabId: UUID?
let source: RestoreSource
var lastActiveDatabase: String? = nil
var lastActiveSchema: String? = nil

enum RestoreSource {
case disk
Expand All @@ -32,29 +34,53 @@ internal final class TabPersistenceCoordinator {
// MARK: - Save

internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) {
let nonPreviewTabs = tabs.filter { !$0.isPreview }
guard !nonPreviewTabs.isEmpty else {
saveNow(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId)
}

internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) {
guard !windowedTabs.isEmpty else {
clearSavedState()
return
}
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
? selectedTabId : nonPreviewTabs.first?.id
scheduleSave(tabs: persisted, selectedTabId: normalizedSelectedId)
let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter preview tabs before writing restore state

When persistence runs while a temporary preview tab is open, this now serializes every windowedTabs entry instead of the previous non-preview subset. PersistedTab does not store isPreview, so a preview tab restored from disk comes back as a normal tab; if the window only contains a preview tab, the old saved state is no longer cleared either. Please filter out tab.isPreview before the empty check, persisted map, and selected-tab normalization in both async and sync save paths.

Useful? React with 👍 / 👎.

let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId })
? selectedTabId : windowedTabs.first?.tab.id
let active = currentActiveDatabaseAndSchema()
scheduleSave(
tabs: persisted,
selectedTabId: normalizedSelectedId,
lastActiveDatabase: active.database,
lastActiveSchema: active.schema
)
}

internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) {
let nonPreviewTabs = tabs.filter { !$0.isPreview }
guard !nonPreviewTabs.isEmpty else {
saveNowSync(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId)
}

internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) {
guard !windowedTabs.isEmpty else {
saveTask?.cancel()
saveTask = nil
TabDiskActor.clearSync(connectionId: connectionId)
return
}
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
? selectedTabId : nonPreviewTabs.first?.id
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId)
let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) }
let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId })
? selectedTabId : windowedTabs.first?.tab.id
let active = currentActiveDatabaseAndSchema()
TabDiskActor.saveSync(
connectionId: connectionId,
tabs: persisted,
selectedTabId: normalizedSelectedId,
lastActiveDatabase: active.database,
lastActiveSchema: active.schema
)
}

private func currentActiveDatabaseAndSchema() -> (database: String?, schema: String?) {
guard let session = DatabaseManager.shared.session(for: connectionId) else { return (nil, nil) }
return (session.currentDatabase, session.currentSchema)
}

// MARK: - Clear
Expand All @@ -70,18 +96,31 @@ internal final class TabPersistenceCoordinator {

// MARK: - Private save scheduling

private func scheduleSave(tabs: [PersistedTab], selectedTabId: UUID?) {
private func scheduleSave(
tabs: [PersistedTab],
selectedTabId: UUID?,
lastActiveDatabase: String?,
lastActiveSchema: String?
) {
saveTask?.cancel()
let connId = connectionId
let tabsCopy = tabs
let selectedId = selectedTabId
let activeDatabase = lastActiveDatabase
let activeSchema = lastActiveSchema
Self.logger.debug("[persist] saveNow queued tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public)")

saveTask = Task {
guard !Task.isCancelled else { return }
let t0 = Date()
do {
try await TabDiskActor.shared.save(connectionId: connId, tabs: tabsCopy, selectedTabId: selectedId)
try await TabDiskActor.shared.save(
connectionId: connId,
tabs: tabsCopy,
selectedTabId: selectedId,
lastActiveDatabase: activeDatabase,
lastActiveSchema: activeSchema
)
Self.logger.debug("[persist] saveNow written tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
} catch is CancellationError {
return
Expand Down Expand Up @@ -114,30 +153,9 @@ internal final class TabPersistenceCoordinator {
return RestoreResult(
tabs: restoredTabs,
selectedTabId: state.selectedTabId,
source: .disk
)
}

// MARK: - Private

private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab {
let persistedQuery: String
if (tab.content.query as NSString).length > TabQueryContent.maxPersistableQuerySize {
persistedQuery = ""
} else {
persistedQuery = tab.content.query
}

return PersistedTab(
id: tab.id,
title: tab.title,
query: persistedQuery,
tabType: tab.tabType,
tableName: tab.tableContext.tableName,
isView: tab.tableContext.isView,
databaseName: tab.tableContext.databaseName,
schemaName: tab.tableContext.schemaName,
sourceFileURL: tab.content.sourceFileURL
source: .disk,
lastActiveDatabase: state.lastActiveDatabase,
lastActiveSchema: state.lastActiveSchema
)
}
}
12 changes: 12 additions & 0 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class AppSettingsStorage {
static let sync = "com.TablePro.settings.sync"
static let mcp = "com.TablePro.settings.mcp"
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast"
}

init(userDefaults: UserDefaults = .standard) {
Expand All @@ -48,6 +49,17 @@ final class AppSettingsStorage {
save(settings, key: Keys.general)
}

func migrateStartupBehaviorToReopenLastIfNeeded() {
guard !defaults.bool(forKey: Keys.startupReopenMigration) else { return }
defaults.set(true, forKey: Keys.startupReopenMigration)

guard defaults.data(forKey: Keys.general) != nil else { return }
var general = loadGeneral()
guard general.startupBehavior == .showWelcome else { return }
general.startupBehavior = .reopenLast

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve an explicit Show Welcome preference

For existing installs that already have GeneralSettings saved with .showWelcome—for example because the user explicitly selected it, or changed another general setting while leaving startup behavior alone—this migration rewrites that saved preference to .reopenLast on the next launch. That makes TablePro reopen prior connections instead of showing the welcome screen despite the stored setting; the default flip should only affect installs with no saved general settings, or otherwise distinguish an unset value from an explicit user choice.

Useful? React with 👍 / 👎.

saveGeneral(general)
}

// MARK: - Appearance Settings

func loadAppearance() -> AppearanceSettings {
Expand Down
60 changes: 60 additions & 0 deletions TablePro/Core/Storage/LastOpenConnectionsStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// LastOpenConnectionsStorage.swift
// TablePro
//
// Records which connections had open windows at last quit so the
// "Reopen Last Session" startup behavior can recover after a crash,
// where AppKit's window-restoration archive is never written.
//

import Foundation
import os

@MainActor
final class LastOpenConnectionsStorage {
static let shared = LastOpenConnectionsStorage()

private static let logger = Logger(subsystem: "com.TablePro", category: "LastOpenConnections")

private let fileURL: URL

private convenience init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
self.init(directory: appSupport.appendingPathComponent("TablePro", isDirectory: true))
}

init(directory: URL) {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("LastOpenConnections.json")
}

func save(connectionIds: [UUID]) {
guard !connectionIds.isEmpty else {
clear()
return
}
do {
let data = try JSONEncoder().encode(connectionIds)
try data.write(to: fileURL, options: .atomic)
} catch {
Self.logger.error("Failed to save last open connections: \(error.localizedDescription, privacy: .public)")
}
}

func load() -> [UUID] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([UUID].self, from: data)
} catch {
Self.logger.error("Failed to load last open connections: \(error.localizedDescription, privacy: .public)")
return []
}
}

func clear() {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
try? FileManager.default.removeItem(at: fileURL)
}
}
Loading
Loading