Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions TablePro/Core/Storage/ClosedTabDraftStorage.swift
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)
}
}
2 changes: 2 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid saving drafts during database-switch cleanup

When switching databases, switchDatabase(to:) calls closeSiblingNativeWindows(), which closes sibling native tabs via sibling.close() before clearing the current tab (MainContentCoordinator+Navigation.swift lines 373-397). Those programmatic closes now run this same windowWillClose path 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 👍 / 👎.

}
persistence.saveOrClearAggregatedSync()
}

Expand Down
12 changes: 10 additions & 2 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
119 changes: 119 additions & 0 deletions TableProTests/Core/Storage/ClosedTabDraftStorageTests.swift
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")
}
}
Loading