Skip to content

Commit 024a61a

Browse files
authored
fix(tabs): keep a closed query tab's SQL and add Reopen Closed Tab (#1854) (#1857)
* fix(tabs): keep a closed query tab's SQL and add Reopen Closed Tab (#1854) * fix(tabs): reopen a closed tab into the empty window instead of stranding a blank tab (#1854)
1 parent f94ccaa commit 024a61a

28 files changed

Lines changed: 779 additions & 230 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474)
1414
- The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab.
1515
- Manage database users, roles, and privileges on MySQL and PostgreSQL connections. Open **View > Users & Roles** to see the accounts on the server, pick an object from the server down through databases, schemas, tables, and columns, and grant or revoke privileges on it. The privilege list shows where access actually comes from, including privileges inherited from a role or from a parent object. Changes are staged, undoable with ⌘Z, and shown as the exact SQL before they run. (#1413)
16+
- Bring back a tab you closed by mistake with **File > Reopen Closed Tab** (`Cmd+Shift+T`), or pick an older one from **File > Recently Closed**. The last 20 closed query and table tabs are kept for 30 days, with their SQL, cursor position, and database context. (#1854)
1617

1718
### Changed
1819

1920
- Query results now always show a result tab, so a single result can be pinned before the next query replaces it. Pinning was previously only reachable after running several statements at once. Pin from the tab's context menu or with `Cmd+Option+P`, and hover a tab to see the query that produced it. (#1855)
21+
- Closing a query tab no longer throws away the SQL in it. Every closed tab goes to **Recently Closed** instead, so closing stays quiet and stays undoable. The close button now also shows the unsaved dot for a query tab you have typed into, and a new tab no longer silently inherits the last closed tab's query. (#1854)
2022

2123
### Fixed
2224

2325
- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
2426
- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855)
27+
- Quitting now warns about unsaved changes in any tab, not just the visible one. Unsaved edits to a `.sql` file, table structure changes, and pending truncates and deletes were all missed before. (#1854)
2528

2629
## [0.56.2] - 2026-07-10
2730

TablePro/Core/Services/Infrastructure/LaunchIntent.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@ internal enum LaunchIntent: @unchecked Sendable {
1919
case startMCPServer
2020
case openDatabaseURL(URL)
2121
case installPlugin(URL)
22+
case reopenClosedTab(RecentlyClosedTabEntry)
2223

2324
internal var targetConnectionId: UUID? {
2425
switch self {
2526
case .openConnection(let id),
2627
.openTable(let id, _, _, _, _),
2728
.openQuery(let id, _):
2829
return id
30+
case .reopenClosedTab(let entry):
31+
return entry.connectionId
2932
case .openDatabaseURL,
3033
.openDatabaseFile,
3134
.openInspectorFile,

TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ internal final class LaunchIntentRouter {
2323
.openQuery,
2424
.openDatabaseURL,
2525
.openDatabaseFile,
26-
.openSQLFile:
26+
.openSQLFile,
27+
.reopenClosedTab:
2728
try await TabRouter.shared.route(intent)
2829

2930
case .openInspectorFile(let url):
@@ -87,7 +88,8 @@ internal final class LaunchIntentRouter {
8788
title = String(localized: "Pairing Failed")
8889
case .installPlugin:
8990
title = String(localized: "Plugin Installation Failed")
90-
case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile:
91+
case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile,
92+
.reopenClosedTab:
9193
title = String(localized: "Connection Failed")
9294
case .openSQLFile, .openInspectorFile:
9395
title = String(localized: "Could Not Open File")
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import AppKit
2+
import Foundation
3+
4+
/// Brings a closed tab back into a native window tab. Reopening reuses the restoration path that
5+
/// cold launch already uses, so a reopened table tab recovers its filters, sort, and column
6+
/// layout instead of reimplementing that here.
7+
@MainActor
8+
internal enum RecentlyClosedTabReopener {
9+
internal static func reopenMostRecent() {
10+
guard let entry = RecentlyClosedTabStore.shared.mostRecentEntry else { return }
11+
reopen(id: entry.id)
12+
}
13+
14+
internal static func reopen(id: UUID) {
15+
guard let entry = RecentlyClosedTabStore.shared.consume(id: id) else { return }
16+
17+
// Closing the last tab of the last window leaves the window standing but empty. Reopening
18+
// into a new window tab there would strand that empty tab alongside the restored one, so
19+
// the empty window is filled in place, matching how New Tab reuses it.
20+
if let coordinator = emptyWindowCoordinator(for: entry.connectionId) {
21+
restore(entry, into: coordinator)
22+
return
23+
}
24+
25+
guard WindowManager.shared.hasOpenWindow(for: entry.connectionId) else {
26+
Task { await LaunchIntentRouter.shared.route(.reopenClosedTab(entry)) }
27+
return
28+
}
29+
30+
openWindowTab(for: entry)
31+
NSApp.activate(ignoringOtherApps: true)
32+
}
33+
34+
private static func emptyWindowCoordinator(for connectionId: UUID) -> MainContentCoordinator? {
35+
let empty = MainContentCoordinator.allActiveCoordinators().filter {
36+
$0.connectionId == connectionId && $0.tabManager.tabs.isEmpty
37+
}
38+
return empty.first { $0.contentWindow?.isKeyWindow == true } ?? empty.first
39+
}
40+
41+
private static func restore(_ entry: RecentlyClosedTabEntry, into coordinator: MainContentCoordinator) {
42+
let tab = makeTab(for: entry)
43+
coordinator.tabManager.adoptTab(tab, claimFocus: tab.tabType == .query)
44+
45+
if tab.tabType == .table, let tableName = tab.tableContext.tableName {
46+
coordinator.restoreLastHiddenColumnsForTable()
47+
coordinator.restoreFiltersForTable(tableName)
48+
coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore)
49+
}
50+
51+
coordinator.contentWindow?.makeKeyAndOrderFront(nil)
52+
NSApp.activate(ignoringOtherApps: true)
53+
}
54+
55+
private static func makeTab(for entry: RecentlyClosedTabEntry) -> QueryTab {
56+
QueryTab(
57+
from: entry.tab,
58+
defaultPageSize: AppSettingsManager.shared.dataGrid.defaultPageSize
59+
)
60+
}
61+
62+
internal static func openWindowTab(for entry: RecentlyClosedTabEntry) {
63+
let tab = makeTab(for: entry)
64+
let payload = EditorTabPayload(
65+
connectionId: entry.connectionId,
66+
tabType: tab.tabType,
67+
tableName: tab.tableContext.tableName,
68+
databaseName: tab.tableContext.databaseName,
69+
schemaName: tab.tableContext.schemaName,
70+
isView: tab.tableContext.isView,
71+
skipAutoExecute: true,
72+
sourceFileURL: tab.content.sourceFileURL,
73+
erDiagramSchemaKey: tab.display.erDiagramSchemaKey,
74+
tabTitle: tab.title,
75+
intent: .restoreOrDefault
76+
)
77+
RestorationGroupRegistry.register(
78+
.init(tabs: [tab], selectedTabId: tab.id, loadTiming: .immediate),
79+
for: payload.id
80+
)
81+
WindowManager.shared.openTab(payload: payload)
82+
}
83+
}

TablePro/Core/Services/Infrastructure/TabRouter.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,28 @@ internal final class TabRouter {
6262
case .openSQLFile(let url):
6363
try await openSQLFile(url)
6464

65+
case .reopenClosedTab(let entry):
66+
try await reopenClosedTab(entry)
67+
6568
default:
6669
throw TabRouterError.unsupportedIntent(String(describing: intent))
6770
}
6871
}
6972

73+
// MARK: - Recently Closed
74+
75+
private func reopenClosedTab(_ entry: RecentlyClosedTabEntry) async throws {
76+
guard let connection = ConnectionStorage.shared.loadConnections()
77+
.first(where: { $0.id == entry.connectionId }) else {
78+
throw TabRouterError.connectionNotFound(entry.connectionId)
79+
}
80+
try await runPreConnectScriptIfNeeded(connection)
81+
try await DatabaseManager.shared.ensureConnected(connection)
82+
RecentlyClosedTabReopener.openWindowTab(for: entry)
83+
NSApp.activate(ignoringOtherApps: true)
84+
closeWelcomeWindows()
85+
}
86+
7087
// MARK: - Connection
7188

7289
private func openConnection(id: UUID) async throws {

TablePro/Core/Storage/ClosedTabDraftStorage.swift

Lines changed: 0 additions & 54 deletions
This file was deleted.

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ final class ConnectionStorage {
270270
FavoriteTablesStorage.shared.removeFavorites(for: connection.id)
271271
FilterSettingsStorage.shared.removeFilters(for: connection.id)
272272
DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id)
273-
ClosedTabDraftStorage.shared.removeDraft(for: connection.id)
273+
RecentlyClosedTabStore.shared.removeEntries(for: connection.id)
274274
Task {
275275
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id)
276276
}
@@ -306,7 +306,7 @@ final class ConnectionStorage {
306306
}
307307
FilterSettingsStorage.shared.removeFilters(for: idsToDelete)
308308
DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete)
309-
ClosedTabDraftStorage.shared.removeDrafts(for: idsToDelete)
309+
RecentlyClosedTabStore.shared.removeEntries(for: idsToDelete)
310310
Task {
311311
for conn in connectionsToDelete {
312312
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id)

0 commit comments

Comments
 (0)