Skip to content

Commit 8198db3

Browse files
authored
Merge pull request #5 from datlechin/feature/tab-persistence-and-query-memory
feat: implement tab persistence and last query memory
2 parents ba35aac + 4e2325d commit 8198db3

6 files changed

Lines changed: 230 additions & 8 deletions

File tree

OpenTable/AppDelegate.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,44 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5858

5959
// Clean up window tracking
6060
configuredWindows.remove(ObjectIdentifier(window))
61-
61+
6262
// Check if main window is being closed
6363
if isMainWindow(window) {
64-
// Disconnect all sessions
64+
// CRITICAL: Save tab state SYNCHRONOUSLY before any async operations
65+
// Otherwise sessions might be cleared before we save
66+
saveAllTabStates()
67+
68+
// NOW disconnect sessions asynchronously (after save is complete)
6569
Task { @MainActor in
6670
await DatabaseManager.shared.disconnectAll()
6771
}
68-
72+
6973
// Reopen welcome window after a brief delay
7074
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
7175
self.openWelcomeWindow()
7276
}
7377
}
7478
}
79+
80+
func applicationWillTerminate(_ notification: Notification) {
81+
// Save tab state synchronously before app terminates (backup mechanism)
82+
saveAllTabStates()
83+
}
84+
85+
/// Save tab state for all active sessions
86+
private func saveAllTabStates() {
87+
for (connectionId, session) in DatabaseManager.shared.activeSessions {
88+
if session.tabs.isEmpty {
89+
TabStateStorage.shared.clearTabState(connectionId: connectionId)
90+
} else {
91+
TabStateStorage.shared.saveTabState(
92+
connectionId: connectionId,
93+
tabs: session.tabs,
94+
selectedTabId: session.selectedTabId
95+
)
96+
}
97+
}
98+
}
7599

76100
private func isMainWindow(_ window: NSWindow) -> Bool {
77101
// Main window has identifier containing "main" (from WindowGroup(id: "main"))

OpenTable/Core/Database/DatabaseManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ final class DatabaseManager: ObservableObject {
109109
session.status = driver.status
110110
activeSessions[connection.id] = session
111111

112+
// Restore tab state if it exists
113+
if let tabState = TabStateStorage.shared.loadTabState(connectionId: connection.id) {
114+
let restoredTabs = tabState.tabs.map { QueryTab(from: $0) }
115+
activeSessions[connection.id]?.tabs = restoredTabs
116+
activeSessions[connection.id]?.selectedTabId = tabState.selectedTabId
117+
}
118+
112119
// Post notification for reliable delivery
113120
NotificationCenter.default.post(name: .databaseDidConnect, object: nil)
114121
} catch {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//
2+
// TabStateStorage.swift
3+
// OpenTable
4+
//
5+
// Service for persisting tab state per connection
6+
//
7+
8+
import Foundation
9+
10+
/// Represents persisted tab state for a connection
11+
struct TabState: Codable {
12+
let tabs: [PersistedTab]
13+
let selectedTabId: UUID?
14+
}
15+
16+
/// Service for persisting tab state per connection
17+
final class TabStateStorage {
18+
static let shared = TabStateStorage()
19+
20+
private let defaults = UserDefaults.standard
21+
private let tabStateKeyPrefix = "com.opentable.tabs."
22+
23+
private init() {}
24+
25+
// MARK: - Public API
26+
27+
/// Save tab state for a connection
28+
func saveTabState(connectionId: UUID, tabs: [QueryTab], selectedTabId: UUID?) {
29+
let persistedTabs = tabs.map { $0.toPersistedTab() }
30+
let tabState = TabState(tabs: persistedTabs, selectedTabId: selectedTabId)
31+
32+
do {
33+
let encoder = JSONEncoder()
34+
let data = try encoder.encode(tabState)
35+
let key = tabStateKey(for: connectionId)
36+
defaults.set(data, forKey: key)
37+
} catch {
38+
#if DEBUG
39+
print("[TabStateStorage] Failed to encode tab state: \(error.localizedDescription)")
40+
#endif
41+
}
42+
}
43+
44+
/// Load tab state for a connection
45+
func loadTabState(connectionId: UUID) -> TabState? {
46+
let key = tabStateKey(for: connectionId)
47+
48+
guard let data = defaults.data(forKey: key) else {
49+
return nil
50+
}
51+
52+
do {
53+
let decoder = JSONDecoder()
54+
return try decoder.decode(TabState.self, from: data)
55+
} catch {
56+
#if DEBUG
57+
print("[TabStateStorage] Failed to decode tab state: \(error.localizedDescription)")
58+
#endif
59+
return nil
60+
}
61+
}
62+
63+
/// Clear tab state for a connection
64+
func clearTabState(connectionId: UUID) {
65+
let key = tabStateKey(for: connectionId)
66+
defaults.removeObject(forKey: key)
67+
}
68+
69+
// MARK: - Last Query Memory (TablePlus-style)
70+
71+
/// Save the last query text for a connection (persists across tab close/open)
72+
func saveLastQuery(_ query: String, for connectionId: UUID) {
73+
let key = "com.opentable.lastquery.\(connectionId.uuidString)"
74+
75+
// Only save non-empty queries (trimmed to avoid saving whitespace-only queries)
76+
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
77+
if trimmed.isEmpty {
78+
defaults.removeObject(forKey: key)
79+
} else {
80+
defaults.set(trimmed, forKey: key)
81+
}
82+
}
83+
84+
/// Load the last query text for a connection
85+
func loadLastQuery(for connectionId: UUID) -> String? {
86+
let key = "com.opentable.lastquery.\(connectionId.uuidString)"
87+
return defaults.string(forKey: key)
88+
}
89+
90+
// MARK: - Private Helpers
91+
92+
private func tabStateKey(for connectionId: UUID) -> String {
93+
return "\(tabStateKeyPrefix)\(connectionId.uuidString)"
94+
}
95+
}

OpenTable/Models/QueryTab.swift

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,21 @@ import Combine
99
import Foundation
1010

1111
/// Type of tab
12-
enum TabType: Equatable {
12+
enum TabType: Equatable, Codable {
1313
case query // SQL editor tab
1414
case table // Direct table view tab
1515
}
1616

17+
/// Minimal representation of a tab for persistence
18+
struct PersistedTab: Codable {
19+
let id: UUID
20+
let title: String
21+
let query: String
22+
let isPinned: Bool
23+
let tabType: TabType
24+
let tableName: String?
25+
}
26+
1727
/// Stores pending changes for a tab (used to preserve state when switching tabs)
1828
struct TabPendingChanges: Equatable {
1929
var changes: [RowChange]
@@ -158,6 +168,45 @@ struct QueryTab: Identifiable, Equatable {
158168
self.pagination = PaginationState()
159169
self.filterState = TabFilterState()
160170
}
171+
172+
/// Initialize from persisted tab state (used when restoring tabs)
173+
init(from persisted: PersistedTab) {
174+
self.id = persisted.id
175+
self.title = persisted.title
176+
self.query = persisted.query
177+
self.isPinned = persisted.isPinned
178+
self.tabType = persisted.tabType
179+
self.tableName = persisted.tableName
180+
181+
// Initialize runtime state with defaults
182+
self.lastExecutedAt = nil
183+
self.resultColumns = []
184+
self.columnDefaults = [:]
185+
self.resultRows = []
186+
self.executionTime = nil
187+
self.errorMessage = nil
188+
self.isExecuting = false
189+
self.isEditable = persisted.tabType == .table
190+
self.showStructure = false
191+
self.pendingChanges = TabPendingChanges()
192+
self.selectedRowIndices = []
193+
self.sortState = SortState()
194+
self.hasUserInteraction = false
195+
self.pagination = PaginationState()
196+
self.filterState = TabFilterState()
197+
}
198+
199+
/// Convert tab to persisted format for storage
200+
func toPersistedTab() -> PersistedTab {
201+
return PersistedTab(
202+
id: id,
203+
title: title,
204+
query: query,
205+
isPinned: isPinned,
206+
tabType: tabType,
207+
tableName: tableName
208+
)
209+
}
161210

162211
static func == (lhs: QueryTab, rhs: QueryTab) -> Bool {
163212
lhs.id == rhs.id
@@ -187,9 +236,16 @@ final class QueryTabManager: ObservableObject {
187236

188237
// MARK: - Tab Management
189238

190-
func addTab() {
239+
func addTab(initialQuery: String? = nil) {
191240
let queryCount = tabs.filter { $0.tabType == .query }.count
192-
let newTab = QueryTab(title: "Query \(queryCount + 1)", tabType: .query)
241+
var newTab = QueryTab(title: "Query \(queryCount + 1)", tabType: .query)
242+
243+
// If initialQuery provided, use it; otherwise tab starts empty
244+
if let query = initialQuery {
245+
newTab.query = query
246+
newTab.hasUserInteraction = true // Mark as having content
247+
}
248+
193249
tabs.append(newTab)
194250
selectedTabId = newTab.id
195251
}

OpenTable/Views/MainContentView.swift

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ struct MainContentView: View {
3838
@State private var currentQueryTask: Task<Void, Never>?
3939
@State private var queryGeneration: Int = 0
4040
@State private var changeManagerUpdateTask: Task<Void, Never>?
41+
@State private var isRestoringTabs = false // Prevent circular sync during restoration
4142

4243
// Error alert state
4344
@State private var showErrorAlert = false
@@ -196,6 +197,29 @@ struct MainContentView: View {
196197
.onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in
197198
// Must be synchronous - save state BEFORE SwiftUI updates the view
198199
handleTabChange(oldTabId: oldTabId, newTabId: newTabId)
200+
201+
// Sync selected tab ID to session for persistence
202+
if let sessionId = DatabaseManager.shared.currentSessionId {
203+
DatabaseManager.shared.updateSession(sessionId) { session in
204+
session.selectedTabId = newTabId
205+
}
206+
}
207+
}
208+
.onChange(of: tabManager.tabs) { _, newTabs in
209+
// Skip sync if we're currently restoring tabs from session (prevents circular updates)
210+
guard !isRestoringTabs else { return }
211+
212+
// Sync tabs array to session for persistence
213+
if let sessionId = DatabaseManager.shared.currentSessionId {
214+
DatabaseManager.shared.updateSession(sessionId) { session in
215+
session.tabs = newTabs
216+
}
217+
218+
// Clear saved state immediately when all tabs are closed
219+
if newTabs.isEmpty {
220+
TabStateStorage.shared.clearTabState(connectionId: connection.id)
221+
}
222+
}
199223
}
200224
.onChange(of: currentTab?.resultColumns) { _, newColumns in
201225
Task { @MainActor in
@@ -283,6 +307,18 @@ struct MainContentView: View {
283307
viewWithToolbar
284308
.task {
285309
await initializeView()
310+
311+
// Restore tabs from session if available (after DatabaseManager has loaded them)
312+
if let sessionId = DatabaseManager.shared.currentSessionId,
313+
let session = DatabaseManager.shared.activeSessions[sessionId],
314+
!session.tabs.isEmpty {
315+
// Set flag to prevent onChange(tabManager.tabs) from syncing back
316+
// Use defer to ensure flag is always reset even if an error occurs
317+
isRestoringTabs = true
318+
defer { isRestoringTabs = false }
319+
tabManager.tabs = session.tabs
320+
tabManager.selectedTabId = session.selectedTabId
321+
}
286322
}
287323
.onChange(of: selectedTables) { oldTables, newTables in
288324
// Find newly added table to open
@@ -305,9 +341,10 @@ struct MainContentView: View {
305341
}
306342
}
307343
.onReceive(NotificationCenter.default.publisher(for: .newTab)) { _ in
308-
// Cmd+T to create new query tab
344+
// Cmd+T - create new query tab - load last query if available
309345
Task { @MainActor in
310-
tabManager.addTab()
346+
let lastQuery = TabStateStorage.shared.loadLastQuery(for: connection.id)
347+
tabManager.addTab(initialQuery: lastQuery)
311348
}
312349
}
313350
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("loadQueryIntoEditor"))) { notification in
@@ -421,6 +458,9 @@ struct MainContentView: View {
421458
set: { newValue in
422459
if let index = tabManager.selectedTabIndex {
423460
tabManager.tabs[index].query = newValue
461+
462+
// Save as last query for this connection (TablePlus-style)
463+
TabStateStorage.shared.saveLastQuery(newValue, for: connection.id)
424464
}
425465
}
426466
),

0 commit comments

Comments
 (0)