From aa3e5628dadc5331b98f12c971c4de618e383510 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 20:35:52 +0700 Subject: [PATCH 1/6] feat(coordinator): restore session view state and recover after crashes (#1673) --- CHANGELOG.md | 2 + TablePro/AppDelegate.swift | 9 + .../Infrastructure/AppLaunchCoordinator.swift | 20 ++- .../RestorationGroupRegistry.swift | 30 ++++ ...ersistenceCoordinator+AggregatedSave.swift | 4 +- .../TabPersistenceCoordinator.swift | 92 +++++++---- .../Storage/LastOpenConnectionsStorage.swift | 60 +++++++ TablePro/Core/Storage/TabDiskActor.swift | 41 ++++- TablePro/Models/Query/QueryTab.swift | 45 ++++- TablePro/Models/Query/QueryTabState.swift | 82 +++++++++- .../Models/Settings/GeneralSettings.swift | 4 +- .../Main/Child/MainEditorContentView.swift | 1 + ...ainContentCoordinator+TableFirstLoad.swift | 35 +++- .../MainContentView+EventHandlers.swift | 2 +- .../Extensions/MainContentView+Setup.swift | 140 ++++++++++------ .../Views/Main/MainContentCoordinator.swift | 69 +++++++- .../MultiWindowRestorationTests.swift | 68 ++++++++ .../Services/PersistedTabRoundTripTests.swift | 154 ++++++++++++++++++ docs/customization/settings.mdx | 4 +- docs/features/tabs.mdx | 15 +- 20 files changed, 754 insertions(+), 123 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift create mode 100644 TablePro/Core/Storage/LastOpenConnectionsStorage.swift create mode 100644 TableProTests/Core/Services/MultiWindowRestorationTests.swift create mode 100644 TableProTests/Core/Services/PersistedTabRoundTripTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f3779a5..2b82d839a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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. +- New installs now reopen your last session on launch by default instead of showing the welcome screen. Change it under Settings > General > Startup Behavior. (#1673) ### Fixed diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 54fe47fc0..0511dce84 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -143,12 +143,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + persistOpenConnectionsForRecovery() LinkedFolderWatcher.shared.stop() SQLFolderWatcher.shared.stop() SSHTunnelManager.shared.terminateAllProcessesSync() CloudflareTunnelManager.shared.terminateAllProcessesSync() } + private func persistOpenConnectionsForRecovery() { + var seen = Set() + 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() } diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 2fd0c65a8..35b812923 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -125,10 +125,28 @@ 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 connections = ConnectionStorage.shared.loadConnections() + let knownIds = Set(connections.map(\.id)) + for connectionId in connectionIds where knownIds.contains(connectionId) { + WindowManager.shared.openTab( + payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault) + ) } } diff --git a/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift b/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift new file mode 100644 index 000000000..f0ca428b7 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift @@ -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) + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift index c4424943e..1508ab016 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift @@ -15,7 +15,7 @@ extension TabPersistenceCoordinator { clearSavedState() } else { let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) - saveNow(tabs: aggregatedTabs, selectedTabId: selectedId) + saveNow(windowedTabs: aggregatedTabs, selectedTabId: selectedId) } } @@ -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) } } } diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index d502c33a5..7c2587156 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -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 @@ -32,29 +34,55 @@ internal final class TabPersistenceCoordinator { // MARK: - Save internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) { - let nonPreviewTabs = tabs.filter { !$0.isPreview } + saveNow(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) + } + + internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { + let nonPreviewTabs = windowedTabs.filter { !$0.tab.isPreview } guard !nonPreviewTabs.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 = nonPreviewTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } + let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.tab.id == selectedTabId }) + ? selectedTabId : nonPreviewTabs.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 } + saveNowSync(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) + } + + internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { + let nonPreviewTabs = windowedTabs.filter { !$0.tab.isPreview } guard !nonPreviewTabs.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 = nonPreviewTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } + let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.tab.id == selectedTabId }) + ? selectedTabId : nonPreviewTabs.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 @@ -70,18 +98,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 @@ -114,30 +155,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 ) } } diff --git a/TablePro/Core/Storage/LastOpenConnectionsStorage.swift b/TablePro/Core/Storage/LastOpenConnectionsStorage.swift new file mode 100644 index 000000000..21fcb3e52 --- /dev/null +++ b/TablePro/Core/Storage/LastOpenConnectionsStorage.swift @@ -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) + } +} diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index a38726c6b..eb4398ec2 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -13,21 +13,34 @@ import os internal struct TabDiskState: Codable { let tabs: [PersistedTab] let selectedTabId: UUID? + let lastActiveDatabase: String? + let lastActiveSchema: String? - init(tabs: [PersistedTab], selectedTabId: UUID?) { + init( + tabs: [PersistedTab], + selectedTabId: UUID?, + lastActiveDatabase: String? = nil, + lastActiveSchema: String? = nil + ) { self.tabs = tabs self.selectedTabId = selectedTabId + self.lastActiveDatabase = lastActiveDatabase + self.lastActiveSchema = lastActiveSchema } private enum CodingKeys: String, CodingKey { case tabs case selectedTabId + case lastActiveDatabase + case lastActiveSchema } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) tabs = try container.decode([LossyTab].self, forKey: .tabs).compactMap(\.value) selectedTabId = try container.decodeIfPresent(UUID.self, forKey: .selectedTabId) + lastActiveDatabase = try container.decodeIfPresent(String.self, forKey: .lastActiveDatabase) + lastActiveSchema = try container.decodeIfPresent(String.self, forKey: .lastActiveSchema) } } @@ -71,8 +84,19 @@ internal actor TabDiskActor { // MARK: - Public API - internal func save(connectionId: UUID, tabs: [PersistedTab], selectedTabId: UUID?) throws { - let state = TabDiskState(tabs: tabs, selectedTabId: selectedTabId) + internal func save( + connectionId: UUID, + tabs: [PersistedTab], + selectedTabId: UUID?, + lastActiveDatabase: String? = nil, + lastActiveSchema: String? = nil + ) throws { + let state = TabDiskState( + tabs: tabs, + selectedTabId: selectedTabId, + lastActiveDatabase: lastActiveDatabase, + lastActiveSchema: lastActiveSchema + ) let data = try encoder.encode(state) let fileURL = tabStateFileURL(for: connectionId) try data.write(to: fileURL, options: .atomic) @@ -126,9 +150,16 @@ internal actor TabDiskActor { nonisolated internal static func saveSync( connectionId: UUID, tabs: [PersistedTab], - selectedTabId: UUID? + selectedTabId: UUID?, + lastActiveDatabase: String? = nil, + lastActiveSchema: String? = nil ) { - let state = TabDiskState(tabs: tabs, selectedTabId: selectedTabId) + let state = TabDiskState( + tabs: tabs, + selectedTabId: selectedTabId, + lastActiveDatabase: lastActiveDatabase, + lastActiveSchema: lastActiveSchema + ) let encoder = JSONEncoder() do { diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 59b46b42b..8d1aa14fe 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -32,6 +32,15 @@ struct QueryTab: Identifiable, Equatable { var paginationVersion: Int var loadEpoch: Int = 0 + var pendingRestoredSort: [PersistedSortColumn]? + var restoredPage: Int? + var restoredCursorOffset: Int? + + private static func clampedCursorOffset(_ offset: Int?, in query: String) -> Int? { + guard let offset, offset >= 0 else { return nil } + return min(offset, (query as NSString).length) + } + init( id: UUID = UUID(), title: String = "Query", @@ -58,6 +67,9 @@ struct QueryTab: Identifiable, Equatable { self.metadataVersion = 0 self.paginationVersion = 0 self.loadEpoch = 0 + self.pendingRestoredSort = nil + self.restoredPage = nil + self.restoredCursorOffset = nil } init(from persisted: PersistedTab) { @@ -83,13 +95,16 @@ struct QueryTab: Identifiable, Equatable { self.selectedRowIndices = [] self.sortState = SortState() self.filterState = TabFilterState() - self.columnLayout = ColumnLayoutState() + self.columnLayout = ColumnLayoutState(columnWidths: persisted.columnWidths ?? [:]) self.pagination = PaginationState() self.hasUserInteraction = false self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 self.loadEpoch = 0 + self.pendingRestoredSort = persisted.sortColumns + self.restoredPage = persisted.restoredPage.map { max(1, $0) } + self.restoredCursorOffset = Self.clampedCursorOffset(persisted.cursorOffset, in: persisted.query) } @MainActor static func buildBaseTableQuery( @@ -138,13 +153,20 @@ struct QueryTab: Identifiable, Equatable { sortState.isSorting && sortState.source == .user } - func toPersistedTab() -> PersistedTab { - let persistedQuery: String - if (content.query as NSString).length > TabQueryContent.maxPersistableQuerySize { - persistedQuery = "" - } else { - persistedQuery = content.query - } + func toPersistedTab(windowGroupIndex: Int? = nil) -> PersistedTab { + let queryLength = (content.query as NSString).length + let persistedQuery = queryLength > TabQueryContent.maxPersistableQuerySize ? "" : content.query + + let persistedSort: [PersistedSortColumn]? = { + let resolved = sortState.columns.compactMap { column -> PersistedSortColumn? in + guard let name = column.columnName else { return nil } + return PersistedSortColumn(columnName: name, direction: column.direction) + } + return resolved.isEmpty ? nil : resolved + }() + + let restoredPage = (tabType == .table && pagination.currentPage > 1) ? pagination.currentPage : nil + let widths = columnLayout.columnWidths.isEmpty ? nil : columnLayout.columnWidths return PersistedTab( id: id, @@ -157,7 +179,12 @@ struct QueryTab: Identifiable, Equatable { schemaName: tableContext.schemaName, sourceFileURL: content.sourceFileURL, erDiagramSchemaKey: display.erDiagramSchemaKey, - queryParameters: content.queryParameters.isEmpty ? nil : content.queryParameters + queryParameters: content.queryParameters.isEmpty ? nil : content.queryParameters, + sortColumns: persistedSort, + restoredPage: restoredPage, + cursorOffset: Self.clampedCursorOffset(restoredCursorOffset, in: persistedQuery), + columnWidths: widths, + windowGroupIndex: windowGroupIndex ) } diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 71ad68032..0d38707ed 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -33,6 +33,73 @@ struct PersistedTab: Codable { var sourceFileURL: URL? var erDiagramSchemaKey: String? var queryParameters: [QueryParameter]? + var sortColumns: [PersistedSortColumn]? + var restoredPage: Int? + var cursorOffset: Int? + var columnWidths: [String: CGFloat]? + var windowGroupIndex: Int? + + init( + id: UUID, + title: String, + query: String, + tabType: TabType, + tableName: String?, + isView: Bool = false, + databaseName: String = "", + schemaName: String? = nil, + sourceFileURL: URL? = nil, + erDiagramSchemaKey: String? = nil, + queryParameters: [QueryParameter]? = nil, + sortColumns: [PersistedSortColumn]? = nil, + restoredPage: Int? = nil, + cursorOffset: Int? = nil, + columnWidths: [String: CGFloat]? = nil, + windowGroupIndex: Int? = nil + ) { + self.id = id + self.title = title + self.query = query + self.tabType = tabType + self.tableName = tableName + self.isView = isView + self.databaseName = databaseName + self.schemaName = schemaName + self.sourceFileURL = sourceFileURL + self.erDiagramSchemaKey = erDiagramSchemaKey + self.queryParameters = queryParameters + self.sortColumns = sortColumns + self.restoredPage = restoredPage + self.cursorOffset = cursorOffset + self.columnWidths = columnWidths + self.windowGroupIndex = windowGroupIndex + } + + private enum CodingKeys: String, CodingKey { + case id, title, query, tabType, tableName, isView, databaseName, schemaName + case sourceFileURL, erDiagramSchemaKey, queryParameters + case sortColumns, restoredPage, cursorOffset, columnWidths, windowGroupIndex + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + query = try container.decodeIfPresent(String.self, forKey: .query) ?? "" + tabType = try container.decode(TabType.self, forKey: .tabType) + tableName = try container.decodeIfPresent(String.self, forKey: .tableName) + isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false + databaseName = try container.decodeIfPresent(String.self, forKey: .databaseName) ?? "" + schemaName = try container.decodeIfPresent(String.self, forKey: .schemaName) + sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) + erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey) + queryParameters = try container.decodeIfPresent([QueryParameter].self, forKey: .queryParameters) + sortColumns = try container.decodeIfPresent([PersistedSortColumn].self, forKey: .sortColumns) + restoredPage = try container.decodeIfPresent(Int.self, forKey: .restoredPage) + cursorOffset = try container.decodeIfPresent(Int.self, forKey: .cursorOffset) + columnWidths = try container.decodeIfPresent([String: CGFloat].self, forKey: .columnWidths) + windowGroupIndex = try container.decodeIfPresent(Int.self, forKey: .windowGroupIndex) + } } struct TabChangeSnapshot: Equatable { @@ -59,7 +126,7 @@ struct TabChangeSnapshot: Equatable { } } -enum SortDirection: Equatable { +enum SortDirection: String, Equatable, Codable { case ascending case descending @@ -72,6 +139,19 @@ enum SortDirection: Equatable { struct SortColumn: Equatable { var columnIndex: Int var direction: SortDirection + var columnName: String? + + init(columnIndex: Int, direction: SortDirection, columnName: String? = nil) { + self.columnIndex = columnIndex + self.direction = direction + self.columnName = columnName + } +} + +/// A sort column captured for cross-launch persistence, keyed by name so it survives column reordering +struct PersistedSortColumn: Codable, Equatable { + let columnName: String + let direction: SortDirection } enum SortSource: Equatable { diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index ffa240795..7aff8bb41 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -62,7 +62,7 @@ struct GeneralSettings: Codable, Equatable { var shareAnalytics: Bool static let `default` = GeneralSettings( - startupBehavior: .showWelcome, + startupBehavior: .reopenLast, language: .system, automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, @@ -70,7 +70,7 @@ struct GeneralSettings: Codable, Equatable { ) init( - startupBehavior: StartupBehavior = .showWelcome, + startupBehavior: StartupBehavior = .reopenLast, language: AppLanguage = .system, automaticallyCheckForUpdates: Bool = true, queryTimeoutSeconds: Int = 60, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index f5c484589..7684bcb72 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -329,6 +329,7 @@ struct MainEditorContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } ) + .onAppear { coordinator.applyRestoredCursor(for: tab.id) } } private func reloadFileForTab(tabId: UUID, url: URL) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index 3d06f3f68..ec46e62d0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -29,15 +29,44 @@ extension MainContentCoordinator { let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tableContext.tableName == tableName else { return false } - let sortApplied = applyResolvedDefaultSort(at: index, hint: hint) - if sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty { + let restoreApplied = applyPendingRestoredViewState(at: index) + let sortApplied = restoreApplied ? false : applyResolvedDefaultSort(at: index, hint: hint) + if restoreApplied || sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty { filterCoordinator.rebuildTableQuery(at: index) } return true } func firstLoadNeedsSchemaColumns(for tab: QueryTab, hint: DefaultSortHint) -> Bool { - wantsDefaultSort(for: tab, hint: hint) || !tab.columnLayout.hiddenColumns.isEmpty + wantsDefaultSort(for: tab, hint: hint) + || !tab.columnLayout.hiddenColumns.isEmpty + || tab.pendingRestoredSort != nil + || tab.restoredPage != nil + } + + private func applyPendingRestoredViewState(at index: Int) -> Bool { + let tab = tabManager.tabs[index] + guard tab.pendingRestoredSort != nil || tab.restoredPage != nil else { return false } + + let columns = effectiveResultColumns(for: tab) + let resolvedSort: [SortColumn] = (tab.pendingRestoredSort ?? []).compactMap { persisted in + guard let columnIndex = columns.firstIndex(of: persisted.columnName) else { return nil } + return SortColumn(columnIndex: columnIndex, direction: persisted.direction, columnName: persisted.columnName) + } + let pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) + let page = max(1, tab.restoredPage ?? 1) + + tabManager.mutate(at: index) { tab in + tab.pendingRestoredSort = nil + tab.restoredPage = nil + if !resolvedSort.isEmpty { + tab.sortState = SortState(columns: resolvedSort, source: .user) + } + tab.pagination.pageSize = pageSize + tab.pagination.currentPage = page + tab.pagination.currentOffset = (page - 1) * pageSize + } + return !resolvedSort.isEmpty || page > 1 } func wantsDefaultSort(for tab: QueryTab, hint: DefaultSortHint) -> Bool { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 454f2bf2a..ed2b39129 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -35,7 +35,7 @@ extension MainContentView { guard !coordinator.isTearingDown else { return } let aggregated = MainContentCoordinator.aggregatedTabs(for: coordinator.connectionId) coordinator.persistence.saveNow( - tabs: aggregated, + windowedTabs: aggregated, selectedTabId: newTabId ) MainContentView.lifecycleLogger.debug( diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 8ae574d37..18f344396 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -90,6 +90,11 @@ extension MainContentView { } private func handleRestoreOrDefault() async { + if let group = RestorationGroupRegistry.consume(for: payload?.id) { + applyRestoredGroup(group.tabs, selectedTabId: group.selectedTabId) + return + } + if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { MainContentView.lifecycleLogger.info( "[open] handleRestoreOrDefault short-circuit (other windows exist) windowId=\(windowId, privacy: .public)" @@ -103,66 +108,99 @@ extension MainContentView { "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" ) guard !result.tabs.isEmpty else { return } - do { - var restoredTabs = result.tabs - for i in restoredTabs.indices where restoredTabs[i].tabType == .table { - if let tableName = restoredTabs[i].tableContext.tableName { - do { - restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( - tableName: tableName, - databaseType: connection.type, - schemaName: restoredTabs[i].tableContext.schemaName - ) - } catch { - MainContentView.lifecycleLogger.error( - "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - } + + var restoredTabs = result.tabs + for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + if let tableName = restoredTabs[i].tableContext.tableName { + do { + restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( + tableName: tableName, + databaseType: connection.type, + schemaName: restoredTabs[i].tableContext.schemaName + ) + } catch { + MainContentView.lifecycleLogger.error( + "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) } } + } - let selectedId = result.selectedTabId + applyRestoredActiveContext(database: result.lastActiveDatabase, schema: result.lastActiveSchema) - // First tab in the array gets the current window to preserve order. - // Remaining tabs open as native window tabs in order. - let firstTab = restoredTabs[0] - tabManager.tabs = [firstTab] - tabManager.selectedTabId = firstTab.id + let selectedId = result.selectedTabId - let remainingTabs = Array(restoredTabs.dropFirst()) + // First tab gets the current window to preserve order; the rest open as + // native window tabs, each carrying its full restored state via the registry. + let firstTab = restoredTabs[0] + applyRestoredGroup([firstTab], selectedTabId: firstTab.id) - if !remainingTabs.isEmpty { - let selectedWasFirst = firstTab.id == selectedId - for tab in remainingTabs { - let restorePayload = EditorTabPayload( - from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowManager.shared.openTab(payload: restorePayload) - } - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } + let remainingTabs = Array(restoredTabs.dropFirst()) + if !remainingTabs.isEmpty { + let selectedWasFirst = firstTab.id == selectedId + for tab in remainingTabs { + openRestoredTabWindow(tab) } + if selectedWasFirst { + viewWindow?.makeKeyAndOrderFront(nil) + } + } + } + + private func applyRestoredGroup(_ tabs: [QueryTab], selectedTabId: UUID?) { + guard let firstTab = tabs.first else { return } + tabManager.tabs = tabs + tabManager.selectedTabId = tabs.contains(where: { $0.id == selectedTabId }) ? selectedTabId : firstTab.id + + guard let selected = tabManager.selectedTab, selected.tabType == .table, + !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } - if firstTab.tabType == .table, - !firstTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if let tableName = selected.tableContext.tableName { + coordinator.restoreLastHiddenColumnsForTable(tableName) + coordinator.restoreFiltersForTable(tableName) + } + if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { + if !selected.tableContext.databaseName.isEmpty, + selected.tableContext.databaseName != session.activeDatabase { - if let tableName = firstTab.tableContext.tableName { - coordinator.restoreLastHiddenColumnsForTable(tableName) - coordinator.restoreFiltersForTable(tableName) - } - if let session = DatabaseManager.shared.activeSessions[connection.id], - session.isConnected - { - if !firstTab.tableContext.databaseName.isEmpty, - firstTab.tableContext.databaseName != session.activeDatabase - { - Task { await coordinator.switchDatabase(to: firstTab.tableContext.databaseName) } - } else { - coordinator.lazyLoadCurrentTabIfNeeded() - } - } else { - coordinator.needsLazyLoad = true - } + Task { await coordinator.switchDatabase(to: selected.tableContext.databaseName) } + } else { + coordinator.lazyLoadCurrentTabIfNeeded() + } + } else { + coordinator.needsLazyLoad = true + } + } + + private func openRestoredTabWindow(_ tab: QueryTab) { + let restorePayload = EditorTabPayload( + connectionId: connection.id, + tabType: tab.tabType, + tableName: tab.tableContext.tableName, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + isView: tab.tableContext.isView, + skipAutoExecute: true, + erDiagramSchemaKey: tab.display.erDiagramSchemaKey, + tabTitle: tab.title, + intent: .restoreOrDefault + ) + RestorationGroupRegistry.register( + .init(tabs: [tab], selectedTabId: tab.id), + for: restorePayload.id + ) + WindowManager.shared.openTab(payload: restorePayload) + } + + private func applyRestoredActiveContext(database: String?, schema: String?) { + guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { return } + Task { + if let database, !database.isEmpty, database != session.activeDatabase { + await coordinator.switchDatabase(to: database) + } + if let schema, !schema.isEmpty, schema != session.currentSchema { + await coordinator.switchSchema(to: schema) } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cd110f992..3ed4f22c8 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -182,6 +182,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? + @ObservationIgnored private var periodicSaveTask: Task? @ObservationIgnored private var terminationObserver: NSObjectProtocol? @ObservationIgnored private var postConnectCancellable: AnyCancellable? @ObservationIgnored private var externalFileModCancellable: AnyCancellable? @@ -274,8 +275,9 @@ final class MainContentCoordinator { Self.activeCoordinators.removeValue(forKey: instanceId) } - /// Collect non-preview tabs for persistence. - static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { + /// Collect non-preview tabs for persistence, tagged with the index of the + /// native window group they belong to so multi-window layouts restore intact. + static func aggregatedTabs(for connectionId: UUID) -> [(tab: QueryTab, windowGroupIndex: Int)] { let coordinators = activeCoordinators.values .filter { $0.connectionId == connectionId } @@ -295,9 +297,61 @@ final class MainContentCoordinator { orderedCoordinators = Array(coordinators) } - return orderedCoordinators - .flatMap { $0.tabManager.tabs } - .filter { !$0.isPreview } + return orderedCoordinators.enumerated().flatMap { groupIndex, coordinator in + coordinator.tabManager.tabs + .filter { !$0.isPreview } + .map { (tab: coordinator.enrichedForPersistence($0), windowGroupIndex: groupIndex) } + } + } + + /// Resolve transient view state that only the live coordinator knows about + /// (sort column names, editor cursor offset) onto the tab before it is serialized. + private func enrichedForPersistence(_ tab: QueryTab) -> QueryTab { + var enriched = tab + if enriched.sortState.isSorting { + let columns = columnsForPersistence(of: tab) + enriched.sortState.columns = enriched.sortState.columns.map { column in + guard column.columnName == nil, + column.columnIndex >= 0, + column.columnIndex < columns.count else { return column } + var named = column + named.columnName = columns[column.columnIndex] + return named + } + } + if tab.tabType == .query, tab.id == tabManager.selectedTabId { + enriched.restoredCursorOffset = cursorPositions.first?.range.location + } + return enriched + } + + private func columnsForPersistence(of tab: QueryTab) -> [String] { + let buffer = tabSessionRegistry.tableRows(for: tab.id) + return buffer.columns.isEmpty ? effectiveResultColumns(for: tab) : buffer.columns + } + + func applyRestoredCursor(for tabId: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), + tabManager.tabs[index].tabType == .query, + let offset = tabManager.tabs[index].restoredCursorOffset else { return } + let length = (tabManager.tabs[index].content.query as NSString).length + let clamped = min(max(0, offset), length) + cursorPositions = [CursorPosition(range: NSRange(location: clamped, length: 0))] + tabManager.mutate(at: index) { $0.restoredCursorOffset = nil } + } + + private static let periodicSaveInterval: Duration = .seconds(30) + + private func startPeriodicSave() { + guard periodicSaveTask == nil else { return } + periodicSaveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.periodicSaveInterval) + guard let self, !Task.isCancelled, !Self.isAppTerminating, !self.isTearingDown else { return } + guard self.isFirstCoordinatorForConnection() else { continue } + self.persistence.saveOrClearAggregated() + } + } } /// Get selected tab ID from any coordinator for a given connectionId. @@ -398,7 +452,7 @@ final class MainContentCoordinator { let allTabs = Self.aggregatedTabs(for: self.connectionId) let selectedId = Self.aggregatedSelectedTabId(for: self.connectionId) self.persistence.saveNowSync( - tabs: allTabs, + windowedTabs: allTabs, selectedTabId: selectedId ) } @@ -461,6 +515,7 @@ final class MainContentCoordinator { services.schemaProviderRegistry.retain(for: connection.id) } registerForPersistence() + startPeriodicSave() setupPluginDriver() startFileWatcherIfNeeded() if changeManager.pluginDriver == nil { @@ -665,6 +720,8 @@ final class MainContentCoordinator { tableLoadTasks.removeAll() changeManagerUpdateTask?.cancel() changeManagerUpdateTask = nil + periodicSaveTask?.cancel() + periodicSaveTask = nil redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil diff --git a/TableProTests/Core/Services/MultiWindowRestorationTests.swift b/TableProTests/Core/Services/MultiWindowRestorationTests.swift new file mode 100644 index 000000000..bd0e81376 --- /dev/null +++ b/TableProTests/Core/Services/MultiWindowRestorationTests.swift @@ -0,0 +1,68 @@ +// +// MultiWindowRestorationTests.swift +// TableProTests +// +// Tests for the restoration group registry and last-open-connections recovery list. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Multi-window restoration") +@MainActor +struct MultiWindowRestorationTests { + private func tab(_ title: String) -> QueryTab { + QueryTab(id: UUID(), title: title, query: "SELECT 1", tabType: .query) + } + + @Test("Registry hands back the registered group exactly once") + func registryConsumeReturnsGroupOnce() { + let payloadId = UUID() + let tabs = [tab("A"), tab("B")] + RestorationGroupRegistry.register(.init(tabs: tabs, selectedTabId: tabs[1].id), for: payloadId) + + let consumed = RestorationGroupRegistry.consume(for: payloadId) + #expect(consumed?.tabs.map(\.id) == tabs.map(\.id)) + #expect(consumed?.selectedTabId == tabs[1].id) + + #expect(RestorationGroupRegistry.consume(for: payloadId) == nil) + } + + @Test("Consuming a nil payload id returns nil") + func registryConsumeNilReturnsNil() { + #expect(RestorationGroupRegistry.consume(for: nil) == nil) + } + + @Test("Last open connections round-trip through storage") + func connectionListRoundTrip() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) + let storage = LastOpenConnectionsStorage(directory: directory) + let ids = [UUID(), UUID(), UUID()] + + storage.save(connectionIds: ids) + #expect(storage.load() == ids) + + storage.clear() + #expect(storage.load().isEmpty) + } + + @Test("Loading from an empty directory returns no connections") + func connectionListMissingFileReturnsEmpty() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) + #expect(LastOpenConnectionsStorage(directory: directory).load().isEmpty) + } + + @Test("Saving an empty list clears the stored file") + func savingEmptyListClears() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) + let storage = LastOpenConnectionsStorage(directory: directory) + + storage.save(connectionIds: [UUID()]) + storage.save(connectionIds: []) + #expect(storage.load().isEmpty) + } +} diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift new file mode 100644 index 000000000..53fbc0b5d --- /dev/null +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -0,0 +1,154 @@ +// +// PersistedTabRoundTripTests.swift +// TableProTests +// +// Tests that session-restore view state round-trips through PersistedTab. +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("PersistedTab round-trip") +@MainActor +struct PersistedTabRoundTripTests { + private func tableTab(query: String = "SELECT 1") -> QueryTab { + QueryTab(id: UUID(), title: "Users", query: query, tabType: .table, tableName: "users") + } + + @Test("Sort columns persist by name and restore as pending sort") + func sortColumnsRoundTrip() { + var tab = tableTab() + tab.sortState = SortState( + columns: [ + SortColumn(columnIndex: 0, direction: .descending, columnName: "created_at"), + SortColumn(columnIndex: 2, direction: .ascending, columnName: "name") + ], + source: .user + ) + + let restored = QueryTab(from: tab.toPersistedTab()) + + #expect(restored.pendingRestoredSort?.count == 2) + #expect(restored.pendingRestoredSort?[0].columnName == "created_at") + #expect(restored.pendingRestoredSort?[0].direction == .descending) + #expect(restored.pendingRestoredSort?[1].columnName == "name") + #expect(restored.pendingRestoredSort?[1].direction == .ascending) + } + + @Test("Sort columns without a resolved name are dropped from persistence") + func sortColumnsWithoutNameAreDropped() { + var tab = tableTab() + tab.sortState = SortState(columns: [SortColumn(columnIndex: 0, direction: .ascending)], source: .user) + + #expect(tab.toPersistedTab().sortColumns == nil) + } + + @Test("Pagination page round-trips for table tabs") + func paginationPageRoundTrip() { + var tab = tableTab() + tab.pagination.currentPage = 3 + + let persisted = tab.toPersistedTab() + #expect(persisted.restoredPage == 3) + #expect(QueryTab(from: persisted).restoredPage == 3) + } + + @Test("Page 1 is not persisted") + func pageOneNotPersisted() { + var tab = tableTab() + tab.pagination.currentPage = 1 + + #expect(tab.toPersistedTab().restoredPage == nil) + } + + @Test("Cursor offset round-trips for query tabs") + func cursorOffsetRoundTrip() { + var tab = QueryTab(id: UUID(), title: "Q", query: "SELECT * FROM users", tabType: .query) + tab.restoredCursorOffset = 7 + + let persisted = tab.toPersistedTab() + #expect(persisted.cursorOffset == 7) + #expect(QueryTab(from: persisted).restoredCursorOffset == 7) + } + + @Test("Cursor offset is clamped to query length on restore") + func cursorOffsetClampedToQueryLength() { + let persisted = PersistedTab( + id: UUID(), + title: "Q", + query: "SELECT", + tabType: .query, + tableName: nil, + cursorOffset: 10_000 + ) + + #expect(QueryTab(from: persisted).restoredCursorOffset == ("SELECT" as NSString).length) + } + + @Test("A truncated query drops the persisted cursor offset") + func cursorOffsetDroppedWhenQueryTruncated() { + var tab = QueryTab( + id: UUID(), + title: "Q", + query: String(repeating: "a", count: TabQueryContent.maxPersistableQuerySize + 1), + tabType: .query + ) + tab.restoredCursorOffset = 42 + + let persisted = tab.toPersistedTab() + #expect(persisted.query.isEmpty) + #expect(persisted.cursorOffset == 0) + } + + @Test("Column widths round-trip") + func columnWidthsRoundTrip() { + var tab = tableTab() + tab.columnLayout.columnWidths = ["id": 80, "name": 220.5] + + let restored = QueryTab(from: tab.toPersistedTab()) + #expect(restored.columnLayout.columnWidths == ["id": 80, "name": 220.5]) + } + + @Test("erDiagramSchemaKey and queryParameters persist through toPersistedTab") + func erDiagramAndParametersRoundTrip() { + var tab = QueryTab(id: UUID(), title: "ER", query: "", tabType: .erDiagram) + tab.display.erDiagramSchemaKey = "public" + tab.content.queryParameters = [QueryParameter(name: "id", value: "1")] + + let persisted = tab.toPersistedTab() + #expect(persisted.erDiagramSchemaKey == "public") + #expect(persisted.queryParameters?.count == 1) + } + + @Test("windowGroupIndex encodes and decodes") + func windowGroupIndexRoundTrip() throws { + let tab = tableTab().toPersistedTab(windowGroupIndex: 2) + let data = try JSONEncoder().encode(tab) + let decoded = try JSONDecoder().decode(PersistedTab.self, from: data) + #expect(decoded.windowGroupIndex == 2) + } + + @Test("SortDirection encodes as a stable string") + func sortDirectionCodable() throws { + for direction in [SortDirection.ascending, .descending] { + let data = try JSONEncoder().encode(direction) + #expect(try JSONDecoder().decode(SortDirection.self, from: data) == direction) + } + } + + @Test("Old persisted tabs without new fields decode with defaults") + func oldFileWithoutNewFieldsDecodesGracefully() throws { + let json = """ + {"id":"\(UUID().uuidString)","title":"Legacy","query":"SELECT 1","tabType":{"query":{}},"tableName":null} + """ + let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) + #expect(decoded.sortColumns == nil) + #expect(decoded.restoredPage == nil) + #expect(decoded.cursorOffset == nil) + #expect(decoded.columnWidths == nil) + #expect(decoded.windowGroupIndex == nil) + #expect(decoded.isView == false) + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 7c4913f12..69157d066 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -29,8 +29,10 @@ System (default), English, Tiếng Việt, Türkçe. Changing the language requi | Option | Description | |--------|-------------| +| **Reopen Last Session** (default) | Restore the windows, tabs, and view state from your last session on launch | | **Show Welcome Screen** | Display the welcome screen with recent connections | -| **Reopen Last Session** | Auto-reconnect to your last database | + +Reopened tabs restore their SQL, cursor position, applied sort, filters, page, and column widths. Connections reconnect lazily on first interaction, so a missing or unreachable database does not block launch. See [Tab Persistence](/features/tabs#tab-persistence) for the full list of restored state. ### Query Execution Timeout diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 00ea435c2..31cff8000 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -153,15 +153,20 @@ Each tab maintains its own pending changes. Switching tabs saves and restores ea | Saved | Not Saved | |-------|-----------| | Tab ID and title | Query results (re-queried on reopen) | -| SQL content | Pending data changes | -| Tab type and table name | Selected rows, sort/filter state | -| Pin state | Preview tabs (discarded) | +| SQL content and cursor position | Pending data changes | +| Tab type and table name | Selected rows | +| Applied sort, filters, and page | Preview tabs (discarded) | +| Column widths | | +| Active database and schema | | +| Pin state | | -Tab state auto-saves 500ms after any change. On reconnect, TablePro restores your tabs and re-executes table queries. +Tab state auto-saves 500ms after any change, plus a background save every 30 seconds so a crash or force quit keeps your recent work. On reconnect, TablePro restores your tabs and re-runs table queries with the sort, filters, and page you left them on. Connections reconnect lazily, when you first interact with a restored tab, so launch stays fast and unreachable hosts do not block startup. + +Whether the session reopens on launch is controlled by [Startup Behavior](/customization/settings#general), which defaults to reopening your last session. ### Multi-Window Restoration -If you had several editor windows open with their own tab groups, TablePro restores every window on next launch. Earlier versions kept tabs from one window and dropped the rest. +Tabs restore in their original order across launches. If a previous session was recovered after a crash, TablePro reopens each connection that had open tabs. ### Window Size, Position, and Zoom From 05b15a5b166264dd04aca6291b6812e6b81266ef Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 20:42:35 +0700 Subject: [PATCH 2/6] fix(launch): reopen last session and dismiss welcome on relaunch (#1673) --- CHANGELOG.md | 2 +- .../Infrastructure/AppLaunchCoordinator.swift | 6 ++++++ TablePro/Core/Storage/AppSettingsStorage.swift | 12 ++++++++++++ TablePro/TableProApp.swift | 1 + 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b82d839a..6e4098f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +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. -- New installs now reopen your last session on launch by default instead of showing the welcome screen. Change it under Settings > General > Startup Behavior. (#1673) +- 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 diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 35b812923..8fafbe285 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -143,10 +143,16 @@ internal final class AppLaunchCoordinator { let connections = ConnectionStorage.shared.loadConnections() let knownIds = Set(connections.map(\.id)) + var openedAny = false for connectionId in connectionIds where knownIds.contains(connectionId) { WindowManager.shared.openTab( payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault) ) + openedAny = true + } + + if openedAny { + WindowOpener.shared.orderOutWelcome() } } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index ebac33b53..ae57d1b48 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -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) { @@ -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 + saveGeneral(general) + } + // MARK: - Appearance Settings func loadAppearance() -> AppearanceSettings { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 91a4e404c..1cd92e270 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -767,6 +767,7 @@ struct TableProApp: App { @State private var commandRegistry = CommandActionsRegistry.shared init() { + AppSettingsStorage.shared.migrateStartupBehaviorToReopenLastIfNeeded() AIProviderRegistration.registerAll() // Perform startup cleanup of query history if auto-cleanup is enabled From 0696de7f4a23adefcd2d2f94580578705f27e6c0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 21:31:31 +0700 Subject: [PATCH 3/6] fix(launch): connect restored sessions reopened from the recovery list (#1673) --- .../Infrastructure/AppLaunchCoordinator.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 8fafbe285..c76466146 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -141,14 +141,26 @@ internal final class AppLaunchCoordinator { let connectionIds = LastOpenConnectionsStorage.shared.load() guard !connectionIds.isEmpty else { return } - let connections = ConnectionStorage.shared.loadConnections() - let knownIds = Set(connections.map(\.id)) + let connectionsById = Dictionary( + ConnectionStorage.shared.loadConnections().map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) var openedAny = false - for connectionId in connectionIds where knownIds.contains(connectionId) { + for connectionId in connectionIds { + guard let connection = connectionsById[connectionId] else { continue } WindowManager.shared.openTab( payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault) ) 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 { From f156fc3bb8cf56f4a018d567d91161733c110cd4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 22:22:52 +0700 Subject: [PATCH 4/6] feat(coordinator): restore open preview table tabs as permanent (#1673) --- .../TabPersistenceCoordinator.swift | 18 ++++----- .../Views/Main/MainContentCoordinator.swift | 1 - .../TabPersistenceCoordinatorTests.swift | 37 +++++++++---------- docs/features/tabs.mdx | 4 +- 4 files changed, 27 insertions(+), 33 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 7c2587156..0c9407762 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -38,14 +38,13 @@ internal final class TabPersistenceCoordinator { } internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { - let nonPreviewTabs = windowedTabs.filter { !$0.tab.isPreview } - guard !nonPreviewTabs.isEmpty else { + guard !windowedTabs.isEmpty else { clearSavedState() return } - let persisted = nonPreviewTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } - let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.tab.id == selectedTabId }) - ? selectedTabId : nonPreviewTabs.first?.tab.id + 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() scheduleSave( tabs: persisted, @@ -60,16 +59,15 @@ internal final class TabPersistenceCoordinator { } internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { - let nonPreviewTabs = windowedTabs.filter { !$0.tab.isPreview } - guard !nonPreviewTabs.isEmpty else { + guard !windowedTabs.isEmpty else { saveTask?.cancel() saveTask = nil TabDiskActor.clearSync(connectionId: connectionId) return } - let persisted = nonPreviewTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } - let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.tab.id == selectedTabId }) - ? selectedTabId : nonPreviewTabs.first?.tab.id + 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, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3ed4f22c8..a7752830a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -299,7 +299,6 @@ final class MainContentCoordinator { return orderedCoordinators.enumerated().flatMap { groupIndex, coordinator in coordinator.tabManager.tabs - .filter { !$0.isPreview } .map { (tab: coordinator.enrichedForPersistence($0), windowGroupIndex: groupIndex) } } } diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index c8190bf53..bbd0f1bc1 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -186,8 +186,8 @@ struct TabPersistenceCoordinatorTests { #expect(afterClear.source == .none) } - @Test("Preview tabs are excluded from persistence") - func previewTabsExcludedFromPersistence() async { + @Test("Preview tabs are persisted and restored as permanent tabs") + func previewTabsRestoredAsPermanent() async { let coordinator = makeCoordinator() let normalTab = QueryTab(id: UUID(), title: "Normal", query: "SELECT 1", tabType: .query) var previewTab = QueryTab(id: UUID(), title: "Preview", query: "SELECT 2", tabType: .table, tableName: "users") @@ -198,47 +198,44 @@ struct TabPersistenceCoordinatorTests { let result = await coordinator.restoreFromDisk() - #expect(result.tabs.count == 1) - #expect(result.tabs[0].id == normalTab.id) - #expect(result.tabs[0].title == "Normal") + #expect(result.tabs.count == 2) + #expect(result.tabs.contains { $0.id == previewTab.id }) + #expect(result.tabs.allSatisfy { !$0.isPreview }) coordinator.clearSavedState() await sleep() } - @Test("All-preview tabs clears saved state") - func allPreviewTabsClearsSavedState() async { + @Test("A lone preview tab is persisted") + func lonePreviewTabIsPersisted() async { let coordinator = makeCoordinator() - let normalTab = QueryTab(id: UUID(), title: "Normal", query: "SELECT 1", tabType: .query) - - // First save a normal tab - coordinator.saveNow(tabs: [normalTab], selectedTabId: normalTab.id) - await sleep() - - // Now save only preview tabs — should clear state var previewTab = QueryTab(id: UUID(), title: "Preview", query: "SELECT 2", tabType: .table, tableName: "users") previewTab.isPreview = true + coordinator.saveNow(tabs: [previewTab], selectedTabId: previewTab.id) await sleep() let result = await coordinator.restoreFromDisk() - #expect(result.tabs.isEmpty) - #expect(result.source == .none) + #expect(result.tabs.count == 1) + #expect(result.tabs[0].id == previewTab.id) + #expect(result.tabs[0].isPreview == false) + + coordinator.clearSavedState() + await sleep() } - @Test("selectedTabId normalizes when selected tab is preview") - func selectedTabIdNormalizesWhenPreview() async { + @Test("selectedTabId on a preview tab is preserved") + func selectedPreviewTabIdPreserved() async { let coordinator = makeCoordinator() let normalTab = QueryTab(id: UUID(), title: "Normal", query: "SELECT 1", tabType: .query) var previewTab = QueryTab(id: UUID(), title: "Preview", query: "SELECT 2", tabType: .table, tableName: "users") previewTab.isPreview = true - // Select the preview tab — should normalize to first non-preview tab coordinator.saveNow(tabs: [normalTab, previewTab], selectedTabId: previewTab.id) await sleep() let result = await coordinator.restoreFromDisk() - #expect(result.selectedTabId == normalTab.id) + #expect(result.selectedTabId == previewTab.id) coordinator.clearSavedState() await sleep() diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 31cff8000..b4d52567d 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -45,7 +45,7 @@ A preview tab becomes permanent when you: - **Double-click** a table in the sidebar - **Interact** with the tab (sort, filter, edit data, select rows) -Preview tabs are not saved across restarts. +A preview tab that is still open when you quit is restored as a permanent tab on next launch. Disable preview tabs in **Settings** > **Tabs** if you prefer every click to open a permanent tab. @@ -155,7 +155,7 @@ Each tab maintains its own pending changes. Switching tabs saves and restores ea | Tab ID and title | Query results (re-queried on reopen) | | SQL content and cursor position | Pending data changes | | Tab type and table name | Selected rows | -| Applied sort, filters, and page | Preview tabs (discarded) | +| Applied sort, filters, and page | | | Column widths | | | Active database and schema | | | Pin state | | From 51d068474ca2ef8c0258840162549623841d2957 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 22:46:54 +0700 Subject: [PATCH 5/6] refactor(coordinator): unify restored db/schema switch and test sort resolution (#1673) --- ...ainContentCoordinator+TableFirstLoad.swift | 9 +-- .../Extensions/MainContentView+Setup.swift | 74 ++++++++++++------- .../Views/Main/MainContentCoordinator.swift | 16 +++- .../MultiWindowRestorationTests.swift | 25 +++++++ .../AppSettingsStorageMigrationTests.swift | 67 +++++++++++++++++ 5 files changed, 156 insertions(+), 35 deletions(-) create mode 100644 TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index ec46e62d0..bcc985cb2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -48,11 +48,10 @@ extension MainContentCoordinator { let tab = tabManager.tabs[index] guard tab.pendingRestoredSort != nil || tab.restoredPage != nil else { return false } - let columns = effectiveResultColumns(for: tab) - let resolvedSort: [SortColumn] = (tab.pendingRestoredSort ?? []).compactMap { persisted in - guard let columnIndex = columns.firstIndex(of: persisted.columnName) else { return nil } - return SortColumn(columnIndex: columnIndex, direction: persisted.direction, columnName: persisted.columnName) - } + let resolvedSort = MainContentCoordinator.resolveRestoredSortColumns( + tab.pendingRestoredSort ?? [], + in: effectiveResultColumns(for: tab) + ) let pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) let page = max(1, tab.restoredPage ?? 1) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 18f344396..875bc2e1d 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -126,14 +126,17 @@ extension MainContentView { } } - applyRestoredActiveContext(database: result.lastActiveDatabase, schema: result.lastActiveSchema) - let selectedId = result.selectedTabId // First tab gets the current window to preserve order; the rest open as // native window tabs, each carrying its full restored state via the registry. let firstTab = restoredTabs[0] - applyRestoredGroup([firstTab], selectedTabId: firstTab.id) + applyRestoredGroup( + [firstTab], + selectedTabId: firstTab.id, + activeDatabase: result.lastActiveDatabase, + activeSchema: result.lastActiveSchema + ) let remainingTabs = Array(restoredTabs.dropFirst()) if !remainingTabs.isEmpty { @@ -147,29 +150,56 @@ extension MainContentView { } } - private func applyRestoredGroup(_ tabs: [QueryTab], selectedTabId: UUID?) { + private func applyRestoredGroup( + _ tabs: [QueryTab], + selectedTabId: UUID?, + activeDatabase: String? = nil, + activeSchema: String? = nil + ) { guard let firstTab = tabs.first else { return } tabManager.tabs = tabs tabManager.selectedTabId = tabs.contains(where: { $0.id == selectedTabId }) ? selectedTabId : firstTab.id - guard let selected = tabManager.selectedTab, selected.tabType == .table, - !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { return } + guard let selected = tabManager.selectedTab else { return } - if let tableName = selected.tableContext.tableName { + if selected.tabType == .table, let tableName = selected.tableContext.tableName, + !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { coordinator.restoreLastHiddenColumnsForTable(tableName) coordinator.restoreFiltersForTable(tableName) } - if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { - if !selected.tableContext.databaseName.isEmpty, - selected.tableContext.databaseName != session.activeDatabase - { - Task { await coordinator.switchDatabase(to: selected.tableContext.databaseName) } - } else { + + restoreConnectionContext(for: selected, activeDatabase: activeDatabase, activeSchema: activeSchema) + } + + /// Restore the connection's database and schema, then load the selected tab, in a single + /// sequenced task so the database and schema switches never race each other. + private func restoreConnectionContext(for selected: QueryTab, activeDatabase: String?, activeSchema: String?) { + let isTableTab = selected.tabType == .table + && !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { + if isTableTab { coordinator.needsLazyLoad = true } + return + } + + let targetDatabase = selected.tabType == .table && !selected.tableContext.databaseName.isEmpty + ? selected.tableContext.databaseName + : activeDatabase.flatMap { $0.isEmpty ? nil : $0 } + + Task { + var contextChanged = false + if let targetDatabase, targetDatabase != session.activeDatabase { + await coordinator.switchDatabase(to: targetDatabase) + contextChanged = true + } + if let activeSchema, !activeSchema.isEmpty, activeSchema != session.currentSchema { + await coordinator.switchSchema(to: activeSchema) + contextChanged = true + } + if isTableTab, !contextChanged { coordinator.lazyLoadCurrentTabIfNeeded() } - } else { - coordinator.needsLazyLoad = true } } @@ -193,18 +223,6 @@ extension MainContentView { WindowManager.shared.openTab(payload: restorePayload) } - private func applyRestoredActiveContext(database: String?, schema: String?) { - guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { return } - Task { - if let database, !database.isEmpty, database != session.activeDatabase { - await coordinator.switchDatabase(to: database) - } - if let schema, !schema.isEmpty, schema != session.currentSchema { - await coordinator.switchSchema(to: schema) - } - } - } - // MARK: - Command Actions Setup func updateToolbarPendingState() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a7752830a..f5cf66aef 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -275,8 +275,8 @@ final class MainContentCoordinator { Self.activeCoordinators.removeValue(forKey: instanceId) } - /// Collect non-preview tabs for persistence, tagged with the index of the - /// native window group they belong to so multi-window layouts restore intact. + /// Collect tabs across all of a connection's windows for persistence, tagged with + /// the index of the native window group they belong to so tab order restores intact. static func aggregatedTabs(for connectionId: UUID) -> [(tab: QueryTab, windowGroupIndex: Int)] { let coordinators = activeCoordinators.values .filter { $0.connectionId == connectionId } @@ -329,6 +329,18 @@ final class MainContentCoordinator { return buffer.columns.isEmpty ? effectiveResultColumns(for: tab) : buffer.columns } + /// Map persisted sort columns (keyed by name) back to indices into the live column set. + /// Columns that no longer exist are dropped, so a renamed or removed column degrades gracefully. + static func resolveRestoredSortColumns( + _ persisted: [PersistedSortColumn], + in columns: [String] + ) -> [SortColumn] { + persisted.compactMap { column in + guard let columnIndex = columns.firstIndex(of: column.columnName) else { return nil } + return SortColumn(columnIndex: columnIndex, direction: column.direction, columnName: column.columnName) + } + } + func applyRestoredCursor(for tabId: UUID) { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tabType == .query, diff --git a/TableProTests/Core/Services/MultiWindowRestorationTests.swift b/TableProTests/Core/Services/MultiWindowRestorationTests.swift index bd0e81376..4a4d69d8b 100644 --- a/TableProTests/Core/Services/MultiWindowRestorationTests.swift +++ b/TableProTests/Core/Services/MultiWindowRestorationTests.swift @@ -34,6 +34,31 @@ struct MultiWindowRestorationTests { #expect(RestorationGroupRegistry.consume(for: nil) == nil) } + @Test("Restored sort columns resolve to indices, preserving order and dropping missing columns") + func resolveRestoredSortColumns() { + let persisted = [ + PersistedSortColumn(columnName: "name", direction: .ascending), + PersistedSortColumn(columnName: "ghost", direction: .descending), + PersistedSortColumn(columnName: "id", direction: .descending) + ] + + let resolved = MainContentCoordinator.resolveRestoredSortColumns(persisted, in: ["id", "email", "name"]) + + #expect(resolved.count == 2) + #expect(resolved[0].columnName == "name") + #expect(resolved[0].columnIndex == 2) + #expect(resolved[0].direction == .ascending) + #expect(resolved[1].columnName == "id") + #expect(resolved[1].columnIndex == 0) + #expect(resolved[1].direction == .descending) + } + + @Test("Resolving sort columns against an empty column set yields nothing") + func resolveRestoredSortColumnsEmpty() { + let persisted = [PersistedSortColumn(columnName: "id", direction: .ascending)] + #expect(MainContentCoordinator.resolveRestoredSortColumns(persisted, in: []).isEmpty) + } + @Test("Last open connections round-trip through storage") func connectionListRoundTrip() { let directory = FileManager.default.temporaryDirectory diff --git a/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift new file mode 100644 index 000000000..35f71615b --- /dev/null +++ b/TableProTests/Core/Storage/AppSettingsStorageMigrationTests.swift @@ -0,0 +1,67 @@ +// +// AppSettingsStorageMigrationTests.swift +// TableProTests +// +// Tests the one-time startup-behavior migration to "Reopen Last Session". +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("AppSettingsStorage startup migration") +struct AppSettingsStorageMigrationTests { + private let generalKey = "com.TablePro.settings.general" + + private func makeStorage() -> (storage: AppSettingsStorage, defaults: UserDefaults, suite: String) { + let suite = "StartupMigrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + return (AppSettingsStorage(userDefaults: defaults), defaults, suite) + } + + @Test("A saved showWelcome default flips to reopenLast") + func migratesSavedShowWelcome() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + storage.saveGeneral(GeneralSettings(startupBehavior: .showWelcome)) + storage.migrateStartupBehaviorToReopenLastIfNeeded() + + #expect(storage.loadGeneral().startupBehavior == .reopenLast) + } + + @Test("An explicit reopenLast is left untouched") + func leavesReopenLast() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + storage.saveGeneral(GeneralSettings(startupBehavior: .reopenLast)) + storage.migrateStartupBehaviorToReopenLastIfNeeded() + + #expect(storage.loadGeneral().startupBehavior == .reopenLast) + } + + @Test("A fresh install with no saved settings is not written by the migration") + func skipsFreshInstall() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + storage.migrateStartupBehaviorToReopenLastIfNeeded() + + #expect(defaults.data(forKey: generalKey) == nil) + } + + @Test("The migration runs only once, then respects later choices") + func runsOnce() { + let (storage, defaults, suite) = makeStorage() + defer { defaults.removePersistentDomain(forName: suite) } + + storage.saveGeneral(GeneralSettings(startupBehavior: .showWelcome)) + storage.migrateStartupBehaviorToReopenLastIfNeeded() + #expect(storage.loadGeneral().startupBehavior == .reopenLast) + + storage.saveGeneral(GeneralSettings(startupBehavior: .showWelcome)) + storage.migrateStartupBehaviorToReopenLastIfNeeded() + #expect(storage.loadGeneral().startupBehavior == .showWelcome) + } +} From 2862ec61d0f9d33402d99b8f84504391267b48f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 22:59:38 +0700 Subject: [PATCH 6/6] test: make schema-switch test helper async for Swift 6 strict concurrency --- TableProTests/Views/SwitchSchemaTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProTests/Views/SwitchSchemaTests.swift b/TableProTests/Views/SwitchSchemaTests.swift index 30670e2b0..126933c23 100644 --- a/TableProTests/Views/SwitchSchemaTests.swift +++ b/TableProTests/Views/SwitchSchemaTests.swift @@ -21,8 +21,8 @@ struct SwitchSchemaTests { } private func withSchemaSwitchingConnection( - _ body: (DatabaseConnection, MockDatabaseDriver) -> Void - ) { + _ body: (DatabaseConnection, MockDatabaseDriver) async -> Void + ) async { let connection = TestFixtures.makeConnection(type: .postgresql) let driver = MockDatabaseDriver(connection: connection) DatabaseManager.shared.injectSession( @@ -30,7 +30,7 @@ struct SwitchSchemaTests { for: connection.id ) defer { DatabaseManager.shared.removeSession(for: connection.id) } - body(connection, driver) + await body(connection, driver) } @Test("switchSchema keeps query and table tabs and their contents")