Skip to content

Commit eac44d1

Browse files
authored
Merge pull request #354 from datlechin/fix/sidebar-refresh-stale-cache
fix: sidebar not refreshing after creating or dropping tables
2 parents 53ce59a + df7bf14 commit eac44d1

File tree

9 files changed

+123
-28
lines changed

9 files changed

+123
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- SSH port field accepting invalid values
2828
- DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers
2929
- Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL
30+
- Sidebar not refreshing after creating or dropping tables
31+
- Dropping a table disconnecting the database when the dropped table's tab was active
3032

3133
## [0.19.1] - 2026-03-16
3234

TablePro/Core/Autocomplete/SQLSchemaProvider.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ actor SQLSchemaProvider {
101101
cachedDriver = nil
102102
}
103103

104+
func invalidateTables() {
105+
tables.removeAll()
106+
}
107+
108+
func updateTables(_ newTables: [TableInfo]) {
109+
tables = newTables
110+
}
111+
112+
func fetchFreshTables() async throws -> [TableInfo]? {
113+
guard let driver = cachedDriver else { return nil }
114+
let fresh = try await driver.fetchTables()
115+
tables = fresh
116+
return fresh
117+
}
118+
104119
/// Find table name from alias
105120
func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? {
106121
// First check if it's an alias

TablePro/ViewModels/SidebarViewModel.swift

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import SwiftUI
1313

1414
/// Abstraction over table fetching for testability
1515
protocol TableFetcher: Sendable {
16-
func fetchTables() async throws -> [TableInfo]
16+
func fetchTables(force: Bool) async throws -> [TableInfo]
1717
}
1818

1919
/// Production implementation that uses DatabaseManager, with optional schema provider cache
@@ -26,17 +26,23 @@ struct LiveTableFetcher: TableFetcher {
2626
self.schemaProvider = schemaProvider
2727
}
2828

29-
func fetchTables() async throws -> [TableInfo] {
29+
func fetchTables(force: Bool) async throws -> [TableInfo] {
3030
if let provider = schemaProvider {
31-
let cached = await provider.getTables()
32-
if !cached.isEmpty {
33-
return cached
31+
if force {
32+
if let fresh = try await provider.fetchFreshTables() { return fresh }
33+
} else {
34+
let cached = await provider.getTables()
35+
if !cached.isEmpty { return cached }
3436
}
3537
}
3638
guard let driver = await DatabaseManager.shared.driver(for: connectionId) else {
3739
return []
3840
}
39-
return try await driver.fetchTables()
41+
let fetched = try await driver.fetchTables()
42+
if let provider = schemaProvider {
43+
await provider.updateTables(fetched)
44+
}
45+
return fetched
4046
}
4147
}
4248

@@ -145,27 +151,27 @@ final class SidebarViewModel {
145151

146152
// MARK: - Table Loading
147153

148-
func loadTables() {
154+
func loadTables(force: Bool = false) {
149155
guard !isLoading else { return }
150156
isLoading = true
151157
errorMessage = nil
152158
loadTask = Task {
153-
await loadTablesAsync()
159+
await loadTablesAsync(force: force)
154160
}
155161
}
156162

157163
func forceLoadTables() {
158164
loadTask?.cancel()
159165
loadTask = nil
160166
isLoading = false
161-
loadTables()
167+
loadTables(force: true)
162168
}
163169

164-
private func loadTablesAsync() async {
170+
private func loadTablesAsync(force: Bool = false) async {
165171
let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name
166172

167173
do {
168-
let fetchedTables = try await tableFetcher.fetchTables()
174+
let fetchedTables = try await tableFetcher.fetchTables(force: force)
169175
tables = fetchedTables
170176

171177
// Clean up stale entries for tables that no longer exist

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,24 @@ extension MainContentCoordinator {
225225
}
226226

227227
if clearTableOps {
228-
// Close tabs for deleted tables
228+
// Remove tabs for deleted tables
229229
if !deletedTables.isEmpty {
230-
if let currentTab = tabManager.selectedTab,
231-
let tableName = currentTab.tableName,
232-
deletedTables.contains(tableName) {
233-
NSApp.keyWindow?.close()
230+
let tabIdsToRemove = Set(
231+
tabManager.tabs
232+
.filter { $0.tabType == .table && deletedTables.contains($0.tableName ?? "") }
233+
.map(\.id)
234+
)
235+
236+
if !tabIdsToRemove.isEmpty {
237+
let firstRemovedIndex = tabManager.tabs
238+
.firstIndex { tabIdsToRemove.contains($0.id) } ?? 0
239+
tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) }
240+
if !tabManager.tabs.isEmpty {
241+
let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1)
242+
tabManager.selectedTabId = tabManager.tabs[neighborIndex].id
243+
} else {
244+
tabManager.selectedTabId = nil
245+
}
234246
}
235247
}
236248

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ final class MainContentCoordinator {
313313
}
314314

315315
func reloadSidebar() {
316-
sidebarViewModel?.forceLoadTables()
316+
Task { @MainActor in
317+
await schemaProvider.invalidateTables()
318+
sidebarViewModel?.forceLoadTables()
319+
}
317320
}
318321

319322
/// Explicit cleanup called from `onDisappear`. Releases schema provider

TableProTests/ViewModels/LiveTableFetcherTests.swift

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ struct LiveTableFetcherTests {
9090
#expect(initialCallCount == 1)
9191

9292
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
93-
let result = try await fetcher.fetchTables()
93+
let result = try await fetcher.fetchTables(force: false)
9494

9595
#expect(result.count == 3)
9696
#expect(result.map(\.name) == ["users", "orders", "products"])
@@ -102,15 +102,15 @@ struct LiveTableFetcherTests {
102102
let provider = SQLSchemaProvider()
103103

104104
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
105-
let result = try await fetcher.fetchTables()
105+
let result = try await fetcher.fetchTables(force: false)
106106

107107
#expect(result.isEmpty)
108108
}
109109

110110
@Test("works without schema provider using direct driver fetch")
111111
func worksWithoutSchemaProvider() async throws {
112112
let fetcher = LiveTableFetcher(connectionId: UUID())
113-
let result = try await fetcher.fetchTables()
113+
let result = try await fetcher.fetchTables(force: false)
114114

115115
#expect(result.isEmpty)
116116
}
@@ -131,11 +131,64 @@ struct LiveTableFetcherTests {
131131
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
132132

133133
for _ in 0..<3 {
134-
let result = try await fetcher.fetchTables()
134+
let result = try await fetcher.fetchTables(force: false)
135135
#expect(result.count == 2)
136136
#expect(result.map(\.name) == ["accounts", "transactions"])
137137
}
138138

139139
#expect(mockDriver.fetchTablesCallCount == 1)
140140
}
141+
142+
@Test("force: true bypasses schema provider cache and hits driver")
143+
func forceBypassesCache() async throws {
144+
let initialTables = [
145+
TestFixtures.makeTableInfo(name: "users"),
146+
TestFixtures.makeTableInfo(name: "orders")
147+
]
148+
149+
let mockDriver = MockDatabaseDriver()
150+
mockDriver.tablesToReturn = initialTables
151+
152+
let provider = SQLSchemaProvider()
153+
await provider.loadSchema(using: mockDriver)
154+
155+
let freshTables = [
156+
TestFixtures.makeTableInfo(name: "users"),
157+
TestFixtures.makeTableInfo(name: "orders"),
158+
TestFixtures.makeTableInfo(name: "new_table")
159+
]
160+
mockDriver.tablesToReturn = freshTables
161+
162+
let callCountBefore = mockDriver.fetchTablesCallCount
163+
164+
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
165+
let result = try await fetcher.fetchTables(force: true)
166+
167+
#expect(result.count == 3)
168+
#expect(result.map(\.name) == ["users", "orders", "new_table"])
169+
#expect(mockDriver.fetchTablesCallCount == callCountBefore + 1)
170+
}
171+
172+
@Test("force: true writes fresh tables back into schema provider")
173+
func forcedFetchUpdatesSchemaProvider() async throws {
174+
let initialTables = [TestFixtures.makeTableInfo(name: "old_table")]
175+
176+
let mockDriver = MockDatabaseDriver()
177+
mockDriver.tablesToReturn = initialTables
178+
179+
let provider = SQLSchemaProvider()
180+
await provider.loadSchema(using: mockDriver)
181+
182+
let freshTables = [
183+
TestFixtures.makeTableInfo(name: "alpha"),
184+
TestFixtures.makeTableInfo(name: "beta")
185+
]
186+
mockDriver.tablesToReturn = freshTables
187+
188+
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
189+
_ = try await fetcher.fetchTables(force: true)
190+
191+
let cached = await provider.getTables()
192+
#expect(cached.map(\.name).sorted() == ["alpha", "beta"])
193+
}
141194
}

TableProTests/ViewModels/SidebarViewModelTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ private struct MockTableFetcher: TableFetcher {
1616
var tables: [TableInfo]
1717
var error: Error?
1818

19-
func fetchTables() async throws -> [TableInfo] {
19+
func fetchTables(force: Bool) async throws -> [TableInfo] {
2020
if let error { throw error }
2121
return tables
2222
}

TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ import Testing
1818
private final class FetchTrackingTableFetcher: TableFetcher, @unchecked Sendable {
1919
private let lock = NSLock()
2020
private var _fetchCount = 0
21+
private var _forceCount = 0
2122

22-
var fetchCount: Int {
23-
lock.withLock { _fetchCount }
24-
}
23+
var fetchCount: Int { lock.withLock { _fetchCount } }
24+
var forceCount: Int { lock.withLock { _forceCount } }
2525

26-
func fetchTables() async throws -> [TableInfo] {
27-
lock.withLock { _fetchCount += 1 }
26+
func fetchTables(force: Bool) async throws -> [TableInfo] {
27+
lock.withLock {
28+
_fetchCount += 1
29+
if force { _forceCount += 1 }
30+
}
2831
return []
2932
}
3033
}
@@ -77,6 +80,7 @@ struct CoordinatorReloadSidebarTests {
7780
try? await Task.sleep(nanoseconds: 100_000_000)
7881

7982
#expect(mockFetcher.fetchCount > 0)
83+
#expect(mockFetcher.forceCount > 0)
8084
}
8185

8286
@Test("reloadSidebar is safe when sidebarViewModel is nil")

TableProTests/Views/SwitchDatabaseTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import Testing
1818
private struct MockTableFetcher: TableFetcher {
1919
var tables: [TableInfo]
2020

21-
func fetchTables() async throws -> [TableInfo] {
21+
func fetchTables(force: Bool) async throws -> [TableInfo] {
2222
tables
2323
}
2424
}

0 commit comments

Comments
 (0)