From f00595cc9523c3b22aea60491897ae05579b875c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 28 May 2026 12:45:03 +0700 Subject: [PATCH 1/5] feat(welcome): favorite connections from the welcome screen (#1302) --- CHANGELOG.md | 1 + TablePro/Core/Storage/ConnectionStorage.swift | 9 +++++ TablePro/Core/Sync/SyncRecordMapper.swift | 3 ++ .../Connection/DatabaseConnection.swift | 7 +++- TablePro/ViewModels/WelcomeViewModel.swift | 20 ++++++++++ .../Connection/WelcomeConnectionRow.swift | 38 +++++++++++++++++++ .../Connection/WelcomeContextMenus.swift | 23 +++++++++++ .../Views/Connection/WelcomeWindowView.swift | 20 +++++++++- .../ConnectionStoragePersistenceTests.swift | 25 ++++++++++++ docs/databases/overview.mdx | 8 ++++ 10 files changed, 152 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2f6533..551adb8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) - Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425) - Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421) +- Favorite a connection from the welcome screen. Hover a row or right-click to star it, and starred connections appear in a Favorites section at the top of the list. They keep their place in their group below too, so you can still find them where you put them. (#1302) ### Changed diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index d212298ef..a9e88d9b7 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -551,6 +551,8 @@ private struct StoredConnection: Codable { let isSample: Bool + let isFavorite: Bool + // TOTP configuration let totpMode: String let totpAlgorithm: String @@ -638,6 +640,9 @@ private struct StoredConnection: Codable { // Sample marker self.isSample = connection.isSample + // Favorite flag + self.isFavorite = connection.isFavorite + // SSH tunnel mode (v2 format preserving jump hosts, profiles, etc.) self.sshTunnelModeJson = try? JSONEncoder().encode(connection.sshTunnelMode) @@ -669,6 +674,7 @@ private struct StoredConnection: Codable { case additionalFields case localOnly case isSample + case isFavorite } func encode(to encoder: Encoder) throws { @@ -711,6 +717,7 @@ private struct StoredConnection: Codable { try container.encodeIfPresent(additionalFields, forKey: .additionalFields) try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) + try container.encode(isFavorite, forKey: .isFavorite) } // Custom decoder to handle migration from old format @@ -779,6 +786,7 @@ private struct StoredConnection: Codable { additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false + isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false } func toConnection() -> DatabaseConnection { @@ -882,6 +890,7 @@ private struct StoredConnection: Codable { sortOrder: sortOrder, localOnly: localOnly, isSample: isSample, + isFavorite: isFavorite, additionalFields: mergedFields ) } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index b0e236b1c..6eba88390 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -78,6 +78,7 @@ struct SyncRecordMapper { record["modifiedAtLocal"] = Date() as CKRecordValue record["schemaVersion"] = schemaVersion as CKRecordValue record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue + record["isFavorite"] = Int64(connection.isFavorite ? 1 : 0) as CKRecordValue if let tagId = connection.tagId { record["tagId"] = tagId.uuidString as CKRecordValue @@ -163,6 +164,7 @@ struct SyncRecordMapper { let redisDatabase = (record["redisDatabase"] as? Int64).map { Int($0) } let startupCommands = record["startupCommands"] as? String let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 + let isFavorite = (record["isFavorite"] as? Int64 ?? 0) != 0 let sshProfileId = (record["sshProfileId"] as? String).flatMap { UUID(uuidString: $0) } var sshConfig = SSHConfiguration() @@ -216,6 +218,7 @@ struct SyncRecordMapper { redisDatabase: redisDatabase, startupCommands: startupCommands, sortOrder: sortOrder, + isFavorite: isFavorite, additionalFields: additionalFields ) } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 539c2bdc6..dda3adc46 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -333,6 +333,7 @@ struct DatabaseConnection: Identifiable, Hashable { var sortOrder: Int var localOnly: Bool = false var isSample: Bool = false + var isFavorite: Bool = false var mongoAuthSource: String? { get { additionalFields["mongoAuthSource"]?.nilIfEmpty } @@ -428,6 +429,7 @@ struct DatabaseConnection: Identifiable, Hashable { sortOrder: Int = 0, localOnly: Bool = false, isSample: Bool = false, + isFavorite: Bool = false, additionalFields: [String: String]? = nil ) { self.id = id @@ -469,6 +471,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.sortOrder = sortOrder self.localOnly = localOnly self.isSample = isSample + self.isFavorite = isFavorite if let additionalFields { self.additionalFields = additionalFields } else { @@ -516,7 +519,7 @@ extension DatabaseConnection: Codable { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, groupId, sshProfileId case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields - case redisDatabase, startupCommands, sortOrder, localOnly, isSample + case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite } init(from decoder: Decoder) throws { @@ -545,6 +548,7 @@ extension DatabaseConnection: Codable { sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false + isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present @@ -595,6 +599,7 @@ extension DatabaseConnection: Codable { try container.encode(sortOrder, forKey: .sortOrder) try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) + try container.encode(isFavorite, forKey: .isFavorite) } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index c0c8ea992..5ff910d7b 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -326,6 +326,26 @@ final class WelcomeViewModel { WindowOpener.shared.openConnectionForm(editing: duplicate.id) } + // MARK: - Favorites + + var favoriteConnections: [DatabaseConnection] { + connections + .filter(\.isFavorite) + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + func toggleFavorite(_ targets: [DatabaseConnection]) { + guard !targets.isEmpty else { return } + let shouldFavorite = !targets.allSatisfy(\.isFavorite) + let ids = Set(targets.map(\.id)) + for i in connections.indices where ids.contains(connections[i].id) { + connections[i].isFavorite = shouldFavorite + storage.updateConnection(connections[i]) + } + rebuildTree() + AppEvents.shared.connectionUpdated.send(targets.count == 1 ? targets.first?.id : nil) + } + // MARK: - Delete func deleteSelectedConnections() { diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index ba8261b60..83fb8c573 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -8,6 +8,9 @@ import SwiftUI struct WelcomeConnectionRow: View { let connection: DatabaseConnection let sshProfile: SSHProfile? + let isSelected: Bool + let onToggleFavorite: () -> Void + @State private var isHovering = false private let pluginManager = PluginManager.shared private var displayTag: ConnectionTag? { @@ -26,6 +29,12 @@ struct WelcomeConnectionRow: View { } } + private var toggleFavoriteActionName: String { + connection.isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites") + } + var body: some View { HStack { connection.type.iconImage @@ -52,7 +61,11 @@ struct WelcomeConnectionRow: View { trailingAccessories } .contentShape(Rectangle()) + .onHover { hovering in isHovering = hovering } .accessibilityElement(children: .combine) + .accessibilityAction(named: Text(toggleFavoriteActionName)) { + onToggleFavorite() + } } @ViewBuilder @@ -87,6 +100,31 @@ struct WelcomeConnectionRow: View { .accessibilityElement(children: .combine) .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) } + + favoriteButton + } + } + + @ViewBuilder + private var favoriteButton: some View { + if connection.isFavorite { + Button(action: onToggleFavorite) { + Image(systemName: "star.fill") + .imageScale(.small) + .foregroundStyle(.yellow) + } + .buttonStyle(.borderless) + .help(String(localized: "Remove from Favorites")) + .accessibilityLabel(String(localized: "Favorited")) + } else if isHovering || isSelected { + Button(action: onToggleFavorite) { + Image(systemName: "star") + .imageScale(.small) + .foregroundStyle(.tertiary) + } + .buttonStyle(.borderless) + .help(String(localized: "Add to Favorites")) + .accessibilityHidden(true) } } diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index ef9c5286c..9c3072bb0 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -32,6 +32,18 @@ extension WelcomeWindowView { Divider() + let allFavorited = connections.allSatisfy(\.isFavorite) + Button { vm.toggleFavorite(connections) } label: { + Label( + allFavorited + ? String(format: String(localized: "Remove %d Connections from Favorites"), connections.count) + : String(format: String(localized: "Add %d Connections to Favorites"), connections.count), + systemImage: allFavorited ? "star.slash" : "star" + ) + } + + Divider() + Menu(String(localized: "Share")) { Button { vm.exportConnections(connections) @@ -109,6 +121,17 @@ extension WelcomeWindowView { Divider() + Button { vm.toggleFavorite([connection]) } label: { + Label( + connection.isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites"), + systemImage: connection.isFavorite ? "star.slash" : "star" + ) + } + + Divider() + Menu(String(localized: "Share")) { Button { let pw = ConnectionStorage.shared.loadPassword(for: connection.id) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 095bab8da..e388a23f9 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -294,6 +294,22 @@ struct WelcomeWindowView: View { private var connectionList: some View { ScrollViewReader { proxy in List(selection: $vm.selectedConnectionIds) { + if vm.searchText.isEmpty, !vm.favoriteConnections.isEmpty { + Section { + ForEach(vm.favoriteConnections) { conn in + connectionRow(for: conn) + } + } header: { + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.caption2) + Text(String(localized: "Favorites")) + .font(.caption) + } + .foregroundStyle(.secondary) + } + } + TreeRowsView(items: vm.treeItems, parentGroupId: nil, vm: vm) { conn in connectionRow(for: conn) } @@ -372,7 +388,9 @@ struct WelcomeWindowView: View { let sshProfile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) } return WelcomeConnectionRow( connection: connection, - sshProfile: sshProfile + sshProfile: sshProfile, + isSelected: vm.selectedConnectionIds.contains(connection.id), + onToggleFavorite: { vm.toggleFavorite([connection]) } ) .tag(connection.id) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index cb03c2bbe..25f745f70 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -63,4 +63,29 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.id == connection.id) #expect(loaded.first?.name == "Round Trip Test") } + + @Test("connections default to not favorited") + func defaultsToNotFavorited() { + let connection = DatabaseConnection(name: "Plain Test") + storage.saveConnections([connection]) + let loaded = storage.loadConnections() + + #expect(loaded.first?.isFavorite == false) + } + + @Test("round-trip preserves the isFavorite flag") + func roundTripPreservesFavorite() { + var connection = DatabaseConnection( + name: "Favorite Test", + host: "127.0.0.1", + port: 5_432, + type: .postgresql + ) + connection.isFavorite = true + + storage.saveConnections([connection]) + let loaded = storage.loadConnections() + + #expect(loaded.first?.isFavorite == true) + } } diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index c1d251490..b69a75d1b 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -325,6 +325,14 @@ Groups support up to 3 levels of nesting. Right-click a group to create a subgro /> +### Favorites + +Star a connection to keep it within reach. Hover any row in the welcome list and the star button appears on the right; click it, or right-click the row and choose **Add to Favorites**. Multi-select to favorite many at once. + +Favorited connections gather in a **Favorites** section at the top of the list, sorted alphabetically. The same connection still appears in its group below, so you can keep your taxonomy intact while pinning the few you reach for daily. Click the filled star (or right-click and pick **Remove from Favorites**) to unstar. + +Favorites sync through iCloud along with the rest of your connection settings. + ## Quick Connection Switching Switch connections from the toolbar: From de510f1f76655b56b32a31c17554af12f876ed80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 28 May 2026 12:51:31 +0700 Subject: [PATCH 2/5] refactor(welcome): batch favorite save, memoize favorites list, drop count from multi-select labels (#1302) --- TablePro/Core/Storage/ConnectionStorage.swift | 22 +++++++++++++ TablePro/ViewModels/WelcomeViewModel.swift | 19 +++++++----- .../Connection/WelcomeContextMenus.swift | 4 +-- .../ConnectionStoragePersistenceTests.swift | 31 ++++++++++++++++++- docs/databases/overview.mdx | 2 +- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index a9e88d9b7..cc9596d4f 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -173,6 +173,28 @@ final class ConnectionStorage { } } + /// Update multiple connections in a single file write, marking each dirty for sync. + @discardableResult + func updateConnections(_ updates: [DatabaseConnection]) -> Bool { + guard !updates.isEmpty else { return true } + var connections = loadConnections() + let updatesById = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) + var didMutate = false + for index in connections.indices { + if let replacement = updatesById[connections[index].id] { + connections[index] = replacement + didMutate = true + } + } + guard didMutate, saveConnections(connections) else { + return false + } + for connection in updates where !connection.localOnly && !connection.isSample { + syncTracker.markDirty(.connection, id: connection.id.uuidString) + } + return true + } + /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { var connections = loadConnections() diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 5ff910d7b..a058ebf1f 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -97,6 +97,7 @@ final class WelcomeViewModel { // MARK: - Computed Properties private(set) var treeItems: [ConnectionGroupTreeNode] = [] + private(set) var favoriteConnections: [DatabaseConnection] = [] private(set) var connectionCountByGroup: [UUID: Int] = [:] private(set) var depthByGroup: [UUID: Int] = [:] private(set) var maxDescendantDepthByGroup: [UUID: Int] = [:] @@ -109,6 +110,10 @@ final class WelcomeViewModel { treeItems = filterGroupTree(tree, searchText: searchText) } + favoriteConnections = connections + .filter(\.isFavorite) + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + var counts: [UUID: Int] = [:] var depths: [UUID: Int] = [:] var descendantDepths: [UUID: Int] = [:] @@ -328,19 +333,19 @@ final class WelcomeViewModel { // MARK: - Favorites - var favoriteConnections: [DatabaseConnection] { - connections - .filter(\.isFavorite) - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - } - func toggleFavorite(_ targets: [DatabaseConnection]) { guard !targets.isEmpty else { return } let shouldFavorite = !targets.allSatisfy(\.isFavorite) let ids = Set(targets.map(\.id)) + var updated: [DatabaseConnection] = [] for i in connections.indices where ids.contains(connections[i].id) { connections[i].isFavorite = shouldFavorite - storage.updateConnection(connections[i]) + updated.append(connections[i]) + } + guard storage.updateConnections(updated) else { + connections = storage.loadConnections() + rebuildTree() + return } rebuildTree() AppEvents.shared.connectionUpdated.send(targets.count == 1 ? targets.first?.id : nil) diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 9c3072bb0..747841afa 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -36,8 +36,8 @@ extension WelcomeWindowView { Button { vm.toggleFavorite(connections) } label: { Label( allFavorited - ? String(format: String(localized: "Remove %d Connections from Favorites"), connections.count) - : String(format: String(localized: "Add %d Connections to Favorites"), connections.count), + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites"), systemImage: allFavorited ? "star.slash" : "star" ) } diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 25f745f70..6a81ad4a9 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -12,11 +12,12 @@ import Testing @MainActor struct ConnectionStoragePersistenceTests { private let storage: ConnectionStorage + private let fileURL: URL private let defaults: UserDefaults init() { let unique = UUID().uuidString - let fileURL = FileManager.default.temporaryDirectory + self.fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-tests") .appendingPathComponent("connections_\(unique).json") try? FileManager.default.createDirectory( @@ -88,4 +89,32 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.isFavorite == true) } + + @Test("legacy connections.json without isFavorite key decodes as not favorited") + func decodesLegacyFileWithoutFavoriteKey() throws { + let legacyJSON = """ + [{ + "id": "11111111-1111-1111-1111-111111111111", + "name": "Legacy Connection", + "host": "localhost", + "port": 3306, + "database": "", + "username": "root", + "type": "MySQL", + "sshEnabled": false, + "sshHost": "", + "sshUsername": "", + "sshAuthMethod": "password", + "sshPrivateKeyPath": "" + }] + """ + try Data(legacyJSON.utf8).write(to: fileURL, options: .atomic) + storage.invalidateCache() + + let loaded = storage.loadConnections() + + #expect(loaded.count == 1) + #expect(loaded.first?.name == "Legacy Connection") + #expect(loaded.first?.isFavorite == false) + } } diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index b69a75d1b..cde26ce68 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -331,7 +331,7 @@ Star a connection to keep it within reach. Hover any row in the welcome list and Favorited connections gather in a **Favorites** section at the top of the list, sorted alphabetically. The same connection still appears in its group below, so you can keep your taxonomy intact while pinning the few you reach for daily. Click the filled star (or right-click and pick **Remove from Favorites**) to unstar. -Favorites sync through iCloud along with the rest of your connection settings. +Favorites sync through iCloud along with the rest of your connection settings. Connections you've excluded from iCloud sync keep their favorite status on the local device only. ## Quick Connection Switching From d3851e77adc4f5d678c824d6b8ab920233e1c1fe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:07:50 +0700 Subject: [PATCH 3/5] refactor(welcome): harden favorites batch update against duplicate ids and stale state (#1302) --- TablePro/Core/Storage/ConnectionStorage.swift | 4 +- TablePro/ViewModels/WelcomeViewModel.swift | 28 ++++--- .../ConnectionStoragePersistenceTests.swift | 82 ++++++++++++++++++- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index cc9596d4f..903124914 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -178,7 +178,7 @@ final class ConnectionStorage { func updateConnections(_ updates: [DatabaseConnection]) -> Bool { guard !updates.isEmpty else { return true } var connections = loadConnections() - let updatesById = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) + let updatesById = Dictionary(updates.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) var didMutate = false for index in connections.indices { if let replacement = updatesById[connections[index].id] { @@ -189,7 +189,7 @@ final class ConnectionStorage { guard didMutate, saveConnections(connections) else { return false } - for connection in updates where !connection.localOnly && !connection.isSample { + for connection in updatesById.values where !connection.localOnly && !connection.isSample { syncTracker.markDirty(.connection, id: connection.id.uuidString) } return true diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index a058ebf1f..73bb2df10 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -103,17 +103,21 @@ final class WelcomeViewModel { private(set) var maxDescendantDepthByGroup: [UUID: Int] = [:] func rebuildTree() { - let tree = buildGroupTree(groups: groups, connections: connections, parentId: nil) - if searchText.isEmpty { - treeItems = tree - } else { - treeItems = filterGroupTree(tree, searchText: searchText) - } - favoriteConnections = connections .filter(\.isFavorite) .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + let tree = buildGroupTree(groups: groups, connections: connections, parentId: nil) + let baseItems = searchText.isEmpty ? tree : filterGroupTree(tree, searchText: searchText) + if searchText.isEmpty, !favoriteConnections.isEmpty { + treeItems = baseItems.filter { node in + if case .connection(let conn) = node, conn.isFavorite { return false } + return true + } + } else { + treeItems = baseItems + } + var counts: [UUID: Int] = [:] var depths: [UUID: Int] = [:] var descendantDepths: [UUID: Int] = [:] @@ -335,12 +339,14 @@ final class WelcomeViewModel { func toggleFavorite(_ targets: [DatabaseConnection]) { guard !targets.isEmpty else { return } - let shouldFavorite = !targets.allSatisfy(\.isFavorite) let ids = Set(targets.map(\.id)) + let live = connections.filter { ids.contains($0.id) } + guard !live.isEmpty else { return } + let shouldFavorite = !live.allSatisfy(\.isFavorite) var updated: [DatabaseConnection] = [] - for i in connections.indices where ids.contains(connections[i].id) { - connections[i].isFavorite = shouldFavorite - updated.append(connections[i]) + for index in connections.indices where ids.contains(connections[index].id) { + connections[index].isFavorite = shouldFavorite + updated.append(connections[index]) } guard storage.updateConnections(updated) else { connections = storage.loadConnections() diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 6a81ad4a9..22f13b0aa 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -12,6 +12,7 @@ import Testing @MainActor struct ConnectionStoragePersistenceTests { private let storage: ConnectionStorage + private let syncTracker: SyncChangeTracker private let fileURL: URL private let defaults: UserDefaults @@ -28,11 +29,11 @@ struct ConnectionStoragePersistenceTests { self.defaults = UserDefaults(suiteName: suiteName)! let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! let metadata = SyncMetadataStorage(userDefaults: syncDefaults) - let tracker = SyncChangeTracker(metadataStorage: metadata) + self.syncTracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( fileURL: fileURL, userDefaults: defaults, - syncTracker: tracker + syncTracker: syncTracker ) } @@ -90,6 +91,83 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.isFavorite == true) } + @Test("updateConnections writes batched changes and marks each dirty for sync") + func updateConnectionsBatchesAndMarksDirty() { + var first = DatabaseConnection(name: "First", type: .postgresql) + var second = DatabaseConnection(name: "Second", type: .mysql) + let untouched = DatabaseConnection(name: "Untouched", type: .sqlite) + storage.saveConnections([first, second, untouched]) + + first.isFavorite = true + second.name = "Renamed" + + let result = storage.updateConnections([first, second]) + #expect(result == true) + + let loaded = storage.loadConnections() + #expect(loaded.first(where: { $0.id == first.id })?.isFavorite == true) + #expect(loaded.first(where: { $0.id == second.id })?.name == "Renamed") + #expect(loaded.first(where: { $0.id == untouched.id })?.name == "Untouched") + + let dirty = syncTracker.dirtyRecords(for: .connection) + #expect(dirty.contains(first.id.uuidString)) + #expect(dirty.contains(second.id.uuidString)) + #expect(!dirty.contains(untouched.id.uuidString)) + } + + @Test("updateConnections returns false when no ids match the stored file") + func updateConnectionsNoMatch() { + let stored = DatabaseConnection(name: "Stored", type: .postgresql) + storage.saveConnections([stored]) + + let ghost = DatabaseConnection(name: "Ghost", type: .mysql) + let result = storage.updateConnections([ghost]) + + #expect(result == false) + let loaded = storage.loadConnections() + #expect(loaded.count == 1) + #expect(loaded.first?.id == stored.id) + } + + @Test("updateConnections tolerates duplicate ids in the input batch") + func updateConnectionsHandlesDuplicateIds() { + let original = DatabaseConnection(name: "Original", type: .postgresql) + storage.saveConnections([original]) + + var firstCopy = original + firstCopy.name = "First Edit" + var secondCopy = original + secondCopy.name = "Second Edit" + + let result = storage.updateConnections([firstCopy, secondCopy]) + #expect(result == true) + + let loaded = storage.loadConnections() + #expect(loaded.first?.name == "Second Edit") + } + + @Test("updateConnections does not mark localOnly or sample connections dirty") + func updateConnectionsSkipsLocalAndSample() { + var localOnly = DatabaseConnection(name: "Local", type: .postgresql) + localOnly.localOnly = true + var sample = DatabaseConnection(name: "Sample", type: .mysql) + sample.isSample = true + var synced = DatabaseConnection(name: "Synced", type: .sqlite) + storage.saveConnections([localOnly, sample, synced]) + + localOnly.isFavorite = true + sample.isFavorite = true + synced.isFavorite = true + + let result = storage.updateConnections([localOnly, sample, synced]) + #expect(result == true) + + let dirty = syncTracker.dirtyRecords(for: .connection) + #expect(dirty.contains(synced.id.uuidString)) + #expect(!dirty.contains(localOnly.id.uuidString)) + #expect(!dirty.contains(sample.id.uuidString)) + } + @Test("legacy connections.json without isFavorite key decodes as not favorited") func decodesLegacyFileWithoutFavoriteKey() throws { let legacyJSON = """ From d65206abc75d41304bc232b814e7884e52b0b32f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:07:55 +0700 Subject: [PATCH 4/5] refactor(welcome): match macos source list patterns in the favorites connection list (#1302) --- TablePro/Resources/Localizable.xcstrings | 15 ++++- .../Connection/WelcomeConnectionRow.swift | 43 +++++++------- .../Views/Connection/WelcomeWindowView.swift | 58 ++++++++++++------- 3 files changed, 71 insertions(+), 45 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index b3deed808..32af71936 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4736,6 +4736,9 @@ } } } + }, + "Add to Favorites" : { + }, "Add validation rules to ensure data integrity" : { "localizations" : { @@ -15164,6 +15167,9 @@ }, "Default row sort:" : { + }, + "Default token" : { + }, "Default value" : { "extractionState" : "stale", @@ -21360,6 +21366,9 @@ } } } + }, + "Favorited" : { + }, "Favorites" : { "localizations" : { @@ -38461,6 +38470,9 @@ }, "Remove Foreign Key" : { + }, + "Remove from Favorites" : { + }, "Remove from Group" : { "localizations" : { @@ -40215,9 +40227,6 @@ } } } - }, - "Sample" : { - }, "Sanitize formula-like values" : { "extractionState" : "stale", diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 83fb8c573..f48415705 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -105,27 +105,31 @@ struct WelcomeConnectionRow: View { } } - @ViewBuilder private var favoriteButton: some View { - if connection.isFavorite { - Button(action: onToggleFavorite) { - Image(systemName: "star.fill") - .imageScale(.small) - .foregroundStyle(.yellow) - } - .buttonStyle(.borderless) - .help(String(localized: "Remove from Favorites")) - .accessibilityLabel(String(localized: "Favorited")) - } else if isHovering || isSelected { - Button(action: onToggleFavorite) { - Image(systemName: "star") - .imageScale(.small) - .foregroundStyle(.tertiary) + Group { + if connection.isFavorite { + Button(action: onToggleFavorite) { + Image(systemName: "star.fill") + .imageScale(.small) + .foregroundStyle(.yellow) + } + .buttonStyle(.borderless) + .help(String(localized: "Remove from Favorites")) + .accessibilityLabel(String(localized: "Favorited")) + } else if isHovering || isSelected { + Button(action: onToggleFavorite) { + Image(systemName: "star") + .imageScale(.small) + .foregroundStyle(.tertiary) + } + .buttonStyle(.borderless) + .help(String(localized: "Add to Favorites")) + .accessibilityHidden(true) + } else { + Color.clear } - .buttonStyle(.borderless) - .help(String(localized: "Add to Favorites")) - .accessibilityHidden(true) } + .frame(width: 16, alignment: .center) } private var subtitleText: String { @@ -133,9 +137,6 @@ struct WelcomeConnectionRow: View { if let viaText = sshViaText { components.append(viaText) } - if connection.isSample { - components.append(String(localized: "Sample")) - } return components.joined(separator: " ยท ") } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index e388a23f9..99f40bfdd 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -224,7 +224,7 @@ struct WelcomeWindowView: View { connectionsHeader Divider() ZStack { - if vm.treeItems.isEmpty && vm.linkedConnections.isEmpty { + if vm.treeItems.isEmpty && vm.linkedConnections.isEmpty && vm.favoriteConnections.isEmpty { emptyState } else { connectionList @@ -294,43 +294,52 @@ struct WelcomeWindowView: View { private var connectionList: some View { ScrollViewReader { proxy in List(selection: $vm.selectedConnectionIds) { - if vm.searchText.isEmpty, !vm.favoriteConnections.isEmpty { + let showsFavoritesSection = vm.searchText.isEmpty && !vm.favoriteConnections.isEmpty + let showsLinkedSection = !vm.linkedConnections.isEmpty + && LicenseManager.shared.isFeatureAvailable(.linkedFolders) + let treeHasGroups = vm.treeItems.contains { item in + if case .group = item { return true } + return false + } + let treeNeedsHeader = showsFavoritesSection && !treeHasGroups && !vm.treeItems.isEmpty + + if showsFavoritesSection { Section { ForEach(vm.favoriteConnections) { conn in connectionRow(for: conn) } } header: { - HStack(spacing: 4) { - Image(systemName: "star.fill") - .font(.caption2) - Text(String(localized: "Favorites")) - .font(.caption) - } - .foregroundStyle(.secondary) + sourceListSectionHeader(String(localized: "Favorites")) } } - TreeRowsView(items: vm.treeItems, parentGroupId: nil, vm: vm) { conn in - connectionRow(for: conn) + if treeNeedsHeader { + Section { + TreeRowsView(items: vm.treeItems, parentGroupId: nil, vm: vm) { conn in + connectionRow(for: conn) + } + } header: { + sourceListSectionHeader(String(localized: "Connections")) + } + } else { + TreeRowsView(items: vm.treeItems, parentGroupId: nil, vm: vm) { conn in + connectionRow(for: conn) + } } - if !vm.linkedConnections.isEmpty, LicenseManager.shared.isFeatureAvailable(.linkedFolders) { + if showsLinkedSection { Section { ForEach(vm.linkedConnections) { linked in linkedConnectionRow(for: linked) } } header: { - HStack(spacing: 4) { - Image(systemName: "folder.fill") - .font(.caption2) - Text(String(localized: "Linked")) - .font(.caption) - } - .foregroundStyle(.secondary) + sourceListSectionHeader(String(localized: "Linked")) } } } .listStyle(.inset) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) .scrollContentBackground(.hidden) .focused($focus, equals: .connectionList) .contextMenu(forSelectionType: UUID.self) { ids in @@ -393,10 +402,17 @@ struct WelcomeWindowView: View { onToggleFavorite: { vm.toggleFavorite([connection]) } ) .tag(connection.id) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) } + private func sourceListSectionHeader(_ title: String) -> some View { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .padding(.top, 6) + } + private func linkedConnectionRow(for linked: LinkedConnection) -> some View { HStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { @@ -418,7 +434,6 @@ struct WelcomeWindowView: View { } .tag(linked.id) .padding(.vertical, 4) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .contentShape(Rectangle()) .listRowSeparator(.hidden) } @@ -497,6 +512,7 @@ private struct TreeRowsView: View { } label: { groupLabel(for: group) } + .listRowSeparator(.hidden) } } .onMove(perform: allConnections ? { from, to in From a8c48668ab7fc6fd4fad7ecbada2beac08fdf7ba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:13:26 +0700 Subject: [PATCH 5/5] refactor(welcome): unify favorite button render path and drop linked row padding (#1302) --- .../Connection/WelcomeConnectionRow.swift | 44 +++++++++---------- .../Views/Connection/WelcomeWindowView.swift | 1 - 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index f48415705..2069f1ab4 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -106,32 +106,32 @@ struct WelcomeConnectionRow: View { } private var favoriteButton: some View { - Group { - if connection.isFavorite { - Button(action: onToggleFavorite) { - Image(systemName: "star.fill") - .imageScale(.small) - .foregroundStyle(.yellow) - } - .buttonStyle(.borderless) - .help(String(localized: "Remove from Favorites")) - .accessibilityLabel(String(localized: "Favorited")) - } else if isHovering || isSelected { - Button(action: onToggleFavorite) { - Image(systemName: "star") - .imageScale(.small) - .foregroundStyle(.tertiary) - } - .buttonStyle(.borderless) - .help(String(localized: "Add to Favorites")) - .accessibilityHidden(true) - } else { - Color.clear - } + let visible = connection.isFavorite || isHovering || isSelected + return Button(action: onToggleFavorite) { + favoriteStarImage } + .buttonStyle(.borderless) + .opacity(visible ? 1 : 0) + .allowsHitTesting(visible) + .help(toggleFavoriteActionName) + .accessibilityHidden(!connection.isFavorite) + .accessibilityLabel(String(localized: "Favorited")) .frame(width: 16, alignment: .center) } + @ViewBuilder + private var favoriteStarImage: some View { + if connection.isFavorite { + Image(systemName: "star.fill") + .imageScale(.small) + .foregroundStyle(.yellow) + } else { + Image(systemName: "star") + .imageScale(.small) + .foregroundStyle(.tertiary) + } + } + private var subtitleText: String { var components: [String] = [primaryEndpoint] if let viaText = sshViaText { diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 99f40bfdd..83b59f549 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -433,7 +433,6 @@ struct WelcomeWindowView: View { } } .tag(linked.id) - .padding(.vertical, 4) .contentShape(Rectangle()) .listRowSeparator(.hidden) }