Skip to content

Commit 6f8d19c

Browse files
committed
feat: implement tab persistence and last query memory
- Add TabStateStorage service for saving/loading tab state per connection - Implement tab persistence: save tabs on quit, restore on reconnect - Add last query memory: auto-fill new query tabs with last typed query - Smart state clearing: remove saved state when all tabs are closed - Sync tabs between UI (QueryTabManager) and session (ConnectionSession) - Clean up debug logs for production readiness Features: - Tabs are saved to UserDefaults per connection ID - Only essential tab data is persisted (title, query, type, table name) - Last query is saved separately and loaded into new query tabs - Empty query clears the last query memory - Closing all tabs immediately clears saved state
1 parent ba35aac commit 6f8d19c

5 files changed

Lines changed: 229 additions & 6 deletions

File tree

OpenTable/AppDelegate.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6161

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+
let sessions = DatabaseManager.shared.activeSessions
67+
68+
for (connectionId, session) in sessions {
69+
// Only save if there are tabs; otherwise clear saved state
70+
if session.tabs.isEmpty {
71+
TabStateStorage.shared.clearTabState(connectionId: connectionId)
72+
} else {
73+
TabStateStorage.shared.saveTabState(
74+
connectionId: connectionId,
75+
tabs: session.tabs,
76+
selectedTabId: session.selectedTabId
77+
)
78+
}
79+
}
80+
81+
// NOW disconnect sessions asynchronously (after save is complete)
6582
Task { @MainActor in
6683
await DatabaseManager.shared.disconnectAll()
6784
}
@@ -73,6 +90,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
7390
}
7491
}
7592

93+
func applicationWillTerminate(_ notification: Notification) {
94+
// Save tab state synchronously before app terminates (backup mechanism)
95+
let sessions = DatabaseManager.shared.activeSessions
96+
for (connectionId, session) in sessions {
97+
if session.tabs.isEmpty {
98+
TabStateStorage.shared.clearTabState(connectionId: connectionId)
99+
} else {
100+
TabStateStorage.shared.saveTabState(
101+
connectionId: connectionId,
102+
tabs: session.tabs,
103+
selectedTabId: session.selectedTabId
104+
)
105+
}
106+
}
107+
}
108+
76109
private func isMainWindow(_ window: NSWindow) -> Bool {
77110
// Main window has identifier containing "main" (from WindowGroup(id: "main"))
78111
// This excludes temporary windows like context menus, panels, popovers, etc.

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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
17+
/// Service for persisting tab state per connection
18+
final class TabStateStorage {
19+
static let shared = TabStateStorage()
20+
21+
private let defaults = UserDefaults.standard
22+
private let tabStateKeyPrefix = "com.opentable.tabs."
23+
24+
private init() {}
25+
26+
// MARK: - Public API
27+
28+
/// Save tab state for a connection
29+
func saveTabState(connectionId: UUID, tabs: [QueryTab], selectedTabId: UUID?) {
30+
let persistedTabs = tabs.map { $0.toPersistedTab() }
31+
let tabState = TabState(tabs: persistedTabs, selectedTabId: selectedTabId)
32+
33+
do {
34+
let encoder = JSONEncoder()
35+
let data = try encoder.encode(tabState)
36+
let key = tabStateKey(for: connectionId)
37+
defaults.set(data, forKey: key)
38+
defaults.synchronize()
39+
} catch {
40+
// Silent failure - tab state is not critical
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+
return nil
57+
}
58+
}
59+
60+
/// Clear tab state for a connection
61+
func clearTabState(connectionId: UUID) {
62+
let key = tabStateKey(for: connectionId)
63+
defaults.removeObject(forKey: key)
64+
}
65+
66+
// MARK: - Last Query Memory (TablePlus-style)
67+
68+
/// Save the last query text for a connection (persists across tab close/open)
69+
func saveLastQuery(_ query: String, for connectionId: UUID) {
70+
let key = "com.opentable.lastquery.\(connectionId.uuidString)"
71+
72+
// Only save non-empty queries
73+
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
74+
if trimmed.isEmpty {
75+
defaults.removeObject(forKey: key)
76+
} else {
77+
defaults.set(query, forKey: key)
78+
defaults.synchronize()
79+
}
80+
}
81+
82+
/// Load the last query text for a connection
83+
func loadLastQuery(for connectionId: UUID) -> String? {
84+
let key = "com.opentable.lastquery.\(connectionId.uuidString)"
85+
return defaults.string(forKey: key)
86+
}
87+
88+
// MARK: - Private Helpers
89+
90+
private func tabStateKey(for connectionId: UUID) -> String {
91+
return "\(tabStateKeyPrefix)\(connectionId.uuidString)"
92+
}
93+
}

OpenTable/Models/QueryTab.swift

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@ 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+
27+
1728
/// Stores pending changes for a tab (used to preserve state when switching tabs)
1829
struct TabPendingChanges: Equatable {
1930
var changes: [RowChange]
@@ -158,12 +169,52 @@ struct QueryTab: Identifiable, Equatable {
158169
self.pagination = PaginationState()
159170
self.filterState = TabFilterState()
160171
}
172+
173+
/// Initialize from persisted tab state (used when restoring tabs)
174+
init(from persisted: PersistedTab) {
175+
self.id = persisted.id
176+
self.title = persisted.title
177+
self.query = persisted.query
178+
self.isPinned = persisted.isPinned
179+
self.tabType = persisted.tabType
180+
self.tableName = persisted.tableName
181+
182+
// Initialize runtime state with defaults
183+
self.lastExecutedAt = nil
184+
self.resultColumns = []
185+
self.columnDefaults = [:]
186+
self.resultRows = []
187+
self.executionTime = nil
188+
self.errorMessage = nil
189+
self.isExecuting = false
190+
self.isEditable = persisted.tabType == .table
191+
self.showStructure = false
192+
self.pendingChanges = TabPendingChanges()
193+
self.selectedRowIndices = []
194+
self.sortState = SortState()
195+
self.hasUserInteraction = false
196+
self.pagination = PaginationState()
197+
self.filterState = TabFilterState()
198+
}
199+
200+
/// Convert tab to persisted format for storage
201+
func toPersistedTab() -> PersistedTab {
202+
return PersistedTab(
203+
id: id,
204+
title: title,
205+
query: query,
206+
isPinned: isPinned,
207+
tabType: tabType,
208+
tableName: tableName
209+
)
210+
}
161211

162212
static func == (lhs: QueryTab, rhs: QueryTab) -> Bool {
163213
lhs.id == rhs.id
164214
}
165215
}
166216

217+
167218
/// Manager for query tabs
168219
final class QueryTabManager: ObservableObject {
169220
@Published var tabs: [QueryTab] = []
@@ -187,9 +238,16 @@ final class QueryTabManager: ObservableObject {
187238

188239
// MARK: - Tab Management
189240

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

OpenTable/Views/MainContentView.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,26 @@ struct MainContentView: View {
196196
.onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in
197197
// Must be synchronous - save state BEFORE SwiftUI updates the view
198198
handleTabChange(oldTabId: oldTabId, newTabId: newTabId)
199+
200+
// Sync selected tab ID to session for persistence
201+
if let sessionId = DatabaseManager.shared.currentSessionId {
202+
DatabaseManager.shared.updateSession(sessionId) { session in
203+
session.selectedTabId = newTabId
204+
}
205+
}
206+
}
207+
.onChange(of: tabManager.tabs) { _, newTabs in
208+
// Sync tabs array to session for persistence
209+
if let sessionId = DatabaseManager.shared.currentSessionId {
210+
DatabaseManager.shared.updateSession(sessionId) { session in
211+
session.tabs = newTabs
212+
}
213+
214+
// Clear saved state immediately when all tabs are closed
215+
if newTabs.isEmpty {
216+
TabStateStorage.shared.clearTabState(connectionId: connection.id)
217+
}
218+
}
199219
}
200220
.onChange(of: currentTab?.resultColumns) { _, newColumns in
201221
Task { @MainActor in
@@ -283,6 +303,14 @@ struct MainContentView: View {
283303
viewWithToolbar
284304
.task {
285305
await initializeView()
306+
307+
// Restore tabs from session if available
308+
if let sessionId = DatabaseManager.shared.currentSessionId,
309+
let session = DatabaseManager.shared.activeSessions[sessionId],
310+
!session.tabs.isEmpty {
311+
tabManager.tabs = session.tabs
312+
tabManager.selectedTabId = session.selectedTabId
313+
}
286314
}
287315
.onChange(of: selectedTables) { oldTables, newTables in
288316
// Find newly added table to open
@@ -305,9 +333,10 @@ struct MainContentView: View {
305333
}
306334
}
307335
.onReceive(NotificationCenter.default.publisher(for: .newTab)) { _ in
308-
// Cmd+T to create new query tab
336+
// Cmd+T to create new query tab - load last query if available
309337
Task { @MainActor in
310-
tabManager.addTab()
338+
let lastQuery = TabStateStorage.shared.loadLastQuery(for: connection.id)
339+
tabManager.addTab(initialQuery: lastQuery)
311340
}
312341
}
313342
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("loadQueryIntoEditor"))) { notification in
@@ -421,6 +450,9 @@ struct MainContentView: View {
421450
set: { newValue in
422451
if let index = tabManager.selectedTabIndex {
423452
tabManager.tabs[index].query = newValue
453+
454+
// Save as last query for this connection (TablePlus-style)
455+
TabStateStorage.shared.saveLastQuery(newValue, for: connection.id)
424456
}
425457
}
426458
),

0 commit comments

Comments
 (0)