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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab.
- 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)
- 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)

### Changed

- 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)
- 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)

### Fixed

- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855)
- 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)

## [0.56.2] - 2026-07-10

Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Services/Infrastructure/LaunchIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ internal enum LaunchIntent: @unchecked Sendable {
case startMCPServer
case openDatabaseURL(URL)
case installPlugin(URL)
case reopenClosedTab(RecentlyClosedTabEntry)

internal var targetConnectionId: UUID? {
switch self {
case .openConnection(let id),
.openTable(let id, _, _, _, _),
.openQuery(let id, _):
return id
case .reopenClosedTab(let entry):
return entry.connectionId
case .openDatabaseURL,
.openDatabaseFile,
.openInspectorFile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ internal final class LaunchIntentRouter {
.openQuery,
.openDatabaseURL,
.openDatabaseFile,
.openSQLFile:
.openSQLFile,
.reopenClosedTab:
try await TabRouter.shared.route(intent)

case .openInspectorFile(let url):
Expand Down Expand Up @@ -87,7 +88,8 @@ internal final class LaunchIntentRouter {
title = String(localized: "Pairing Failed")
case .installPlugin:
title = String(localized: "Plugin Installation Failed")
case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile:
case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile,
.reopenClosedTab:
title = String(localized: "Connection Failed")
case .openSQLFile, .openInspectorFile:
title = String(localized: "Could Not Open File")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import AppKit
import Foundation

/// Brings a closed tab back into a native window tab. Reopening reuses the restoration path that
/// cold launch already uses, so a reopened table tab recovers its filters, sort, and column
/// layout instead of reimplementing that here.
@MainActor
internal enum RecentlyClosedTabReopener {
internal static func reopenMostRecent() {
guard let entry = RecentlyClosedTabStore.shared.mostRecentEntry else { return }
reopen(id: entry.id)
}

internal static func reopen(id: UUID) {
guard let entry = RecentlyClosedTabStore.shared.consume(id: id) else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer consuming entries until reopen succeeds

When there is no open window for the entry's connection, this removes the recently closed entry (and consume deletes any overflow sidecar) before the async reconnect/open path has succeeded. If the reconnect fails, the password prompt is cancelled, or the saved connection is missing, LaunchIntentRouter only shows an error and the tab is already gone, so the user cannot retry Reopen Closed Tab and large unsaved SQL may be deleted. Keep the entry until the reopen path succeeds, or reinsert it on failure.

Useful? React with 👍 / 👎.


// Closing the last tab of the last window leaves the window standing but empty. Reopening
// into a new window tab there would strand that empty tab alongside the restored one, so
// the empty window is filled in place, matching how New Tab reuses it.
if let coordinator = emptyWindowCoordinator(for: entry.connectionId) {
restore(entry, into: coordinator)
return
}

guard WindowManager.shared.hasOpenWindow(for: entry.connectionId) else {
Task { await LaunchIntentRouter.shared.route(.reopenClosedTab(entry)) }
return
}

openWindowTab(for: entry)
NSApp.activate(ignoringOtherApps: true)
}

private static func emptyWindowCoordinator(for connectionId: UUID) -> MainContentCoordinator? {
let empty = MainContentCoordinator.allActiveCoordinators().filter {
$0.connectionId == connectionId && $0.tabManager.tabs.isEmpty
}
return empty.first { $0.contentWindow?.isKeyWindow == true } ?? empty.first
}

private static func restore(_ entry: RecentlyClosedTabEntry, into coordinator: MainContentCoordinator) {
let tab = makeTab(for: entry)
coordinator.tabManager.adoptTab(tab, claimFocus: tab.tabType == .query)

if tab.tabType == .table, let tableName = tab.tableContext.tableName {
coordinator.restoreLastHiddenColumnsForTable()
coordinator.restoreFiltersForTable(tableName)
coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore)
}

coordinator.contentWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}

private static func makeTab(for entry: RecentlyClosedTabEntry) -> QueryTab {
QueryTab(
from: entry.tab,
defaultPageSize: AppSettingsManager.shared.dataGrid.defaultPageSize
)
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed file state when rebuilding reopened SQL tabs

When the closed tab is file-backed, rebuilding it with QueryTab(from:) preserves sourceFileURL and the query text but leaves savedFileContent/loadMtime nil, unlike the normal disk-restore and open-file paths. After reopening foo.sql, TabQueryContent.isFileDirty always returns false because savedFileContent is nil, so subsequent edits do not show the dirty dot and the Save Changes path will not write back to the original file. Load the current file snapshot or otherwise initialize the saved-file state for reopened file-backed tabs.

Useful? React with 👍 / 👎.

}

internal static func openWindowTab(for entry: RecentlyClosedTabEntry) {
let tab = makeTab(for: entry)
let payload = EditorTabPayload(
connectionId: entry.connectionId,
tabType: tab.tabType,
tableName: tab.tableContext.tableName,
databaseName: tab.tableContext.databaseName,
schemaName: tab.tableContext.schemaName,
isView: tab.tableContext.isView,
skipAutoExecute: true,
sourceFileURL: tab.content.sourceFileURL,
erDiagramSchemaKey: tab.display.erDiagramSchemaKey,
tabTitle: tab.title,
intent: .restoreOrDefault
)
RestorationGroupRegistry.register(
.init(tabs: [tab], selectedTabId: tab.id, loadTiming: .immediate),
for: payload.id
)
WindowManager.shared.openTab(payload: payload)
}
}
17 changes: 17 additions & 0 deletions TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,28 @@ internal final class TabRouter {
case .openSQLFile(let url):
try await openSQLFile(url)

case .reopenClosedTab(let entry):
try await reopenClosedTab(entry)

default:
throw TabRouterError.unsupportedIntent(String(describing: intent))
}
}

// MARK: - Recently Closed

private func reopenClosedTab(_ entry: RecentlyClosedTabEntry) async throws {
guard let connection = ConnectionStorage.shared.loadConnections()
.first(where: { $0.id == entry.connectionId }) else {
throw TabRouterError.connectionNotFound(entry.connectionId)
}
try await runPreConnectScriptIfNeeded(connection)
try await DatabaseManager.shared.ensureConnected(connection)
RecentlyClosedTabReopener.openWindowTab(for: entry)
NSApp.activate(ignoringOtherApps: true)
closeWelcomeWindows()
}

// MARK: - Connection

private func openConnection(id: UUID) async throws {
Expand Down
54 changes: 0 additions & 54 deletions TablePro/Core/Storage/ClosedTabDraftStorage.swift

This file was deleted.

4 changes: 2 additions & 2 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ final class ConnectionStorage {
FavoriteTablesStorage.shared.removeFavorites(for: connection.id)
FilterSettingsStorage.shared.removeFilters(for: connection.id)
DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id)
ClosedTabDraftStorage.shared.removeDraft(for: connection.id)
RecentlyClosedTabStore.shared.removeEntries(for: connection.id)
Task {
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id)
}
Expand Down Expand Up @@ -306,7 +306,7 @@ final class ConnectionStorage {
}
FilterSettingsStorage.shared.removeFilters(for: idsToDelete)
DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete)
ClosedTabDraftStorage.shared.removeDrafts(for: idsToDelete)
RecentlyClosedTabStore.shared.removeEntries(for: idsToDelete)
Task {
for conn in connectionsToDelete {
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id)
Expand Down
Loading
Loading