diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f3779a5..6e4098f1a 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. +- 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/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..c76466146 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -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) + ) + 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() } } 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..0c9407762 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,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) } + 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 @@ -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 @@ -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 ) } } 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/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/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 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..bcc985cb2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -29,15 +29,43 @@ 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 resolvedSort = MainContentCoordinator.resolveRestoredSortColumns( + tab.pendingRestoredSort ?? [], + in: effectiveResultColumns(for: tab) + ) + 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..875bc2e1d 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,70 +108,121 @@ 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 - - // 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, + activeDatabase: result.lastActiveDatabase, + activeSchema: result.lastActiveSchema + ) - 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) } + } + } - if firstTab.tabType == .table, - !firstTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - 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 - } + 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 else { return } + + if selected.tabType == .table, let tableName = selected.tableContext.tableName, + !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + coordinator.restoreLastHiddenColumnsForTable(tableName) + coordinator.restoreFiltersForTable(tableName) + } + + 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() } } } + 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) + } + // MARK: - Command Actions Setup func updateToolbarPendingState() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cd110f992..f5cf66aef 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 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 } @@ -295,9 +297,72 @@ 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 + .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 + } + + /// 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, + 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 +463,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 +526,7 @@ final class MainContentCoordinator { services.schemaProviderRegistry.retain(for: connection.id) } registerForPersistence() + startPeriodicSave() setupPluginDriver() startFileWatcherIfNeeded() if changeManager.pluginDriver == nil { @@ -665,6 +731,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..4a4d69d8b --- /dev/null +++ b/TableProTests/Core/Services/MultiWindowRestorationTests.swift @@ -0,0 +1,93 @@ +// +// 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("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 + .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/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/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) + } +} 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") 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..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. @@ -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 | | +| 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