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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Services/Export/LinkedFolderWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 14 additions & 2 deletions TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
12 changes: 8 additions & 4 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -348,6 +351,7 @@ final class ConnectionStorage {
)
}
}
return true
}

/// Duplicate a connection with a new UUID and "(Copy)" suffix
Expand Down
25 changes: 21 additions & 4 deletions TablePro/ViewModels/WelcomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>()
return (favoriteConnections + inTree).filter { seen.insert($0.id).inserted }
}

var selectedConnections: [DatabaseConnection] {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down
98 changes: 98 additions & 0 deletions TableProTests/ViewModels/WelcomeViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

@testable import TablePro
import TableProImport
import TableProPluginKit
import TableProSyncTransport
import XCTest
Expand Down Expand Up @@ -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
)
}
}
Loading