diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fd534f5..db5c4de85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift index be5f3aec3..686470629 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -19,6 +19,7 @@ internal enum LaunchIntent: @unchecked Sendable { case startMCPServer case openDatabaseURL(URL) case installPlugin(URL) + case reopenClosedTab(RecentlyClosedTabEntry) internal var targetConnectionId: UUID? { switch self { @@ -26,6 +27,8 @@ internal enum LaunchIntent: @unchecked Sendable { .openTable(let id, _, _, _, _), .openQuery(let id, _): return id + case .reopenClosedTab(let entry): + return entry.connectionId case .openDatabaseURL, .openDatabaseFile, .openInspectorFile, diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 0a0d099f7..f0a70d984 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -23,7 +23,8 @@ internal final class LaunchIntentRouter { .openQuery, .openDatabaseURL, .openDatabaseFile, - .openSQLFile: + .openSQLFile, + .reopenClosedTab: try await TabRouter.shared.route(intent) case .openInspectorFile(let url): @@ -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") diff --git a/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift b/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift new file mode 100644 index 000000000..927b3111a --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/RecentlyClosedTabReopener.swift @@ -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 } + + // 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 + ) + } + + 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) + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 71e21fe94..b9fba41e6 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -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 { diff --git a/TablePro/Core/Storage/ClosedTabDraftStorage.swift b/TablePro/Core/Storage/ClosedTabDraftStorage.swift deleted file mode 100644 index bec2e43b7..000000000 --- a/TablePro/Core/Storage/ClosedTabDraftStorage.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -@MainActor -final class ClosedTabDraftStorage { - static let shared = ClosedTabDraftStorage() - - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - private func draftKey(connectionId: UUID) -> String { - "com.TablePro.closedTabDraft.\(connectionId.uuidString)" - } - - func saveQuery(_ query: String, connectionId: UUID) { - defaults.set(cappedQuery(query), forKey: draftKey(connectionId: connectionId)) - } - - func consumeQuery(connectionId: UUID) -> String? { - let key = draftKey(connectionId: connectionId) - guard let query = defaults.string(forKey: key), !query.isEmpty else { return nil } - defaults.removeObject(forKey: key) - return query - } - - func removeDraft(for connectionId: UUID) { - defaults.removeObject(forKey: draftKey(connectionId: connectionId)) - } - - func removeDrafts(for connectionIds: Set) { - for id in connectionIds { removeDraft(for: id) } - } - - static func draftCandidate(from tabs: [QueryTab], selectedTabId: UUID?) -> String? { - let candidates = tabs.filter { tab in - tab.tabType == .query - && tab.content.sourceFileURL == nil - && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - if let selectedTabId, - let selected = candidates.first(where: { $0.id == selectedTabId }) { - return selected.content.query - } - return candidates.first?.content.query - } - - private func cappedQuery(_ query: String) -> String { - let queryNS = query as NSString - guard queryNS.length > TabQueryContent.maxPersistableQuerySize else { return query } - return queryNS.substring(to: TabQueryContent.maxPersistableQuerySize) - } -} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index c356254ab..93d8455dd 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -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) } @@ -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) diff --git a/TablePro/Core/Storage/RecentlyClosedTabStore.swift b/TablePro/Core/Storage/RecentlyClosedTabStore.swift new file mode 100644 index 000000000..c1b2e5d40 --- /dev/null +++ b/TablePro/Core/Storage/RecentlyClosedTabStore.swift @@ -0,0 +1,238 @@ +import Foundation +import Observation +import os + +internal struct RecentlyClosedTabEntry: Codable, Identifiable { + internal let id: UUID + internal let closedAt: Date + internal let connectionId: UUID + internal let connectionName: String + internal var tab: PersistedTab + internal var overflowFileName: String? +} + +internal extension RecentlyClosedTabEntry { + var displayTitle: String { + String(format: String(localized: "%1$@ (%2$@)"), contentTitle, connectionName) + } + + private var contentTitle: String { + if tab.tabType == .table, let tableName = tab.tableName, !tableName.isEmpty { + return tableName + } + if let url = tab.sourceFileURL { + return QueryTab.fileDisplayTitle(for: url) + } + return queryPreview ?? tab.title + } + + private var queryPreview: String? { + let firstLine = tab.query + .split(separator: "\n", omittingEmptySubsequences: true) + .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + guard let firstLine else { return nil } + let trimmed = firstLine.trimmingCharacters(in: .whitespaces) + guard trimmed.count > Self.previewLength else { return trimmed } + return trimmed.prefix(Self.previewLength).trimmingCharacters(in: .whitespaces) + "\u{2026}" + } + + private static let previewLength = 48 +} + +/// Durable history of recently closed tabs, deliberately independent of `TabDiskActor`. +/// `TabDiskActor` answers "what is open right now" and is recomputed from the surviving +/// windows on every save, so a closed tab necessarily falls out of it. This store is the +/// append-and-prune log that lets a closed tab come back. +@MainActor +@Observable +internal final class RecentlyClosedTabStore { + internal static let shared = RecentlyClosedTabStore() + + internal static let maxEntries = 20 + internal static let maxAge: TimeInterval = 60 * 60 * 24 * 30 + + private static let logger = Logger(subsystem: "com.TablePro", category: "RecentlyClosedTabStore") + + internal private(set) var entries: [RecentlyClosedTabEntry] = [] + + @ObservationIgnored private let directory: URL + + internal init(directory: URL = RecentlyClosedTabStore.defaultDirectory()) { + self.directory = directory + createDirectories() + entries = Self.decodeEntries(at: Self.stateFileURL(in: directory)) + prune() + persist() + } + + // MARK: - Capture + + internal func push(tab: QueryTab, connection: DatabaseConnection) { + guard tab.isReopenCandidate, let entry = makeEntry(tab: tab, connection: connection) else { return } + discardEntries { $0.tab.id == tab.id } + entries.insert(entry, at: 0) + prune() + persist() + } + + // MARK: - Recovery + + internal var mostRecentEntry: RecentlyClosedTabEntry? { + entries.first + } + + /// Removes the entry and returns it with any overflow text folded back into the tab, so the + /// caller holds everything needed to rebuild the tab without touching disk again. + internal func consume(id: UUID) -> RecentlyClosedTabEntry? { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return nil } + var entry = entries.remove(at: index) + if let overflow = overflowText(for: entry) { + entry.tab.query = overflow + } + removeOverflowFile(for: entry) + entry.overflowFileName = nil + persist() + return entry + } + + // MARK: - Connection Removal + + internal func removeEntries(for connectionId: UUID) { + removeEntries(for: Set([connectionId])) + } + + internal func removeEntries(for connectionIds: Set) { + discardEntries { connectionIds.contains($0.connectionId) } + persist() + } + + // MARK: - Entry Construction + + private func makeEntry(tab: QueryTab, connection: DatabaseConnection) -> RecentlyClosedTabEntry? { + let entryId = UUID() + var persisted = tab.toPersistedTab() + var overflowFileName: String? + + if (tab.content.query as NSString).length > TabQueryContent.maxPersistableQuerySize { + let fileName = "\(entryId.uuidString).sql" + guard writeOverflow(tab.content.query, fileName: fileName) else { return nil } + overflowFileName = fileName + persisted.query = "" + } + + return RecentlyClosedTabEntry( + id: entryId, + closedAt: Date(), + connectionId: connection.id, + connectionName: connection.name, + tab: persisted, + overflowFileName: overflowFileName + ) + } + + // MARK: - Pruning + + private func prune() { + let cutoff = Date().addingTimeInterval(-Self.maxAge) + var kept: [RecentlyClosedTabEntry] = [] + var dropped: [RecentlyClosedTabEntry] = [] + + for entry in entries { + if entry.closedAt < cutoff || kept.count >= Self.maxEntries { + dropped.append(entry) + } else { + kept.append(entry) + } + } + + dropped.forEach(removeOverflowFile) + entries = kept + } + + private func discardEntries(where shouldDiscard: (RecentlyClosedTabEntry) -> Bool) { + let dropped = entries.filter(shouldDiscard) + guard !dropped.isEmpty else { return } + dropped.forEach(removeOverflowFile) + entries.removeAll(where: shouldDiscard) + } + + // MARK: - Overflow Files + + /// A query above `maxPersistableQuerySize` is blanked by `toPersistedTab()` to keep the + /// tab-state JSON small. A recovery entry cannot afford that, so the full text goes to a + /// sidecar file instead of being silently truncated. + private func writeOverflow(_ query: String, fileName: String) -> Bool { + do { + try query.write(to: overflowDirectory.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + return true + } catch { + Self.logger.fault("Failed to write overflow query \(fileName, privacy: .public): \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func overflowText(for entry: RecentlyClosedTabEntry) -> String? { + guard let fileName = entry.overflowFileName else { return nil } + return try? String(contentsOf: overflowDirectory.appendingPathComponent(fileName), encoding: .utf8) + } + + private func removeOverflowFile(for entry: RecentlyClosedTabEntry) { + guard let fileName = entry.overflowFileName else { return } + try? FileManager.default.removeItem(at: overflowDirectory.appendingPathComponent(fileName)) + } + + // MARK: - Disk + + private var overflowDirectory: URL { + directory.appendingPathComponent("Overflow", isDirectory: true) + } + + private func createDirectories() { + do { + try FileManager.default.createDirectory(at: overflowDirectory, withIntermediateDirectories: true) + } catch { + Self.logger.error("Failed to create directory \(self.overflowDirectory.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func persist() { + do { + let data = try JSONEncoder().encode(entries) + try data.write(to: Self.stateFileURL(in: directory), options: .atomic) + } catch { + Self.logger.fault("Failed to persist recently closed tabs: \(error.localizedDescription, privacy: .public)") + } + } + + /// One unreadable entry must not cost the user the rest of the history, so entries decode + /// leniently, matching `TabDiskState`. + private static func decodeEntries(at url: URL) -> [RecentlyClosedTabEntry] { + guard let data = try? Data(contentsOf: url) else { return [] } + do { + return try JSONDecoder().decode([LossyEntry].self, from: data).compactMap(\.value) + } catch { + logger.error("Failed to load recently closed tabs: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + nonisolated internal static func defaultDirectory() -> URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return appSupport + .appendingPathComponent("TablePro", isDirectory: true) + .appendingPathComponent("RecentlyClosedTabs", isDirectory: true) + } + + private static func stateFileURL(in directory: URL) -> URL { + directory.appendingPathComponent("entries.json") + } +} + +private struct LossyEntry: Decodable { + let value: RecentlyClosedTabEntry? + + init(from decoder: Decoder) throws { + value = try? RecentlyClosedTabEntry(from: decoder) + } +} diff --git a/TablePro/Models/Query/QueryTab+Protection.swift b/TablePro/Models/Query/QueryTab+Protection.swift new file mode 100644 index 000000000..27fa0d54f --- /dev/null +++ b/TablePro/Models/Query/QueryTab+Protection.swift @@ -0,0 +1,42 @@ +import Foundation + +extension QueryTab { + var hasQueryText: Bool { + !content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var hasExecutedQuery: Bool { + execution.lastExecutedAt != nil + } + + /// A query tab the user has invested work in, either by typing into it or by running it. + /// A tab holding query work must not be silently reused in place. + var holdsQueryWork: Bool { + guard tabType == .query else { return false } + return hasQueryText || hasExecutedQuery + } + + /// Whether closing this tab loses something worth bringing back. Only the text of a query + /// tab and the browse context of a table tab can be reconstructed; the utility tab types + /// carry no user-authored content. + var isReopenCandidate: Bool { + switch tabType { + case .query: + return hasQueryText + case .table: + return !(tableContext.tableName ?? "").isEmpty + default: + return false + } + } + + /// Drives the native unsaved-changes dot in the window's close button. A file-backed tab is + /// dirty when it diverges from disk; a scratch query tab is dirty whenever it holds text, + /// because that text lives nowhere else. + var showsUnsavedIndicator: Bool { + if content.sourceFileURL != nil { + return content.isFileDirty + } + return tabType == .query && hasQueryText + } +} diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index ef6a84db6..16b1539dc 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -143,6 +143,17 @@ final class QueryTabManager { } } + /// Take an already-built tab, such as one rebuilt from the recently closed history, rather than + /// minting a fresh one. Selecting it drives the window title, toolbar, and persistence through + /// the usual `selectedTabId` observation. + func adoptTab(_ tab: QueryTab, claimFocus: Bool = false) { + tabs.append(tab) + selectedTabId = tab.id + if claimFocus { + pendingFocusTabId = tab.id + } + } + var onTableOpened: ((_ tableName: String, _ schemaName: String?, _ databaseName: String, _ isView: Bool, _ isPreview: Bool) -> Void)? private func notifyTableOpened( diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index c5c8a83da..296a3ab03 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -25,7 +25,7 @@ enum TabType: Equatable, Codable, Hashable { struct PersistedTab: Codable { let id: UUID let title: String - let query: String + var query: String let tabType: TabType let tableName: String? var isView: Bool = false diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index d3cbff4de..d84d63984 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -109,6 +109,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { // Navigation case newTab case closeTab + case reopenClosedTab case quickSwitcher case toggleTableBrowser case toggleInspector @@ -140,8 +141,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .truncateTable, .previewFKReference, .saveAsFavorite, .previousPage, .nextPage, .firstPage, .lastPage, .refresh, .export, .importData: return .dataGrid - case .newTab, .closeTab, .quickSwitcher, .toggleTableBrowser, .toggleInspector, - .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, + case .newTab, .closeTab, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser, + .toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, .nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch, .showSidebarTables, .showSidebarFavorites, .showPreviousTab, .showNextTab: return .navigation @@ -188,6 +189,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .saveAs: return String(localized: "Save As") case .previewSQL: return String(localized: "Preview SQL") case .closeTab: return String(localized: "Close Tab") + case .reopenClosedTab: return String(localized: "Reopen Closed Tab") case .refresh: return String(localized: "Refresh") case .explainQuery: return String(localized: "Explain Query") case .formatQuery: return String(localized: "Format Query") @@ -442,6 +444,7 @@ struct KeyboardSettings: Codable, Equatable { // Navigation .newTab: .character("t", command: true), .closeTab: .character("w", command: true), + .reopenClosedTab: .character("t", command: true, shift: true), .quickSwitcher: .character("o", command: true, shift: true), .toggleTableBrowser: .character("0", command: true), .toggleInspector: .character("i", command: true, option: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 853d16df3..1d1959143 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -138,6 +138,10 @@ struct AppMenuCommands: Commands { /// scene focus instead of MainContentView's). @Bindable var commandRegistry: CommandActionsRegistry + /// Reopen Closed Tab must stay reachable with no editor window focused, so the menu reads the + /// store directly rather than routing through `actions`. + @Bindable var recentlyClosedStore: RecentlyClosedTabStore + /// Effective actions used by every menu item. Prefers @FocusedValue when /// it resolves (correct for in-content focus); falls back to the registry /// otherwise (covers toolbar-click + welcome→connect race scenarios). @@ -333,6 +337,21 @@ struct AppMenuCommands: Commands { } .optionalKeyboardShortcut(shortcut(for: .closeTab)) + Button(String(localized: "Reopen Closed Tab")) { + RecentlyClosedTabReopener.reopenMostRecent() + } + .optionalKeyboardShortcut(shortcut(for: .reopenClosedTab)) + .disabled(recentlyClosedStore.entries.isEmpty) + + Menu(String(localized: "Recently Closed")) { + ForEach(recentlyClosedStore.entries) { entry in + Button(entry.displayTitle) { + RecentlyClosedTabReopener.reopen(id: entry.id) + } + } + } + .disabled(recentlyClosedStore.entries.isEmpty) + Divider() Button(String(localized: "Export Connections...")) { @@ -848,7 +867,8 @@ struct TableProApp: App { AppMenuCommands( settingsManager: AppSettingsManager.shared, updaterBridge: updaterBridge, - commandRegistry: commandRegistry + commandRegistry: commandRegistry, + recentlyClosedStore: RecentlyClosedTabStore.shared ) } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b909f08c4..ef0c113e5 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -459,14 +459,15 @@ struct MainEditorContentView: View { // selectedTabIndex would overwrite the new tab's query. guard tabManager.mutate(tabId: tabId, { $0.content.query = newValue }) else { return } - if let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), - tabManager.tabs[index].content.sourceFileURL != nil { - let isDirty = tabManager.tabs[index].content.isFileDirty - Task { @MainActor in - if let window = NSApp.keyWindow { - window.isDocumentEdited = isDirty - } - } + // Typing into a scratch tab dirties it too: the text lives nowhere but this tab. + // The dot belongs to this tab's own window, not whichever window happens to be + // key, because a background window tab's editor stays mounted and can fire here. + guard tabId == tabManager.selectedTabId, + let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), + let window = coordinator.contentWindow else { return } + let showsIndicator = tabManager.tabs[index].showsUnsavedIndicator + Task { @MainActor in + window.isDocumentEdited = showsIndicator } } ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 245fab546..f2cb32924 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -311,11 +311,7 @@ extension MainContentCoordinator { } if tab.tabType == .createTable { return !toolbarState.hasCreateTablePending } if tab.isPreview { return true } - if tab.tabType == .query, - tab.execution.lastExecutedAt == nil, - tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } + if tab.tabType == .query, !tab.holdsQueryWork { return true } return false } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift new file mode 100644 index 000000000..c79725025 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift @@ -0,0 +1,46 @@ +import Foundation + +/// The single answer to "would closing or quitting destroy something the user cannot get back". +/// The close path (one tab) and the quit path (every tab of every connection) previously each +/// carried their own partial version of this and had drifted apart. +extension MainContentCoordinator { + var hasPendingDestructiveTableOps: Bool { + guard let session = DatabaseManager.shared.session(for: connectionId) else { return false } + return !session.pendingTruncates.isEmpty || !session.pendingDeletes.isEmpty + } + + var hasSidebarEdits: Bool { + rightPanelState?.editState.hasEdits ?? false + } + + /// Work that is only recoverable by saving it. A scratch query tab is deliberately absent: + /// its text is captured into `RecentlyClosedTabStore` on close, so losing it is undoable and + /// warrants no alert. + func hasUnsavedWork(in tab: QueryTab?) -> Bool { + guard let tab else { return false } + if tab.tabType == .usersRoles { + return usersRolesActions?.hasChanges() ?? false + } + return tab.content.isFileDirty || tab.pendingChanges.hasChanges + } + + func hasUnsavedWorkInSelectedTab() -> Bool { + changeManager.hasChanges + || hasPendingDestructiveTableOps + || hasSidebarEdits + || hasUnsavedWork(in: tabManager.selectedTab) + } + + func hasAnyUnsavedWork() -> Bool { + changeManager.hasChanges + || hasPendingDestructiveTableOps + || hasSidebarEdits + || tabManager.tabs.contains { hasUnsavedWork(in: $0) } + } + + /// Tabs resolved against live view state (cursor offset, sort column names) so a reopened tab + /// comes back where the user left it. + func tabsForRecoveryCapture() -> [QueryTab] { + tabManager.tabs.map { enrichedForPersistence($0) } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift index 9a766df2c..71f14ba7a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -20,10 +20,7 @@ extension MainContentCoordinator { } static func hasAnyUnsavedChanges() -> Bool { - activeCoordinators.values.contains { coordinator in - coordinator.changeManager.hasChanges - || coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } - } + activeCoordinators.values.contains { $0.hasAnyUnsavedWork() } } static func allTabs(for connectionId: UUID) -> [QueryTab] { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 97af91ede..1701683d5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -72,12 +72,6 @@ extension MainContentCoordinator { ) if !MainContentCoordinator.isAppTerminating { - if let draft = ClosedTabDraftStorage.draftCandidate( - from: tabManager.tabs, - selectedTabId: tabManager.selectedTabId - ) { - ClosedTabDraftStorage.shared.saveQuery(draft, connectionId: connectionId) - } persistence.saveOrClearAggregatedSync() } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 810ead6b1..b3019e3a7 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -308,7 +308,7 @@ extension MainContentView { connection: connection ) viewWindow?.representedURL = selectedTab?.content.sourceFileURL - viewWindow?.isDocumentEdited = selectedTab?.content.isFileDirty ?? false + viewWindow?.isDocumentEdited = selectedTab?.showsUnsavedIndicator ?? false } /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. @@ -335,7 +335,7 @@ extension MainContentView { // Native proxy icon (Cmd+click shows path in Finder) and dirty dot window.representedURL = tabManager.selectedTab?.content.sourceFileURL - window.isDocumentEdited = tabManager.selectedTab?.content.isFileDirty ?? false + window.isDocumentEdited = tabManager.selectedTab?.showsUnsavedIndicator ?? false commandActions?.window = window diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index adfa59887..44b515238 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -311,15 +311,7 @@ final class MainContentCommandActions { // MARK: - Unsaved Changes Check private var hasUnsavedChanges: Bool { - if isUsersRolesTab { - return coordinator?.usersRolesActions?.hasChanges() ?? false - } - let hasEditedCells = coordinator?.changeManager.hasChanges ?? false - let hasPendingTableOps = !pendingTruncates.wrappedValue.isEmpty - || !pendingDeletes.wrappedValue.isEmpty - let hasSidebarEdits = rightPanelState.editState.hasEdits - let hasFileDirty = coordinator?.tabManager.selectedTab?.content.isFileDirty ?? false - return hasEditedCells || hasPendingTableOps || hasSidebarEdits || hasFileDirty + coordinator?.hasUnsavedWorkInSelectedTab() ?? false } private var isUsersRolesTab: Bool { @@ -353,11 +345,9 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { - let resolvedQuery = initialQuery - ?? ClosedTabDraftStorage.shared.consumeQuery(connectionId: connection.id) if let coordinator, coordinator.tabManager.tabs.isEmpty { coordinator.tabManager.addTab( - initialQuery: resolvedQuery, + initialQuery: initialQuery, databaseName: coordinator.activeDatabaseName, claimFocus: true ) @@ -365,7 +355,7 @@ final class MainContentCommandActions { } let payload = EditorTabPayload( connectionId: connection.id, - initialQuery: resolvedQuery, + initialQuery: initialQuery, intent: .newEmptyTab ) WindowManager.shared.openTab(payload: payload) @@ -396,9 +386,20 @@ final class MainContentCommandActions { } } + /// Every close gesture funnels here, so this is the one place that can guarantee a closing + /// tab's content outlives the window. It runs before the branch dispatch below because two of + /// the three branches tear the window down without another chance to capture anything. + private func captureClosingTabsForRecovery() { + guard let coordinator else { return } + for tab in coordinator.tabsForRecoveryCapture() { + RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + } + } + private func performClose() { let t0 = Date() guard let window = coordinator?.contentWindow ?? NSApp.keyWindow else { return } + captureClosingTabsForRecovery() let visibleTabbedWindows = (window.tabbedWindows ?? [window]).filter(\.isVisible) Self.logger.info("[close] performClose visibleTabs=\(visibleTabbedWindows.count) tabManagerTabs=\(self.coordinator?.tabManager.tabs.count ?? 0)") @@ -408,12 +409,6 @@ final class MainContentCommandActions { window.close() } else { if let coordinator { - if let draft = ClosedTabDraftStorage.draftCandidate( - from: coordinator.tabManager.tabs, - selectedTabId: coordinator.tabManager.selectedTabId - ) { - ClosedTabDraftStorage.shared.saveQuery(draft, connectionId: connection.id) - } for tab in coordinator.tabManager.tabs { coordinator.tabSessionRegistry.removeTableRows(for: tab.id) if let url = tab.content.sourceFileURL { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 2c9689969..4431bb91b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -312,7 +312,7 @@ final class MainContentCoordinator { /// Resolve transient view state that only the live coordinator knows about /// (sort column names, editor cursor offset) onto the tab before it is serialized. - private func enrichedForPersistence(_ tab: QueryTab) -> QueryTab { + func enrichedForPersistence(_ tab: QueryTab) -> QueryTab { var enriched = tab if enriched.sortState.isSorting { let columns = columnsForPersistence(of: tab) diff --git a/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift deleted file mode 100644 index dc78fdaa1..000000000 --- a/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -@MainActor -@Suite("ClosedTabDraftStorage") -struct ClosedTabDraftStorageTests { - private func makeStorage() throws -> ClosedTabDraftStorage { - let suite = "ClosedTabDraftStorageTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - return ClosedTabDraftStorage(defaults: defaults) - } - - @Test("Consuming with no stored draft returns nil") - func defaultsNil() throws { - let storage = try makeStorage() - #expect(storage.consumeQuery(connectionId: UUID()) == nil) - } - - @Test("Saved draft round-trips on consume") - func saveAndConsume() throws { - let storage = try makeStorage() - let connId = UUID() - storage.saveQuery("SELECT 1", connectionId: connId) - #expect(storage.consumeQuery(connectionId: connId) == "SELECT 1") - } - - @Test("A draft is consumed only once") - func consumeOnce() throws { - let storage = try makeStorage() - let connId = UUID() - storage.saveQuery("SELECT 1", connectionId: connId) - #expect(storage.consumeQuery(connectionId: connId) == "SELECT 1") - #expect(storage.consumeQuery(connectionId: connId) == nil) - } - - @Test("Drafts are isolated per connection") - func perConnectionIsolation() throws { - let storage = try makeStorage() - let a = UUID() - let b = UUID() - storage.saveQuery("SELECT a", connectionId: a) - #expect(storage.consumeQuery(connectionId: b) == nil) - #expect(storage.consumeQuery(connectionId: a) == "SELECT a") - } - - @Test("Removing a draft clears the stored value") - func removeDraftClears() throws { - let storage = try makeStorage() - let connId = UUID() - storage.saveQuery("SELECT 1", connectionId: connId) - storage.removeDraft(for: connId) - #expect(storage.consumeQuery(connectionId: connId) == nil) - } - - @Test("Removing drafts in batch clears across connections") - func removeDraftsBatchClears() throws { - let storage = try makeStorage() - let a = UUID() - let b = UUID() - storage.saveQuery("SELECT a", connectionId: a) - storage.saveQuery("SELECT b", connectionId: b) - storage.removeDrafts(for: Set([a, b])) - #expect(storage.consumeQuery(connectionId: a) == nil) - #expect(storage.consumeQuery(connectionId: b) == nil) - } - - @Test("Queries above the cap are truncated") - func capApplied() throws { - let storage = try makeStorage() - let connId = UUID() - let oversized = String(repeating: "a", count: TabQueryContent.maxPersistableQuerySize + 1) - storage.saveQuery(oversized, connectionId: connId) - let restored = try #require(storage.consumeQuery(connectionId: connId)) - #expect((restored as NSString).length == TabQueryContent.maxPersistableQuerySize) - } - - @Test("Blank query tabs produce no draft candidate") - func blankQueryNotSaved() { - let tab = QueryTab(query: " \n\t ") - #expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil) - } - - @Test("File-backed tabs are excluded from draft candidates") - func fileBackedTabExcluded() { - var tab = QueryTab(query: "SELECT 1") - tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/query.sql") - #expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil) - } - - @Test("Table tabs are excluded from draft candidates") - func tableTabExcluded() { - let tab = QueryTab(query: "SELECT 1", tabType: .table, tableName: "users") - #expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil) - } - - @Test("The selected tab is preferred as the draft candidate") - func prefersSelectedTab() { - let first = QueryTab(query: "SELECT first") - let second = QueryTab(query: "SELECT second") - let candidate = ClosedTabDraftStorage.draftCandidate( - from: [first, second], - selectedTabId: second.id - ) - #expect(candidate == "SELECT second") - } - - @Test("Falls back to the first candidate when no tab is selected") - func fallsBackToFirstTab() { - let first = QueryTab(query: "SELECT first") - let second = QueryTab(query: "SELECT second") - let candidate = ClosedTabDraftStorage.draftCandidate( - from: [first, second], - selectedTabId: nil - ) - #expect(candidate == "SELECT first") - } -} diff --git a/TableProTests/Core/Storage/RecentlyClosedTabStoreTests.swift b/TableProTests/Core/Storage/RecentlyClosedTabStoreTests.swift new file mode 100644 index 000000000..8a00c6025 --- /dev/null +++ b/TableProTests/Core/Storage/RecentlyClosedTabStoreTests.swift @@ -0,0 +1,144 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("RecentlyClosedTabStore") +struct RecentlyClosedTabStoreTests { + private func makeStore() throws -> (store: RecentlyClosedTabStore, directory: URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RecentlyClosedTabStoreTests.\(UUID().uuidString)", isDirectory: true) + return (RecentlyClosedTabStore(directory: directory), directory) + } + + @Test("A closed scratch tab can be reopened with its query intact") + func closedScratchTabRoundTrips() throws { + let (store, _) = try makeStore() + let connection = TestFixtures.makeConnection() + store.push(tab: QueryTab(query: "SELECT 1"), connection: connection) + + let entry = try #require(store.mostRecentEntry) + let consumed = try #require(store.consume(id: entry.id)) + #expect(consumed.tab.query == "SELECT 1") + #expect(consumed.connectionId == connection.id) + #expect(store.entries.isEmpty) + } + + @Test("Closing several tabs keeps every one of them, most recent first") + func multipleClosedTabsAllSurvive() throws { + let (store, _) = try makeStore() + let connection = TestFixtures.makeConnection() + store.push(tab: QueryTab(query: "SELECT 1"), connection: connection) + store.push(tab: QueryTab(query: "SELECT 2"), connection: connection) + store.push(tab: QueryTab(query: "SELECT 3"), connection: connection) + + #expect(store.entries.count == 3) + #expect(store.entries.map(\.tab.query) == ["SELECT 3", "SELECT 2", "SELECT 1"]) + } + + @Test("A consumed entry is not handed out twice") + func consumeIsOneShot() throws { + let (store, _) = try makeStore() + store.push(tab: QueryTab(query: "SELECT 1"), connection: TestFixtures.makeConnection()) + + let entry = try #require(store.mostRecentEntry) + #expect(store.consume(id: entry.id) != nil) + #expect(store.consume(id: entry.id) == nil) + } + + @Test("A blank scratch tab is never stored") + func blankTabIsNotStored() throws { + let (store, _) = try makeStore() + store.push(tab: QueryTab(query: " \n "), connection: TestFixtures.makeConnection()) + #expect(store.entries.isEmpty) + } + + @Test("A table tab is stored with its browse context") + func tableTabIsStored() throws { + let (store, _) = try makeStore() + store.push( + tab: QueryTab(query: "SELECT * FROM users", tabType: .table, tableName: "users"), + connection: TestFixtures.makeConnection() + ) + let entry = try #require(store.mostRecentEntry) + #expect(entry.tab.tableName == "users") + #expect(entry.tab.tabType == .table) + } + + @Test("History is capped at the entry limit, dropping the oldest") + func historyIsCapped() throws { + let (store, _) = try makeStore() + let connection = TestFixtures.makeConnection() + for index in 0..<(RecentlyClosedTabStore.maxEntries + 5) { + store.push(tab: QueryTab(query: "SELECT \(index)"), connection: connection) + } + + #expect(store.entries.count == RecentlyClosedTabStore.maxEntries) + #expect(store.entries.first?.tab.query == "SELECT \(RecentlyClosedTabStore.maxEntries + 4)") + #expect(store.entries.last?.tab.query == "SELECT 5") + } + + @Test("An oversized query survives in full instead of being truncated") + func oversizedQuerySurvivesInFull() throws { + let (store, _) = try makeStore() + let oversized = String(repeating: "a", count: TabQueryContent.maxPersistableQuerySize + 100) + store.push(tab: QueryTab(query: oversized), connection: TestFixtures.makeConnection()) + + let entry = try #require(store.mostRecentEntry) + #expect(entry.overflowFileName != nil) + #expect(entry.tab.query.isEmpty) + + let consumed = try #require(store.consume(id: entry.id)) + #expect((consumed.tab.query as NSString).length == (oversized as NSString).length) + #expect(consumed.tab.query == oversized) + } + + @Test("Entries survive a restart of the store") + func entriesPersistAcrossStoreInstances() throws { + let (store, directory) = try makeStore() + store.push(tab: QueryTab(query: "SELECT persisted"), connection: TestFixtures.makeConnection()) + + let reloaded = RecentlyClosedTabStore(directory: directory) + #expect(reloaded.entries.count == 1) + #expect(reloaded.entries.first?.tab.query == "SELECT persisted") + } + + @Test("An oversized query survives a restart of the store") + func oversizedQueryPersistsAcrossStoreInstances() throws { + let (store, directory) = try makeStore() + let oversized = String(repeating: "b", count: TabQueryContent.maxPersistableQuerySize + 100) + store.push(tab: QueryTab(query: oversized), connection: TestFixtures.makeConnection()) + + let reloaded = RecentlyClosedTabStore(directory: directory) + let entry = try #require(reloaded.mostRecentEntry) + let consumed = try #require(reloaded.consume(id: entry.id)) + #expect(consumed.tab.query == oversized) + } + + @Test("Deleting a connection drops its entries") + func removingAConnectionDropsItsEntries() throws { + let (store, _) = try makeStore() + let kept = TestFixtures.makeConnection(name: "Kept") + let deleted = TestFixtures.makeConnection(name: "Deleted") + store.push(tab: QueryTab(query: "SELECT kept"), connection: kept) + store.push(tab: QueryTab(query: "SELECT deleted"), connection: deleted) + + store.removeEntries(for: deleted.id) + + #expect(store.entries.count == 1) + #expect(store.entries.first?.connectionId == kept.id) + } + + @Test("Reclosing the same tab bumps it instead of duplicating it") + func reclosingSameTabBumpsIt() throws { + let (store, _) = try makeStore() + let connection = TestFixtures.makeConnection() + let tabId = UUID() + store.push(tab: QueryTab(id: tabId, query: "SELECT first"), connection: connection) + store.push(tab: QueryTab(query: "SELECT other"), connection: connection) + store.push(tab: QueryTab(id: tabId, query: "SELECT second"), connection: connection) + + #expect(store.entries.count == 2) + #expect(store.entries.first?.tab.query == "SELECT second") + } +} diff --git a/TableProTests/Models/Query/QueryTabManagerAdoptTabTests.swift b/TableProTests/Models/Query/QueryTabManagerAdoptTabTests.swift new file mode 100644 index 000000000..f3dbdbcab --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerAdoptTabTests.swift @@ -0,0 +1,53 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("QueryTabManager.adoptTab") +@MainActor +struct QueryTabManagerAdoptTabTests { + @Test("An adopted tab keeps its identity and content instead of being rebuilt") + func adoptedTabKeepsItsIdentity() { + let manager = QueryTabManager() + let restored = QueryTab(title: "Restored", query: "SELECT 1") + + manager.adoptTab(restored) + + #expect(manager.tabs.count == 1) + #expect(manager.tabs.first?.id == restored.id) + #expect(manager.tabs.first?.content.query == "SELECT 1") + #expect(manager.tabs.first?.title == "Restored") + } + + @Test("An adopted tab becomes the selected tab") + func adoptedTabIsSelected() { + let manager = QueryTabManager() + let restored = QueryTab(query: "SELECT 1") + + manager.adoptTab(restored) + + #expect(manager.selectedTabId == restored.id) + } + + /// Reopening into the empty window left behind by closing the last tab must not strand a blank + /// tab beside the restored one. + @Test("Adopting into an empty manager leaves exactly one tab") + func adoptingIntoEmptyManagerLeavesOneTab() { + let manager = QueryTabManager() + #expect(manager.tabs.isEmpty) + + manager.adoptTab(QueryTab(query: "SELECT restored")) + + #expect(manager.tabs.count == 1) + #expect(manager.tabs.first?.content.query == "SELECT restored") + } + + @Test("Claiming focus marks the adopted tab as the one to focus") + func claimingFocusTargetsTheAdoptedTab() { + let manager = QueryTabManager() + let restored = QueryTab(query: "SELECT 1") + + manager.adoptTab(restored, claimFocus: true) + + #expect(manager.pendingFocusTabId == restored.id) + } +} diff --git a/TableProTests/Models/Query/QueryTabProtectionTests.swift b/TableProTests/Models/Query/QueryTabProtectionTests.swift new file mode 100644 index 000000000..32a543b99 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabProtectionTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("QueryTab protection predicates") +struct QueryTabProtectionTests { + @Test("A blank scratch query tab holds no work") + func blankScratchTabHoldsNoWork() { + let tab = QueryTab(query: " \n\t ") + #expect(!tab.holdsQueryWork) + #expect(!tab.isReopenCandidate) + #expect(!tab.showsUnsavedIndicator) + } + + @Test("Typed SQL in a scratch tab is reopenable work and shows the unsaved dot") + func typedScratchTabIsProtected() { + let tab = QueryTab(query: "SELECT 1") + #expect(tab.holdsQueryWork) + #expect(tab.isReopenCandidate) + #expect(tab.showsUnsavedIndicator) + } + + @Test("An executed but empty query tab is not reusable, yet has nothing to reopen") + func executedEmptyTabIsNotReusableButNotReopenable() { + var tab = QueryTab(query: "") + tab.execution.lastExecutedAt = Date() + #expect(tab.holdsQueryWork) + #expect(!tab.isReopenCandidate) + #expect(!tab.showsUnsavedIndicator) + } + + @Test("A file-backed tab matching disk is clean") + func fileBackedTabMatchingDiskIsClean() { + var tab = QueryTab(query: "SELECT 1") + tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/query.sql") + tab.content.savedFileContent = "SELECT 1" + #expect(!tab.showsUnsavedIndicator) + #expect(tab.isReopenCandidate) + } + + @Test("A file-backed tab diverging from disk shows the unsaved dot") + func fileBackedTabDivergingFromDiskIsDirty() { + var tab = QueryTab(query: "SELECT 2") + tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/query.sql") + tab.content.savedFileContent = "SELECT 1" + #expect(tab.showsUnsavedIndicator) + } + + @Test("A table tab is reopenable but never shows the unsaved dot") + func tableTabIsReopenable() { + let tab = QueryTab(query: "SELECT * FROM users", tabType: .table, tableName: "users") + #expect(tab.isReopenCandidate) + #expect(!tab.showsUnsavedIndicator) + #expect(!tab.holdsQueryWork) + } + + @Test("Utility tabs carry no reopenable content") + func utilityTabsAreNotReopenable() { + let tab = QueryTab(query: "", tabType: .usersRoles) + #expect(!tab.isReopenCandidate) + #expect(!tab.showsUnsavedIndicator) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 5b4c2d082..49b704494 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -186,6 +186,7 @@ Inherits the standard `NSDocument` shortcuts. |--------|----------| | Close window / tab | `Cmd+W` | | New query tab | `Cmd+T` | +| Reopen closed tab | `Cmd+Shift+T` | | Switch to tab 1-9 | `Cmd+1` through `Cmd+9` | | Next tab | `Cmd+Shift+]` | | Previous tab | `Cmd+Shift+[` | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 3d9bbeac3..669bd13c8 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -303,7 +303,9 @@ Files open in a new tab. If not connected to a database, they're queued and open - **`Cmd+Shift+S`** opens a **Save As** dialog to save as a new `.sql` file - For untitled query tabs (no file), `Cmd+S` triggers Save As automatically -The title bar shows the filename for file-backed tabs. A dot appears on the close button when there are unsaved changes (standard macOS behavior). `Cmd+click` the filename in the title bar to reveal the file in Finder. +The title bar shows the filename for file-backed tabs. A dot appears on the close button when there are unsaved changes (standard macOS behavior), including on a query tab you have typed into but never saved to a file. `Cmd+click` the filename in the title bar to reveal the file in Finder. + +Closing a query tab keeps its SQL: reopen it with `Cmd+Shift+T` or from **File** > **Recently Closed**. See [Tabs](/features/tabs) for details. When a tab has both unsaved file changes and pending data grid edits, `Cmd+S` saves the data grid changes first. Save the file after the grid save completes. diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 18d4ba6b2..4e38f389f 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -82,9 +82,16 @@ Disable preview tabs in **Settings** > **Tabs** if you prefer every click to ope | Close specific tab | Hover and click **x** | | Close other tabs | Right-click > **Close Other Tabs** | - -Closing a tab with unsaved changes discards those changes. TablePro warns you before closing if there are pending data modifications. - +Closing a query tab does not throw away the SQL in it, so closing one by accident costs you nothing. TablePro still asks before closing a tab whose changes it cannot bring back: pending data edits, table structure changes, and unsaved edits to a `.sql` file on disk. + +### Reopening Closed Tabs + +| Action | How | +|--------|-----| +| Reopen the last closed tab | `Cmd+Shift+T` or **File** > **Reopen Closed Tab** | +| Reopen an older tab | **File** > **Recently Closed** | + +The last 20 closed query and table tabs are kept for 30 days. A reopened tab comes back with its SQL, cursor position, and database context. Deleting a connection also deletes its closed tabs. ### Switching Tabs