diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d68b9e4..f0add8cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The tree sidebar can show only the databases you pick. Use the filter button to check the ones you want, with a search box for long lists. The choice is saved per connection. (#1667) +- Closing a query tab no longer loses unsaved SQL. The next blank query tab you open for the same connection brings the last closed draft back. (#1686) ### Fixed diff --git a/TablePro/Core/Storage/ClosedTabDraftStorage.swift b/TablePro/Core/Storage/ClosedTabDraftStorage.swift new file mode 100644 index 000000000..bec2e43b7 --- /dev/null +++ b/TablePro/Core/Storage/ClosedTabDraftStorage.swift @@ -0,0 +1,54 @@ +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 5e154cbfd..1cb81f520 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -257,6 +257,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) Task { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id) } @@ -291,6 +292,7 @@ final class ConnectionStorage { } FilterSettingsStorage.shared.removeFilters(for: idsToDelete) DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete) + ClosedTabDraftStorage.shared.removeDrafts(for: idsToDelete) Task { for conn in connectionsToDelete { await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 2f8ff62c1..bbec05ef2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -68,6 +68,12 @@ 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/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 262ef3ded..a72a750b0 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -332,16 +332,18 @@ 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: initialQuery, + initialQuery: resolvedQuery, databaseName: coordinator.activeDatabaseName ) return } let payload = EditorTabPayload( connectionId: connection.id, - initialQuery: initialQuery, + initialQuery: resolvedQuery, intent: .newEmptyTab ) WindowManager.shared.openTab(payload: payload) @@ -384,6 +386,12 @@ 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/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift new file mode 100644 index 000000000..dc78fdaa1 --- /dev/null +++ b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift @@ -0,0 +1,119 @@ +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") + } +}