diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e3b9dea..d59597a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Deleting a connection that has saved queries now always warns that they go with it. The confirmation used to appear before the check for saved queries had finished, so it usually showed the shorter message and the warning was missed. (#2310) +- 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 d7e66b3af..9925b17a1 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -156,7 +156,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] { @@ -334,7 +337,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() @@ -344,7 +353,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 @@ -398,7 +410,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 4f669ffc6..ff71a2654 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -4,6 +4,7 @@ // @testable import TablePro +import TableProImport import TableProPluginKit import TableProSyncTransport import XCTest @@ -267,4 +268,101 @@ final class WelcomeViewModelTests: XCTestCase { try await Task.sleep(nanoseconds: 5_000_000) } } + 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 + ) + } }