-
-
Notifications
You must be signed in to change notification settings - Fork 295
feat(editor): recover last closed query tab draft in the next blank tab #1687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UUID>) { | ||
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When switching databases,
switchDatabase(to:)callscloseSiblingNativeWindows(), which closes sibling native tabs viasibling.close()before clearing the current tab (MainContentCoordinator+Navigation.swiftlines 373-397). Those programmatic closes now run this samewindowWillClosepath and save a draft keyed only by connection id, so after the switch the next blank tab for that connection can be prefilled with SQL from the previous database instead of starting blank. This defeats the database-switch cleanup and can lead users to run stale SQL in the newly selected database; suppress draft capture for those programmatic closes or include the active database in the draft scope.Useful? React with 👍 / 👎.