Skip to content

Commit dc9736f

Browse files
authored
feat(coordinator): restore session view state and recover after crashes (#1673) (#1680)
* feat(coordinator): restore session view state and recover after crashes (#1673) * fix(launch): reopen last session and dismiss welcome on relaunch (#1673) * fix(launch): connect restored sessions reopened from the recovery list (#1673) * feat(coordinator): restore open preview table tabs as permanent (#1673) * refactor(coordinator): unify restored db/schema switch and test sort resolution (#1673) * test: make schema-switch test helper async for Swift 6 strict concurrency
1 parent 5af21b6 commit dc9736f

25 files changed

Lines changed: 928 additions & 151 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- 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.
1616
- Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab.
1717
- `.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)
18+
- 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)
1819

1920
### Changed
2021

@@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2728
- 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.
2829
- 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)
2930
- The inspector panel can now be resized freely by dragging its divider instead of being capped at a fixed width.
31+
- 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)
3032

3133
### Fixed
3234

TablePro/AppDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
143143
}
144144

145145
func applicationWillTerminate(_ notification: Notification) {
146+
persistOpenConnectionsForRecovery()
146147
LinkedFolderWatcher.shared.stop()
147148
SQLFolderWatcher.shared.stop()
148149
SSHTunnelManager.shared.terminateAllProcessesSync()
149150
CloudflareTunnelManager.shared.terminateAllProcessesSync()
150151
}
151152

153+
private func persistOpenConnectionsForRecovery() {
154+
var seen = Set<UUID>()
155+
let connectionIds = MainContentCoordinator.activeCoordinators.values
156+
.map(\.connectionId)
157+
.filter { seen.insert($0).inserted }
158+
LastOpenConnectionsStorage.shared.save(connectionIds: connectionIds)
159+
}
160+
152161
@objc func handleSystemDidWake(_ notification: Notification) {
153162
SQLFolderWatcher.shared.reload()
154163
}

TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,46 @@ internal final class AppLaunchCoordinator {
125125
guard intents.isEmpty else { return }
126126

127127
let general = AppSettingsStorage.shared.loadGeneral()
128-
if general.startupBehavior == .showWelcome {
128+
switch general.startupBehavior {
129+
case .showWelcome:
129130
for window in NSApp.windows where Self.isMainWindow(window) {
130131
window.close()
131132
}
133+
case .reopenLast:
134+
reopenLastSessionIfArchiveMissing()
135+
}
136+
}
137+
138+
private func reopenLastSessionIfArchiveMissing() {
139+
guard !NSApp.windows.contains(where: { Self.isMainWindow($0) }) else { return }
140+
141+
let connectionIds = LastOpenConnectionsStorage.shared.load()
142+
guard !connectionIds.isEmpty else { return }
143+
144+
let connectionsById = Dictionary(
145+
ConnectionStorage.shared.loadConnections().map { ($0.id, $0) },
146+
uniquingKeysWith: { first, _ in first }
147+
)
148+
var openedAny = false
149+
for connectionId in connectionIds {
150+
guard let connection = connectionsById[connectionId] else { continue }
151+
WindowManager.shared.openTab(
152+
payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault)
153+
)
154+
openedAny = true
155+
Task {
156+
do {
157+
try await DatabaseManager.shared.ensureConnected(connection)
158+
} catch {
159+
Self.logger.error(
160+
"[restore] reopen connect failed for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)"
161+
)
162+
}
163+
}
164+
}
165+
166+
if openedAny {
167+
WindowOpener.shared.orderOutWelcome()
132168
}
133169
}
134170

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// RestorationGroupRegistry.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
@MainActor
9+
enum RestorationGroupRegistry {
10+
struct WindowGroup {
11+
let tabs: [QueryTab]
12+
let selectedTabId: UUID?
13+
}
14+
15+
private static var groups: [UUID: WindowGroup] = [:]
16+
private static let entryLifetime: Duration = .seconds(10)
17+
18+
static func register(_ group: WindowGroup, for payloadId: UUID) {
19+
groups[payloadId] = group
20+
Task { @MainActor in
21+
try? await Task.sleep(for: entryLifetime)
22+
groups.removeValue(forKey: payloadId)
23+
}
24+
}
25+
26+
static func consume(for payloadId: UUID?) -> WindowGroup? {
27+
guard let payloadId else { return nil }
28+
return groups.removeValue(forKey: payloadId)
29+
}
30+
}

TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ extension TabPersistenceCoordinator {
1515
clearSavedState()
1616
} else {
1717
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
18-
saveNow(tabs: aggregatedTabs, selectedTabId: selectedId)
18+
saveNow(windowedTabs: aggregatedTabs, selectedTabId: selectedId)
1919
}
2020
}
2121

@@ -27,7 +27,7 @@ extension TabPersistenceCoordinator {
2727
saveNowSync(tabs: [], selectedTabId: nil)
2828
} else {
2929
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
30-
saveNowSync(tabs: aggregatedTabs, selectedTabId: selectedId)
30+
saveNowSync(windowedTabs: aggregatedTabs, selectedTabId: selectedId)
3131
}
3232
}
3333
}

TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ internal struct RestoreResult {
1111
let tabs: [QueryTab]
1212
let selectedTabId: UUID?
1313
let source: RestoreSource
14+
var lastActiveDatabase: String? = nil
15+
var lastActiveSchema: String? = nil
1416

1517
enum RestoreSource {
1618
case disk
@@ -32,29 +34,53 @@ internal final class TabPersistenceCoordinator {
3234
// MARK: - Save
3335

3436
internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) {
35-
let nonPreviewTabs = tabs.filter { !$0.isPreview }
36-
guard !nonPreviewTabs.isEmpty else {
37+
saveNow(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId)
38+
}
39+
40+
internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) {
41+
guard !windowedTabs.isEmpty else {
3742
clearSavedState()
3843
return
3944
}
40-
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
41-
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
42-
? selectedTabId : nonPreviewTabs.first?.id
43-
scheduleSave(tabs: persisted, selectedTabId: normalizedSelectedId)
45+
let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) }
46+
let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId })
47+
? selectedTabId : windowedTabs.first?.tab.id
48+
let active = currentActiveDatabaseAndSchema()
49+
scheduleSave(
50+
tabs: persisted,
51+
selectedTabId: normalizedSelectedId,
52+
lastActiveDatabase: active.database,
53+
lastActiveSchema: active.schema
54+
)
4455
}
4556

4657
internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) {
47-
let nonPreviewTabs = tabs.filter { !$0.isPreview }
48-
guard !nonPreviewTabs.isEmpty else {
58+
saveNowSync(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId)
59+
}
60+
61+
internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) {
62+
guard !windowedTabs.isEmpty else {
4963
saveTask?.cancel()
5064
saveTask = nil
5165
TabDiskActor.clearSync(connectionId: connectionId)
5266
return
5367
}
54-
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
55-
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
56-
? selectedTabId : nonPreviewTabs.first?.id
57-
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId)
68+
let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) }
69+
let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId })
70+
? selectedTabId : windowedTabs.first?.tab.id
71+
let active = currentActiveDatabaseAndSchema()
72+
TabDiskActor.saveSync(
73+
connectionId: connectionId,
74+
tabs: persisted,
75+
selectedTabId: normalizedSelectedId,
76+
lastActiveDatabase: active.database,
77+
lastActiveSchema: active.schema
78+
)
79+
}
80+
81+
private func currentActiveDatabaseAndSchema() -> (database: String?, schema: String?) {
82+
guard let session = DatabaseManager.shared.session(for: connectionId) else { return (nil, nil) }
83+
return (session.currentDatabase, session.currentSchema)
5884
}
5985

6086
// MARK: - Clear
@@ -70,18 +96,31 @@ internal final class TabPersistenceCoordinator {
7096

7197
// MARK: - Private save scheduling
7298

73-
private func scheduleSave(tabs: [PersistedTab], selectedTabId: UUID?) {
99+
private func scheduleSave(
100+
tabs: [PersistedTab],
101+
selectedTabId: UUID?,
102+
lastActiveDatabase: String?,
103+
lastActiveSchema: String?
104+
) {
74105
saveTask?.cancel()
75106
let connId = connectionId
76107
let tabsCopy = tabs
77108
let selectedId = selectedTabId
109+
let activeDatabase = lastActiveDatabase
110+
let activeSchema = lastActiveSchema
78111
Self.logger.debug("[persist] saveNow queued tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public)")
79112

80113
saveTask = Task {
81114
guard !Task.isCancelled else { return }
82115
let t0 = Date()
83116
do {
84-
try await TabDiskActor.shared.save(connectionId: connId, tabs: tabsCopy, selectedTabId: selectedId)
117+
try await TabDiskActor.shared.save(
118+
connectionId: connId,
119+
tabs: tabsCopy,
120+
selectedTabId: selectedId,
121+
lastActiveDatabase: activeDatabase,
122+
lastActiveSchema: activeSchema
123+
)
85124
Self.logger.debug("[persist] saveNow written tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
86125
} catch is CancellationError {
87126
return
@@ -114,30 +153,9 @@ internal final class TabPersistenceCoordinator {
114153
return RestoreResult(
115154
tabs: restoredTabs,
116155
selectedTabId: state.selectedTabId,
117-
source: .disk
118-
)
119-
}
120-
121-
// MARK: - Private
122-
123-
private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab {
124-
let persistedQuery: String
125-
if (tab.content.query as NSString).length > TabQueryContent.maxPersistableQuerySize {
126-
persistedQuery = ""
127-
} else {
128-
persistedQuery = tab.content.query
129-
}
130-
131-
return PersistedTab(
132-
id: tab.id,
133-
title: tab.title,
134-
query: persistedQuery,
135-
tabType: tab.tabType,
136-
tableName: tab.tableContext.tableName,
137-
isView: tab.tableContext.isView,
138-
databaseName: tab.tableContext.databaseName,
139-
schemaName: tab.tableContext.schemaName,
140-
sourceFileURL: tab.content.sourceFileURL
156+
source: .disk,
157+
lastActiveDatabase: state.lastActiveDatabase,
158+
lastActiveSchema: state.lastActiveSchema
141159
)
142160
}
143161
}

TablePro/Core/Storage/AppSettingsStorage.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ final class AppSettingsStorage {
3232
static let sync = "com.TablePro.settings.sync"
3333
static let mcp = "com.TablePro.settings.mcp"
3434
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
35+
static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast"
3536
}
3637

3738
init(userDefaults: UserDefaults = .standard) {
@@ -48,6 +49,17 @@ final class AppSettingsStorage {
4849
save(settings, key: Keys.general)
4950
}
5051

52+
func migrateStartupBehaviorToReopenLastIfNeeded() {
53+
guard !defaults.bool(forKey: Keys.startupReopenMigration) else { return }
54+
defaults.set(true, forKey: Keys.startupReopenMigration)
55+
56+
guard defaults.data(forKey: Keys.general) != nil else { return }
57+
var general = loadGeneral()
58+
guard general.startupBehavior == .showWelcome else { return }
59+
general.startupBehavior = .reopenLast
60+
saveGeneral(general)
61+
}
62+
5163
// MARK: - Appearance Settings
5264

5365
func loadAppearance() -> AppearanceSettings {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// LastOpenConnectionsStorage.swift
3+
// TablePro
4+
//
5+
// Records which connections had open windows at last quit so the
6+
// "Reopen Last Session" startup behavior can recover after a crash,
7+
// where AppKit's window-restoration archive is never written.
8+
//
9+
10+
import Foundation
11+
import os
12+
13+
@MainActor
14+
final class LastOpenConnectionsStorage {
15+
static let shared = LastOpenConnectionsStorage()
16+
17+
private static let logger = Logger(subsystem: "com.TablePro", category: "LastOpenConnections")
18+
19+
private let fileURL: URL
20+
21+
private convenience init() {
22+
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
23+
?? FileManager.default.temporaryDirectory
24+
self.init(directory: appSupport.appendingPathComponent("TablePro", isDirectory: true))
25+
}
26+
27+
init(directory: URL) {
28+
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
29+
fileURL = directory.appendingPathComponent("LastOpenConnections.json")
30+
}
31+
32+
func save(connectionIds: [UUID]) {
33+
guard !connectionIds.isEmpty else {
34+
clear()
35+
return
36+
}
37+
do {
38+
let data = try JSONEncoder().encode(connectionIds)
39+
try data.write(to: fileURL, options: .atomic)
40+
} catch {
41+
Self.logger.error("Failed to save last open connections: \(error.localizedDescription, privacy: .public)")
42+
}
43+
}
44+
45+
func load() -> [UUID] {
46+
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
47+
do {
48+
let data = try Data(contentsOf: fileURL)
49+
return try JSONDecoder().decode([UUID].self, from: data)
50+
} catch {
51+
Self.logger.error("Failed to load last open connections: \(error.localizedDescription, privacy: .public)")
52+
return []
53+
}
54+
}
55+
56+
func clear() {
57+
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
58+
try? FileManager.default.removeItem(at: fileURL)
59+
}
60+
}

0 commit comments

Comments
 (0)