Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
30 changes: 27 additions & 3 deletions OpenTable/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
7 changes: 7 additions & 0 deletions OpenTable/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions OpenTable/Core/Storage/TabStateStorage.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
62 changes: 59 additions & 3 deletions OpenTable/Models/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
44 changes: 42 additions & 2 deletions OpenTable/Views/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct MainContentView: View {
@State private var currentQueryTask: Task<Void, Never>?
@State private var queryGeneration: Int = 0
@State private var changeManagerUpdateTask: Task<Void, Never>?
@State private var isRestoringTabs = false // Prevent circular sync during restoration

// Error alert state
@State private var showErrorAlert = false
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment thread
datlechin marked this conversation as resolved.
}
}
),
Expand Down