diff --git a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate index 547ce69b8..3cce4fc01 100644 Binary files a/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate and b/OpenTable.xcodeproj/project.xcworkspace/xcuserdata/ngoquocdat.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/OpenTable/AppDelegate.swift b/OpenTable/AppDelegate.swift index 6e0390662..6e27799f1 100644 --- a/OpenTable/AppDelegate.swift +++ b/OpenTable/AppDelegate.swift @@ -58,20 +58,44 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Clean up window tracking configuredWindows.remove(ObjectIdentifier(window)) - + // Check if main window is being closed if isMainWindow(window) { - // Disconnect all sessions + // CRITICAL: Save tab state SYNCHRONOUSLY before any async operations + // Otherwise sessions might be cleared before we save + saveAllTabStates() + + // NOW disconnect sessions asynchronously (after save is complete) Task { @MainActor in await DatabaseManager.shared.disconnectAll() } - + // Reopen welcome window after a brief delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.openWelcomeWindow() } } } + + func applicationWillTerminate(_ notification: Notification) { + // Save tab state synchronously before app terminates (backup mechanism) + saveAllTabStates() + } + + /// Save tab state for all active sessions + private func saveAllTabStates() { + for (connectionId, session) in DatabaseManager.shared.activeSessions { + if session.tabs.isEmpty { + TabStateStorage.shared.clearTabState(connectionId: connectionId) + } else { + TabStateStorage.shared.saveTabState( + connectionId: connectionId, + tabs: session.tabs, + selectedTabId: session.selectedTabId + ) + } + } + } private func isMainWindow(_ window: NSWindow) -> Bool { // Main window has identifier containing "main" (from WindowGroup(id: "main")) diff --git a/OpenTable/Core/Database/DatabaseManager.swift b/OpenTable/Core/Database/DatabaseManager.swift index 07b9f27e3..c554efe30 100644 --- a/OpenTable/Core/Database/DatabaseManager.swift +++ b/OpenTable/Core/Database/DatabaseManager.swift @@ -109,6 +109,13 @@ final class DatabaseManager: ObservableObject { session.status = driver.status activeSessions[connection.id] = session + // Restore tab state if it exists + if let tabState = TabStateStorage.shared.loadTabState(connectionId: connection.id) { + let restoredTabs = tabState.tabs.map { QueryTab(from: $0) } + activeSessions[connection.id]?.tabs = restoredTabs + activeSessions[connection.id]?.selectedTabId = tabState.selectedTabId + } + // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) } catch { diff --git a/OpenTable/Core/Storage/TabStateStorage.swift b/OpenTable/Core/Storage/TabStateStorage.swift new file mode 100644 index 000000000..76618ce20 --- /dev/null +++ b/OpenTable/Core/Storage/TabStateStorage.swift @@ -0,0 +1,95 @@ +// +// TabStateStorage.swift +// OpenTable +// +// Service for persisting tab state per connection +// + +import Foundation + +/// Represents persisted tab state for a connection +struct TabState: Codable { + let tabs: [PersistedTab] + let selectedTabId: UUID? +} + +/// Service for persisting tab state per connection +final class TabStateStorage { + static let shared = TabStateStorage() + + private let defaults = UserDefaults.standard + private let tabStateKeyPrefix = "com.opentable.tabs." + + private init() {} + + // MARK: - Public API + + /// Save tab state for a connection + func saveTabState(connectionId: UUID, tabs: [QueryTab], selectedTabId: UUID?) { + let persistedTabs = tabs.map { $0.toPersistedTab() } + let tabState = TabState(tabs: persistedTabs, selectedTabId: selectedTabId) + + do { + let encoder = JSONEncoder() + let data = try encoder.encode(tabState) + let key = tabStateKey(for: connectionId) + defaults.set(data, forKey: key) + } catch { + #if DEBUG + print("[TabStateStorage] Failed to encode tab state: \(error.localizedDescription)") + #endif + } + } + + /// Load tab state for a connection + func loadTabState(connectionId: UUID) -> TabState? { + let key = tabStateKey(for: connectionId) + + guard let data = defaults.data(forKey: key) else { + return nil + } + + do { + let decoder = JSONDecoder() + return try decoder.decode(TabState.self, from: data) + } catch { + #if DEBUG + print("[TabStateStorage] Failed to decode tab state: \(error.localizedDescription)") + #endif + return nil + } + } + + /// Clear tab state for a connection + func clearTabState(connectionId: UUID) { + let key = tabStateKey(for: connectionId) + defaults.removeObject(forKey: key) + } + + // MARK: - Last Query Memory (TablePlus-style) + + /// Save the last query text for a connection (persists across tab close/open) + func saveLastQuery(_ query: String, for connectionId: UUID) { + let key = "com.opentable.lastquery.\(connectionId.uuidString)" + + // Only save non-empty queries (trimmed to avoid saving whitespace-only queries) + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: key) + } else { + defaults.set(trimmed, forKey: key) + } + } + + /// Load the last query text for a connection + func loadLastQuery(for connectionId: UUID) -> String? { + let key = "com.opentable.lastquery.\(connectionId.uuidString)" + return defaults.string(forKey: key) + } + + // MARK: - Private Helpers + + private func tabStateKey(for connectionId: UUID) -> String { + return "\(tabStateKeyPrefix)\(connectionId.uuidString)" + } +} diff --git a/OpenTable/Models/QueryTab.swift b/OpenTable/Models/QueryTab.swift index e15e37d66..20d1548dc 100644 --- a/OpenTable/Models/QueryTab.swift +++ b/OpenTable/Models/QueryTab.swift @@ -9,11 +9,21 @@ import Combine import Foundation /// Type of tab -enum TabType: Equatable { +enum TabType: Equatable, Codable { case query // SQL editor tab case table // Direct table view tab } +/// Minimal representation of a tab for persistence +struct PersistedTab: Codable { + let id: UUID + let title: String + let query: String + let isPinned: Bool + let tabType: TabType + let tableName: String? +} + /// Stores pending changes for a tab (used to preserve state when switching tabs) struct TabPendingChanges: Equatable { var changes: [RowChange] @@ -158,6 +168,45 @@ struct QueryTab: Identifiable, Equatable { self.pagination = PaginationState() self.filterState = TabFilterState() } + + /// Initialize from persisted tab state (used when restoring tabs) + init(from persisted: PersistedTab) { + self.id = persisted.id + self.title = persisted.title + self.query = persisted.query + self.isPinned = persisted.isPinned + self.tabType = persisted.tabType + self.tableName = persisted.tableName + + // Initialize runtime state with defaults + self.lastExecutedAt = nil + self.resultColumns = [] + self.columnDefaults = [:] + self.resultRows = [] + self.executionTime = nil + self.errorMessage = nil + self.isExecuting = false + self.isEditable = persisted.tabType == .table + self.showStructure = false + self.pendingChanges = TabPendingChanges() + self.selectedRowIndices = [] + self.sortState = SortState() + self.hasUserInteraction = false + self.pagination = PaginationState() + self.filterState = TabFilterState() + } + + /// Convert tab to persisted format for storage + func toPersistedTab() -> PersistedTab { + return PersistedTab( + id: id, + title: title, + query: query, + isPinned: isPinned, + tabType: tabType, + tableName: tableName + ) + } static func == (lhs: QueryTab, rhs: QueryTab) -> Bool { lhs.id == rhs.id @@ -187,9 +236,16 @@ final class QueryTabManager: ObservableObject { // MARK: - Tab Management - func addTab() { + func addTab(initialQuery: String? = nil) { let queryCount = tabs.filter { $0.tabType == .query }.count - let newTab = QueryTab(title: "Query \(queryCount + 1)", tabType: .query) + var newTab = QueryTab(title: "Query \(queryCount + 1)", tabType: .query) + + // If initialQuery provided, use it; otherwise tab starts empty + if let query = initialQuery { + newTab.query = query + newTab.hasUserInteraction = true // Mark as having content + } + tabs.append(newTab) selectedTabId = newTab.id } diff --git a/OpenTable/Views/MainContentView.swift b/OpenTable/Views/MainContentView.swift index dde624da4..474c1a1d5 100644 --- a/OpenTable/Views/MainContentView.swift +++ b/OpenTable/Views/MainContentView.swift @@ -38,6 +38,7 @@ struct MainContentView: View { @State private var currentQueryTask: Task? @State private var queryGeneration: Int = 0 @State private var changeManagerUpdateTask: Task? + @State private var isRestoringTabs = false // Prevent circular sync during restoration // Error alert state @State private var showErrorAlert = false @@ -196,6 +197,29 @@ struct MainContentView: View { .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in // Must be synchronous - save state BEFORE SwiftUI updates the view handleTabChange(oldTabId: oldTabId, newTabId: newTabId) + + // Sync selected tab ID to session for persistence + if let sessionId = DatabaseManager.shared.currentSessionId { + DatabaseManager.shared.updateSession(sessionId) { session in + session.selectedTabId = newTabId + } + } + } + .onChange(of: tabManager.tabs) { _, newTabs in + // Skip sync if we're currently restoring tabs from session (prevents circular updates) + guard !isRestoringTabs else { return } + + // Sync tabs array to session for persistence + if let sessionId = DatabaseManager.shared.currentSessionId { + DatabaseManager.shared.updateSession(sessionId) { session in + session.tabs = newTabs + } + + // Clear saved state immediately when all tabs are closed + if newTabs.isEmpty { + TabStateStorage.shared.clearTabState(connectionId: connection.id) + } + } } .onChange(of: currentTab?.resultColumns) { _, newColumns in Task { @MainActor in @@ -283,6 +307,18 @@ struct MainContentView: View { viewWithToolbar .task { await initializeView() + + // Restore tabs from session if available (after DatabaseManager has loaded them) + if let sessionId = DatabaseManager.shared.currentSessionId, + let session = DatabaseManager.shared.activeSessions[sessionId], + !session.tabs.isEmpty { + // Set flag to prevent onChange(tabManager.tabs) from syncing back + // Use defer to ensure flag is always reset even if an error occurs + isRestoringTabs = true + defer { isRestoringTabs = false } + tabManager.tabs = session.tabs + tabManager.selectedTabId = session.selectedTabId + } } .onChange(of: selectedTables) { oldTables, newTables in // Find newly added table to open @@ -305,9 +341,10 @@ struct MainContentView: View { } } .onReceive(NotificationCenter.default.publisher(for: .newTab)) { _ in - // Cmd+T to create new query tab + // Cmd+T - create new query tab - load last query if available Task { @MainActor in - tabManager.addTab() + let lastQuery = TabStateStorage.shared.loadLastQuery(for: connection.id) + tabManager.addTab(initialQuery: lastQuery) } } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("loadQueryIntoEditor"))) { notification in @@ -421,6 +458,9 @@ struct MainContentView: View { set: { newValue in if let index = tabManager.selectedTabIndex { tabManager.tabs[index].query = newValue + + // Save as last query for this connection (TablePlus-style) + TabStateStorage.shared.saveLastQuery(newValue, for: connection.id) } } ),