Skip to content

Commit 13dbc64

Browse files
authored
feat(editor): recover last closed query tab draft in the next blank tab (#1687)
* feat(editor): recover last closed query tab draft in the next blank tab * refactor(connections): clean up closed query tab drafts when a connection is deleted
1 parent 9e65a17 commit 13dbc64

6 files changed

Lines changed: 192 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- 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)
13+
- 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)
1314

1415
### Fixed
1516

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Foundation
2+
3+
@MainActor
4+
final class ClosedTabDraftStorage {
5+
static let shared = ClosedTabDraftStorage()
6+
7+
private let defaults: UserDefaults
8+
9+
init(defaults: UserDefaults = .standard) {
10+
self.defaults = defaults
11+
}
12+
13+
private func draftKey(connectionId: UUID) -> String {
14+
"com.TablePro.closedTabDraft.\(connectionId.uuidString)"
15+
}
16+
17+
func saveQuery(_ query: String, connectionId: UUID) {
18+
defaults.set(cappedQuery(query), forKey: draftKey(connectionId: connectionId))
19+
}
20+
21+
func consumeQuery(connectionId: UUID) -> String? {
22+
let key = draftKey(connectionId: connectionId)
23+
guard let query = defaults.string(forKey: key), !query.isEmpty else { return nil }
24+
defaults.removeObject(forKey: key)
25+
return query
26+
}
27+
28+
func removeDraft(for connectionId: UUID) {
29+
defaults.removeObject(forKey: draftKey(connectionId: connectionId))
30+
}
31+
32+
func removeDrafts(for connectionIds: Set<UUID>) {
33+
for id in connectionIds { removeDraft(for: id) }
34+
}
35+
36+
static func draftCandidate(from tabs: [QueryTab], selectedTabId: UUID?) -> String? {
37+
let candidates = tabs.filter { tab in
38+
tab.tabType == .query
39+
&& tab.content.sourceFileURL == nil
40+
&& !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
41+
}
42+
if let selectedTabId,
43+
let selected = candidates.first(where: { $0.id == selectedTabId }) {
44+
return selected.content.query
45+
}
46+
return candidates.first?.content.query
47+
}
48+
49+
private func cappedQuery(_ query: String) -> String {
50+
let queryNS = query as NSString
51+
guard queryNS.length > TabQueryContent.maxPersistableQuerySize else { return query }
52+
return queryNS.substring(to: TabQueryContent.maxPersistableQuerySize)
53+
}
54+
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ final class ConnectionStorage {
257257
FavoriteTablesStorage.shared.removeFavorites(for: connection.id)
258258
FilterSettingsStorage.shared.removeFilters(for: connection.id)
259259
DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id)
260+
ClosedTabDraftStorage.shared.removeDraft(for: connection.id)
260261
Task {
261262
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id)
262263
}
@@ -291,6 +292,7 @@ final class ConnectionStorage {
291292
}
292293
FilterSettingsStorage.shared.removeFilters(for: idsToDelete)
293294
DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete)
295+
ClosedTabDraftStorage.shared.removeDrafts(for: idsToDelete)
294296
Task {
295297
for conn in connectionsToDelete {
296298
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id)

TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ extension MainContentCoordinator {
6868
)
6969

7070
if !MainContentCoordinator.isAppTerminating {
71+
if let draft = ClosedTabDraftStorage.draftCandidate(
72+
from: tabManager.tabs,
73+
selectedTabId: tabManager.selectedTabId
74+
) {
75+
ClosedTabDraftStorage.shared.saveQuery(draft, connectionId: connectionId)
76+
}
7177
persistence.saveOrClearAggregatedSync()
7278
}
7379

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,18 @@ final class MainContentCommandActions {
332332
// MARK: - Tab Operations (Group A — Called Directly)
333333

334334
func newTab(initialQuery: String? = nil) {
335+
let resolvedQuery = initialQuery
336+
?? ClosedTabDraftStorage.shared.consumeQuery(connectionId: connection.id)
335337
if let coordinator, coordinator.tabManager.tabs.isEmpty {
336338
coordinator.tabManager.addTab(
337-
initialQuery: initialQuery,
339+
initialQuery: resolvedQuery,
338340
databaseName: coordinator.activeDatabaseName
339341
)
340342
return
341343
}
342344
let payload = EditorTabPayload(
343345
connectionId: connection.id,
344-
initialQuery: initialQuery,
346+
initialQuery: resolvedQuery,
345347
intent: .newEmptyTab
346348
)
347349
WindowManager.shared.openTab(payload: payload)
@@ -384,6 +386,12 @@ final class MainContentCommandActions {
384386
window.close()
385387
} else {
386388
if let coordinator {
389+
if let draft = ClosedTabDraftStorage.draftCandidate(
390+
from: coordinator.tabManager.tabs,
391+
selectedTabId: coordinator.tabManager.selectedTabId
392+
) {
393+
ClosedTabDraftStorage.shared.saveQuery(draft, connectionId: connection.id)
394+
}
387395
for tab in coordinator.tabManager.tabs {
388396
coordinator.tabSessionRegistry.removeTableRows(for: tab.id)
389397
if let url = tab.content.sourceFileURL {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import Foundation
2+
@testable import TablePro
3+
import Testing
4+
5+
@MainActor
6+
@Suite("ClosedTabDraftStorage")
7+
struct ClosedTabDraftStorageTests {
8+
private func makeStorage() throws -> ClosedTabDraftStorage {
9+
let suite = "ClosedTabDraftStorageTests.\(UUID().uuidString)"
10+
let defaults = try #require(UserDefaults(suiteName: suite))
11+
defaults.removePersistentDomain(forName: suite)
12+
return ClosedTabDraftStorage(defaults: defaults)
13+
}
14+
15+
@Test("Consuming with no stored draft returns nil")
16+
func defaultsNil() throws {
17+
let storage = try makeStorage()
18+
#expect(storage.consumeQuery(connectionId: UUID()) == nil)
19+
}
20+
21+
@Test("Saved draft round-trips on consume")
22+
func saveAndConsume() throws {
23+
let storage = try makeStorage()
24+
let connId = UUID()
25+
storage.saveQuery("SELECT 1", connectionId: connId)
26+
#expect(storage.consumeQuery(connectionId: connId) == "SELECT 1")
27+
}
28+
29+
@Test("A draft is consumed only once")
30+
func consumeOnce() throws {
31+
let storage = try makeStorage()
32+
let connId = UUID()
33+
storage.saveQuery("SELECT 1", connectionId: connId)
34+
#expect(storage.consumeQuery(connectionId: connId) == "SELECT 1")
35+
#expect(storage.consumeQuery(connectionId: connId) == nil)
36+
}
37+
38+
@Test("Drafts are isolated per connection")
39+
func perConnectionIsolation() throws {
40+
let storage = try makeStorage()
41+
let a = UUID()
42+
let b = UUID()
43+
storage.saveQuery("SELECT a", connectionId: a)
44+
#expect(storage.consumeQuery(connectionId: b) == nil)
45+
#expect(storage.consumeQuery(connectionId: a) == "SELECT a")
46+
}
47+
48+
@Test("Removing a draft clears the stored value")
49+
func removeDraftClears() throws {
50+
let storage = try makeStorage()
51+
let connId = UUID()
52+
storage.saveQuery("SELECT 1", connectionId: connId)
53+
storage.removeDraft(for: connId)
54+
#expect(storage.consumeQuery(connectionId: connId) == nil)
55+
}
56+
57+
@Test("Removing drafts in batch clears across connections")
58+
func removeDraftsBatchClears() throws {
59+
let storage = try makeStorage()
60+
let a = UUID()
61+
let b = UUID()
62+
storage.saveQuery("SELECT a", connectionId: a)
63+
storage.saveQuery("SELECT b", connectionId: b)
64+
storage.removeDrafts(for: Set([a, b]))
65+
#expect(storage.consumeQuery(connectionId: a) == nil)
66+
#expect(storage.consumeQuery(connectionId: b) == nil)
67+
}
68+
69+
@Test("Queries above the cap are truncated")
70+
func capApplied() throws {
71+
let storage = try makeStorage()
72+
let connId = UUID()
73+
let oversized = String(repeating: "a", count: TabQueryContent.maxPersistableQuerySize + 1)
74+
storage.saveQuery(oversized, connectionId: connId)
75+
let restored = try #require(storage.consumeQuery(connectionId: connId))
76+
#expect((restored as NSString).length == TabQueryContent.maxPersistableQuerySize)
77+
}
78+
79+
@Test("Blank query tabs produce no draft candidate")
80+
func blankQueryNotSaved() {
81+
let tab = QueryTab(query: " \n\t ")
82+
#expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil)
83+
}
84+
85+
@Test("File-backed tabs are excluded from draft candidates")
86+
func fileBackedTabExcluded() {
87+
var tab = QueryTab(query: "SELECT 1")
88+
tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/query.sql")
89+
#expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil)
90+
}
91+
92+
@Test("Table tabs are excluded from draft candidates")
93+
func tableTabExcluded() {
94+
let tab = QueryTab(query: "SELECT 1", tabType: .table, tableName: "users")
95+
#expect(ClosedTabDraftStorage.draftCandidate(from: [tab], selectedTabId: nil) == nil)
96+
}
97+
98+
@Test("The selected tab is preferred as the draft candidate")
99+
func prefersSelectedTab() {
100+
let first = QueryTab(query: "SELECT first")
101+
let second = QueryTab(query: "SELECT second")
102+
let candidate = ClosedTabDraftStorage.draftCandidate(
103+
from: [first, second],
104+
selectedTabId: second.id
105+
)
106+
#expect(candidate == "SELECT second")
107+
}
108+
109+
@Test("Falls back to the first candidate when no tab is selected")
110+
func fallsBackToFirstTab() {
111+
let first = QueryTab(query: "SELECT first")
112+
let second = QueryTab(query: "SELECT second")
113+
let candidate = ClosedTabDraftStorage.draftCandidate(
114+
from: [first, second],
115+
selectedTabId: nil
116+
)
117+
#expect(candidate == "SELECT first")
118+
}
119+
}

0 commit comments

Comments
 (0)