From 3f07666d2131b195902f6a3af9b0aec936a075e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 22:19:15 +0700 Subject: [PATCH 1/2] feat(editor): recover last closed query tab draft in the next blank tab --- CHANGELOG.md | 1 + .../Core/Storage/ClosedTabDraftStorage.swift | 50 ++++++++ ...inContentCoordinator+WindowLifecycle.swift | 6 + .../Main/MainContentCommandActions.swift | 12 +- .../Storage/ClosedTabDraftStorageTests.swift | 107 ++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/Storage/ClosedTabDraftStorage.swift create mode 100644 TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift 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..f78741afa --- /dev/null +++ b/TablePro/Core/Storage/ClosedTabDraftStorage.swift @@ -0,0 +1,50 @@ +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 clear(connectionId: UUID) { + defaults.removeObject(forKey: draftKey(connectionId: connectionId)) + } + + 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/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..9b97b4a67 --- /dev/null +++ b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift @@ -0,0 +1,107 @@ +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("Clear removes a stored draft") + func clearRemovesDraft() throws { + let storage = try makeStorage() + let connId = UUID() + storage.saveQuery("SELECT 1", connectionId: connId) + storage.clear(connectionId: connId) + #expect(storage.consumeQuery(connectionId: connId) == 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") + } +} From 46a10e6f05b2aec58889f2f903ddbaed873e9da2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 22:23:02 +0700 Subject: [PATCH 2/2] refactor(connections): clean up closed query tab drafts when a connection is deleted --- .../Core/Storage/ClosedTabDraftStorage.swift | 6 +++++- TablePro/Core/Storage/ConnectionStorage.swift | 2 ++ .../Storage/ClosedTabDraftStorageTests.swift | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Storage/ClosedTabDraftStorage.swift b/TablePro/Core/Storage/ClosedTabDraftStorage.swift index f78741afa..bec2e43b7 100644 --- a/TablePro/Core/Storage/ClosedTabDraftStorage.swift +++ b/TablePro/Core/Storage/ClosedTabDraftStorage.swift @@ -25,10 +25,14 @@ final class ClosedTabDraftStorage { return query } - func clear(connectionId: UUID) { + 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 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/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift index 9b97b4a67..dc78fdaa1 100644 --- a/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift +++ b/TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift @@ -45,15 +45,27 @@ struct ClosedTabDraftStorageTests { #expect(storage.consumeQuery(connectionId: a) == "SELECT a") } - @Test("Clear removes a stored draft") - func clearRemovesDraft() throws { + @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.clear(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()