From a764ef6fcb6a7febb18a0ef08e85a25075f4331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 18:27:33 +0700 Subject: [PATCH 1/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2c9051166..9c535254a 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ From bc530fd9b063dab48a44295d79403a48a8d1ddb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 19:25:09 +0700 Subject: [PATCH 2/7] Update CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging From 97c7f1175275837eb0f0032ce67f47c10818b46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 20:44:04 +0700 Subject: [PATCH 3/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c535254a..1dd4bc8e4 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ .docs/ +Local.xcconfig From 8a85e2598b6e5fd78304b327f383715c0508032e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 21:59:28 +0700 Subject: [PATCH 4/7] fix(sidebar): add debug logging for spinner not clearing after preview --- CHANGELOG.md | 1 + .../Core/Services/Query/SchemaService.swift | 89 +++++++++-- .../Services/SchemaServiceRoutinesTests.swift | 151 ++++++++++++++++++ 3 files changed, 228 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66a87740..c47c3415f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Generate Token sheet focuses the Token Name field on first open. (#1093) - Double-clicking a CSV or TSV file when TablePro is closed opens the file directly. (#1443) - Opening a `.sql` file names the tab after the file instead of showing "SQL Query". (#1220) +- Restoring or previewing a table no longer leaves the Tables sidebar spinner stuck after the table list has already loaded. ## [0.45.0] - 2026-05-26 diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index ead0ed194..e35127d38 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -30,6 +30,8 @@ final class SchemaService { let schema: String } @ObservationIgnored private var schemaChangeCancellable: AnyCancellable? + @ObservationIgnored private var loadGenerations: [UUID: Int] = [:] + @ObservationIgnored private var nextLoadGeneration = 0 @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") init() { @@ -168,6 +170,7 @@ final class SchemaService { await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) } } + loadGenerations.removeValue(forKey: connectionId) states.removeValue(forKey: connectionId) procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) @@ -187,6 +190,7 @@ final class SchemaService { driver: DatabaseDriver, connection: DatabaseConnection ) async { + let generation = beginLoadGeneration(for: connectionId) states[connectionId] = .loading let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: connection.type) @@ -215,21 +219,45 @@ final class SchemaService { dedup: functionDedup, fetch: { try await driver.fetchFunctions(schema: nil) } ) - - let loadedProcedures = await proceduresTask - let loadedFunctions = await functionsTask - if supportsSchemas { - await loadSchemaList(connectionId: connectionId, driver: driver) - } + async let schemasTask: [String]? = supportsSchemas + ? Self.fetchSchemasSafely( + connectionId: connectionId, + dedup: schemasDedup, + fetch: { try await driver.fetchSchemas() } + ) + : nil do { let tables = try await tablesTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-loaded") else { + return + } states[connectionId] = .loaded(tables) + + let loadedProcedures = await proceduresTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "procedures-loaded") else { + return + } procedures[connectionId] = loadedProcedures + + let loadedFunctions = await functionsTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "functions-loaded") else { + return + } functions[connectionId] = loadedFunctions + + if let loadedSchemas = await schemasTask { + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "schemas-loaded") else { + return + } + schemasInOrder[connectionId] = loadedSchemas + } } catch is CancellationError { return } catch { + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-failed") else { + return + } Self.logger.warning( "[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) @@ -253,25 +281,60 @@ final class SchemaService { let loadedProcedures = await proceduresTask let loadedFunctions = await functionsTask - await loadSchemaList(connectionId: connectionId, driver: driver) + if let loadedSchemas = await Self.fetchSchemasSafely( + connectionId: connectionId, + dedup: schemasDedup, + fetch: { try await driver.fetchSchemas() } + ) { + schemasInOrder[connectionId] = loadedSchemas + } procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions states[connectionId] = .loaded([]) } - private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { + private func beginLoadGeneration(for connectionId: UUID) -> Int { + nextLoadGeneration += 1 + let generation = nextLoadGeneration + if case .loading? = states[connectionId] { + let previousGeneration = loadGenerations[connectionId] ?? 0 + Self.logger.debug( + "[schema] superseding in-flight load connId=\(connectionId, privacy: .public) previousGeneration=\(previousGeneration) newGeneration=\(generation)" + ) + } + loadGenerations[connectionId] = generation + return generation + } + + private func isCurrentLoadGeneration( + _ generation: Int, + for connectionId: UUID, + phase: String + ) -> Bool { + guard loadGenerations[connectionId] == generation else { + Self.logger.debug( + "[schema] stale load transition ignored connId=\(connectionId, privacy: .public) phase=\(phase, privacy: .public) generation=\(generation) currentGeneration=\(loadGenerations[connectionId] ?? 0)" + ) + return false + } + return true + } + + private static func fetchSchemasSafely( + connectionId: UUID, + dedup: OnceTask, + fetch: @Sendable @escaping () async throws -> [String] + ) async -> [String]? { do { - let allSchemas = try await schemasDedup.execute(key: connectionId) { - try await driver.fetchSchemas() - } - schemasInOrder[connectionId] = allSchemas + return try await dedup.execute(key: connectionId, work: fetch) } catch is CancellationError { - return + return nil } catch { Self.logger.warning( "[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) + return nil } } diff --git a/TableProTests/Services/SchemaServiceRoutinesTests.swift b/TableProTests/Services/SchemaServiceRoutinesTests.swift index eaa7a4dac..2f1c7eaa2 100644 --- a/TableProTests/Services/SchemaServiceRoutinesTests.swift +++ b/TableProTests/Services/SchemaServiceRoutinesTests.swift @@ -6,6 +6,7 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable { @@ -153,6 +154,114 @@ private final class FailingRoutineDriver: DatabaseDriver, @unchecked Sendable { } } +private actor AsyncGate { + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + func wait() async { + if isOpen { + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + guard !isOpen else { return } + isOpen = true + let currentWaiters = waiters + waiters.removeAll() + for continuation in currentWaiters { + continuation.resume() + } + } +} + +private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var tablesToReturn: [TableInfo] = [] + var proceduresToReturn: [RoutineInfo] = [] + var functionsToReturn: [RoutineInfo] = [] + var schemasToReturn: [String] = [] + + let routinesGate = AsyncGate() + let schemasGate = AsyncGate() + + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchTables() async throws -> [TableInfo] { + tablesToReturn + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchSchemas() async throws -> [String] { + await schemasGate.wait() + return schemasToReturn + } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { + await routinesGate.wait() + return proceduresToReturn + } + + func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { + await routinesGate.wait() + return functionsToReturn + } +} + @Suite("SchemaService routines") @MainActor struct SchemaServiceRoutinesTests { @@ -240,6 +349,48 @@ struct SchemaServiceRoutinesTests { #expect(service.functions(for: connectionId).isEmpty) } + @Test("table state becomes loaded before auxiliary metadata finishes") + func tableStateLoadsBeforeAuxiliaryMetadata() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = BlockingAuxiliaryDriver(connection: connection) + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.proceduresToReturn = [ + RoutineInfo(name: "add_user", schema: "public", kind: .procedure, signature: nil) + ] + driver.functionsToReturn = [ + RoutineInfo(name: "user_count", schema: "public", kind: .function, signature: "int") + ] + driver.schemasToReturn = ["public"] + + let loadTask = Task { + await service.load(connectionId: connectionId, driver: driver, connection: connection) + } + + var loadedTables: [TableInfo] = [] + for _ in 0..<100 { + if case .loaded(let tables) = service.state(for: connectionId) { + loadedTables = tables + break + } + await Task.yield() + } + + #expect(loadedTables.map(\.name) == ["users"]) + #expect(service.procedures(for: connectionId).isEmpty) + #expect(service.functions(for: connectionId).isEmpty) + #expect(service.schemas(for: connectionId).isEmpty) + + await driver.routinesGate.open() + await driver.schemasGate.open() + await loadTask.value + + #expect(service.procedures(for: connectionId).map(\.name) == ["add_user"]) + #expect(service.functions(for: connectionId).map(\.name) == ["user_count"]) + #expect(service.schemas(for: connectionId) == ["public"]) + } + @Test("reloadProcedures refreshes only procedures") func reloadProceduresOnly() async { let service = SchemaService() From 7e337dc09af96675f7e3fdefce3822d341c0bc41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 22:13:44 +0700 Subject: [PATCH 5/7] fix(sidebar): prevent stuck spinner on stale load generation; fix racy test --- .../Core/Services/Query/SchemaService.swift | 3 +++ .../Services/SchemaServiceRoutinesTests.swift | 25 +++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index e35127d38..32522ec24 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -256,6 +256,9 @@ final class SchemaService { return } catch { guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-failed") else { + if case .loading = states[connectionId] { + states[connectionId] = .idle + } return } Self.logger.warning( diff --git a/TableProTests/Services/SchemaServiceRoutinesTests.swift b/TableProTests/Services/SchemaServiceRoutinesTests.swift index 2f1c7eaa2..a4865b3b0 100644 --- a/TableProTests/Services/SchemaServiceRoutinesTests.swift +++ b/TableProTests/Services/SchemaServiceRoutinesTests.swift @@ -189,6 +189,7 @@ private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable var functionsToReturn: [RoutineInfo] = [] var schemasToReturn: [String] = [] + let tablesGate = AsyncGate() let routinesGate = AsyncGate() let schemasGate = AsyncGate() @@ -214,7 +215,8 @@ private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable } func fetchTables() async throws -> [TableInfo] { - tablesToReturn + await tablesGate.wait() + return tablesToReturn } func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } @@ -368,16 +370,10 @@ struct SchemaServiceRoutinesTests { await service.load(connectionId: connectionId, driver: driver, connection: connection) } - var loadedTables: [TableInfo] = [] - for _ in 0..<100 { - if case .loaded(let tables) = service.state(for: connectionId) { - loadedTables = tables - break - } - await Task.yield() - } + await driver.tablesGate.open() + await waitForLoadedState(service, connectionId: connectionId) - #expect(loadedTables.map(\.name) == ["users"]) + #expect(service.tables(for: connectionId).map(\.name) == ["users"]) #expect(service.procedures(for: connectionId).isEmpty) #expect(service.functions(for: connectionId).isEmpty) #expect(service.schemas(for: connectionId).isEmpty) @@ -391,6 +387,15 @@ struct SchemaServiceRoutinesTests { #expect(service.schemas(for: connectionId) == ["public"]) } + private func waitForLoadedState(_ service: SchemaService, connectionId: UUID) async { + while true { + if case .loaded = service.state(for: connectionId) { + return + } + await Task.yield() + } + } + @Test("reloadProcedures refreshes only procedures") func reloadProceduresOnly() async { let service = SchemaService() From 11d61685890f655e0b3eca96cf40dbca31aefb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Thu, 28 May 2026 20:45:30 +0700 Subject: [PATCH 6/7] fix(sidebar): only reset idle state when no active load generation exists --- TablePro/Core/Services/Query/SchemaService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 32522ec24..0cf0ddf10 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -256,7 +256,7 @@ final class SchemaService { return } catch { guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-failed") else { - if case .loading = states[connectionId] { + if loadGenerations[connectionId] == nil, case .loading = states[connectionId] { states[connectionId] = .idle } return From 886a058b279c35d612db451058ff61ad3bf0acd8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 30 May 2026 03:55:37 +0700 Subject: [PATCH 7/7] fix(sidebar): guard hierarchical schema loads against stale generations --- TablePro/Core/Services/Query/SchemaService.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 449280d2d..e8a3c251a 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -215,7 +215,7 @@ final class SchemaService { let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) if grouping == .hierarchicalSchema { - await runHierarchicalLoad(connectionId: connectionId, driver: driver) + await runHierarchicalLoad(connectionId: connectionId, driver: driver, generation: generation) return } @@ -285,7 +285,7 @@ final class SchemaService { } } - private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver) async { + private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver, generation: Int) async { async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( connectionId: connectionId, kind: .procedure, @@ -301,14 +301,18 @@ final class SchemaService { let loadedProcedures = await proceduresTask let loadedFunctions = await functionsTask - if let loadedSchemas = await Self.fetchSchemasSafely( + let loadedSchemas = await Self.fetchSchemasSafely( connectionId: connectionId, dedup: schemasDedup, fetch: { try await driver.fetchSchemas() } - ) { + ) + + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "hierarchical-loaded") else { + return + } + if let loadedSchemas { schemasInOrder[connectionId] = loadedSchemas } - procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions states[connectionId] = .loaded([])