From 3cd8272dab0b4d87aed566dd1a569baae2534f4d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 21 Aug 2026 00:02:07 +0700 Subject: [PATCH] fix(welcome): stop a shared connection row from acting on the local connection --- CHANGELOG.md | 4 + .../Services/Export/LinkedFolderWatcher.swift | 2 +- .../Services/Infrastructure/TabRouter.swift | 16 ++- TablePro/Core/Storage/ConnectionStorage.swift | 12 ++- TablePro/ViewModels/WelcomeViewModel.swift | 25 ++++- .../ViewModels/WelcomeViewModelTests.swift | 101 +++++++++++++++++- 6 files changed, 148 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209f6b42c..39c76b4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Deleting a connection you had published to the Team Library no longer deletes your own local copy of it instead. The shared row was reusing the local connection's identity, so its Edit and Delete acted on the wrong one, taking the saved passwords with it. +- Opening a connection from a linked folder or the Team Library now works. Double-clicking one, or selecting it and pressing Return, used to do nothing at all, with no window, no error, and nothing on screen to say why. +- A connection whose deletion could not be saved to disk no longer disappears from the list. It vanished until the next launch and then came back. +- Select All, and stepping through connections with `Ctrl+J` and `Ctrl+K`, no longer skip favorited connections that are not in a group. - A column whose cells carry an action button, such as the up and down arrows on a date, no longer cuts its value short. Widths saved before 0.66 were measured with no room for the button and were then treated as widths you had chosen, so they were never measured again. Those columns are now measured from their content, while a width you set yourself, the column order, and which columns are hidden are all left alone. An enum, set or foreign key column whose metadata arrives after the rows widens to fit rather than staying short, and never shrinks under you. (#2303) - Size to Fit, Size All Columns to Fit and double-clicking a column edge now measure the loaded page instead of about 30 rows of it, so the longest value is no longer stepped over and the column is no longer still too narrow after you asked it to fit. On a very wide result the scan is bounded so sizing every column at once stays responsive. (#2303) - 235 Turkish strings now read in Turkish. They were marked as translated while still holding their English text, so menus, buttons, alerts and error messages across connections, plugins, SSH, sync and the query editor showed English to Turkish users with nothing reporting a gap. diff --git a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift index cc0d91038..adb0ab8f4 100644 --- a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift +++ b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift @@ -166,7 +166,7 @@ final class LinkedFolderWatcher { // MARK: - Stable IDs (SHA-256 based, deterministic across launches) - nonisolated private static func stableId(folderId: UUID, connection: ExportableConnection) -> UUID { + nonisolated static func stableId(folderId: UUID, connection: ExportableConnection) -> UUID { let key = "\(folderId.uuidString)|\(connection.name)|\(connection.host)|\(connection.port)|\(connection.type)" let digest = SHA256.hash(data: Data(key.utf8)) var bytes = Array(digest.prefix(16)) diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 77c69f310..099baf331 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -90,8 +90,17 @@ internal final class TabRouter { // MARK: - Connection - private func openConnection(id: UUID) async throws { - guard let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) else { + internal func openTransientConnection(_ connection: DatabaseConnection) async throws { + try await openConnection(id: connection.id, transientConnection: connection) + } + + private func openConnection(id: UUID, transientConnection: DatabaseConnection? = nil) async throws { + let connection: DatabaseConnection + if let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) { + connection = stored + } else if let transientConnection { + connection = transientConnection + } else { throw TabRouterError.connectionNotFound(id) } if let existing = WindowLifecycleMonitor.shared.mostRecentWindow(for: id) @@ -110,6 +119,9 @@ internal final class TabRouter { return } let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + if transientConnection != nil { + DatabaseManager.shared.registerPendingSession(connection) + } WindowManager.shared.openTab(payload: payload, autoConnect: true) NSApp.activate(ignoringOtherApps: true) WindowOpener.shared.closeWelcome() diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index e047c4b17..243156b21 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -263,12 +263,13 @@ final class ConnectionStorage { } /// Delete a connection - func deleteConnection(_ connection: DatabaseConnection) { + @discardableResult + func deleteConnection(_ connection: DatabaseConnection) -> Bool { var connections = loadConnections() connections.removeAll { $0.id == connection.id } guard saveConnections(connections) else { Self.logger.error("Aborted deleteConnection: persistence failed for \(connection.id, privacy: .public)") - return + return false } if !connection.localOnly && !connection.isSample { syncTracker.markDeleted(.connection, id: connection.id.uuidString) @@ -302,16 +303,18 @@ final class ConnectionStorage { matching: QueryHistoryFilter(scope: .connection(connection.id)) ) } + return true } /// Batch-delete multiple connections and clean up their Keychain entries - func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) { + @discardableResult + func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) -> Bool { let idsToDelete = Set(connectionsToDelete.map(\.id)) var all = loadConnections() all.removeAll { idsToDelete.contains($0.id) } guard saveConnections(all) else { Self.logger.error("Aborted deleteConnections: persistence failed for \(idsToDelete.count, privacy: .public) connection(s)") - return + return false } for conn in connectionsToDelete where !conn.localOnly && !conn.isSample { syncTracker.markDeleted(.connection, id: conn.id.uuidString) @@ -348,6 +351,7 @@ final class ConnectionStorage { ) } } + return true } /// Duplicate a connection with a new UUID and "(Copy)" suffix diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 782f4887b..4cfd8c80b 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -155,7 +155,10 @@ final class WelcomeViewModel { } var flatVisibleConnections: [DatabaseConnection] { - flattenVisibleConnections(tree: treeItems, expandedGroupIds: expandedGroupIds) + let inTree = flattenVisibleConnections(tree: treeItems, expandedGroupIds: expandedGroupIds) + guard searchText.isEmpty, !favoriteConnections.isEmpty else { return inTree } + var seen = Set() + return (favoriteConnections + inTree).filter { seen.insert($0.id).inserted } } var selectedConnections: [DatabaseConnection] { @@ -333,7 +336,13 @@ final class WelcomeViewModel { username: linked.connection.username, type: DatabaseType(rawValue: linked.connection.type) ) - connectToDatabase(connection) + Task { + do { + try await TabRouter.shared.openTransientConnection(connection) + } catch { + handleConnectError(error, connection: connection) + } + } } private static let teamLibraryFolderId = UUID(uuidString: "00000000-0000-0000-0000-000000000000") ?? UUID() @@ -343,7 +352,10 @@ final class WelcomeViewModel { let placeholderURL = URL(fileURLWithPath: "/") return TeamLibrarySyncCoordinator.shared.library.connections.map { connection in LinkedConnection( - id: UUID(uuidString: connection.sourceConnectionId ?? "") ?? UUID(), + id: LinkedFolderWatcher.stableId( + folderId: teamLibraryFolderId, + connection: connection.payload + ), connection: connection.payload, folderId: teamLibraryFolderId, sourceFileURL: placeholderURL @@ -393,7 +405,12 @@ final class WelcomeViewModel { func deleteSelectedConnections() { let idsToDelete = Set(connectionsToDelete.map(\.id)) - storage.deleteConnections(connectionsToDelete) + guard storage.deleteConnections(connectionsToDelete) else { + connectionsToDelete = [] + connections = storage.loadConnections() + rebuildTree() + return + } connections.removeAll { idsToDelete.contains($0.id) } selectedConnectionIds.subtract(idsToDelete) connectionsToDelete = [] diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift index 91d73e8c4..f8fb77686 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -4,9 +4,10 @@ // @testable import TablePro +import TableProImport import TableProPluginKit -import XCTest import TableProSyncTransport +import XCTest @MainActor final class WelcomeViewModelTests: XCTestCase { @@ -214,4 +215,102 @@ final class WelcomeViewModelTests: XCTestCase { XCTAssertNil(viewModel.pluginInstallConnection) XCTAssertEqual(welcomeRouter.pendingPluginInstall?.id, connection.id) } + + func testAFavoritedUngroupedConnectionStaysReachableFromTheKeyboard() { + var favorited = DatabaseConnection(name: "Starred", type: .mysql) + favorited.isFavorite = true + let plain = DatabaseConnection(name: "Plain", type: .mysql) + connectionStorage.saveConnections([favorited, plain]) + + viewModel.loadConnections() + + XCTAssertEqual(viewModel.favoriteConnections.map(\.id), [favorited.id]) + XCTAssertFalse( + viewModel.treeItems.contains { node in + if case .connection(let conn) = node { return conn.id == favorited.id } + return false + }, + "A favorited ungrouped connection is rendered in the Favorites section, not the tree" + ) + XCTAssertEqual( + Set(viewModel.flatVisibleConnections.map(\.id)), + [favorited.id, plain.id], + "Select All and Ctrl+J walk flatVisibleConnections, so it must include the Favorites section" + ) + } + + func testFlatVisibleConnectionsListsEachConnectionOnce() { + var favorited = DatabaseConnection(name: "Starred", type: .mysql) + favorited.isFavorite = true + connectionStorage.saveConnections([favorited]) + + viewModel.loadConnections() + + let ids = viewModel.flatVisibleConnections.map(\.id) + XCTAssertEqual(ids.count, Set(ids).count, "Ctrl+J must never visit the same connection twice") + } + + func testDeleteKeepsTheConnectionWhenPersistenceFails() throws { + let connection = DatabaseConnection(name: "Prod", type: .mysql) + XCTAssertTrue(connectionStorage.saveConnections([connection])) + viewModel.loadConnections() + XCTAssertEqual(viewModel.connections.map(\.id), [connection.id]) + + let directory = connectionFileURL.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + } + + connectionStorage.invalidateCache() + viewModel.connectionsToDelete = [connection] + viewModel.deleteSelectedConnections() + + XCTAssertEqual( + viewModel.connections.map(\.id), + [connection.id], + "A connection that could not be persisted as deleted must not disappear from the list" + ) + XCTAssertTrue(viewModel.connectionsToDelete.isEmpty) + } + + func testSharedRowIdsAreDerivedFromThePayloadAndAreStable() { + let folderId = UUID() + let payload = makeExportable(name: "Shared") + + let first = LinkedFolderWatcher.stableId(folderId: folderId, connection: payload) + let second = LinkedFolderWatcher.stableId(folderId: folderId, connection: payload) + let otherFolder = LinkedFolderWatcher.stableId(folderId: UUID(), connection: payload) + let otherPayload = LinkedFolderWatcher.stableId( + folderId: folderId, + connection: makeExportable(name: "Different") + ) + + XCTAssertEqual(first, second, "A shared row must keep its identity across launches") + XCTAssertNotEqual(first, otherFolder) + XCTAssertNotEqual(first, otherPayload) + } + + private func makeExportable(name: String) -> ExportableConnection { + ExportableConnection( + name: name, + host: "db.example.com", + port: 3_306, + database: "app", + username: "reader", + type: DatabaseType.mysql.rawValue, + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + } }