diff --git a/CHANGELOG.md b/CHANGELOG.md index f11f305fd..5bfbd6684 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) - Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON. - Add and remove buttons in the table structure editor for columns, indexes, and foreign keys, on the bottom status bar alongside the view-mode picker. Cmd+Shift+N adds and Cmd+Delete removes. An empty Indexes or Foreign Keys tab also shows a labelled add button. (#1319) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index d212298ef..903124914 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(updates.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + 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 updatesById.values where !connection.localOnly && !connection.isSample { + syncTracker.markDirty(.connection, id: connection.id.uuidString) + } + return true + } + /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { var connections = loadConnections() @@ -551,6 +573,8 @@ private struct StoredConnection: Codable { let isSample: Bool + let isFavorite: Bool + // TOTP configuration let totpMode: String let totpAlgorithm: String @@ -638,6 +662,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 +696,7 @@ private struct StoredConnection: Codable { case additionalFields case localOnly case isSample + case isFavorite } func encode(to encoder: Encoder) throws { @@ -711,6 +739,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 +808,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 +912,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/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/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index c0c8ea992..73bb2df10 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -97,16 +97,25 @@ 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] = [:] func rebuildTree() { + favoriteConnections = connections + .filter(\.isFavorite) + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + let tree = buildGroupTree(groups: groups, connections: connections, parentId: nil) - if searchText.isEmpty { - treeItems = tree + 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 = filterGroupTree(tree, searchText: searchText) + treeItems = baseItems } var counts: [UUID: Int] = [:] @@ -326,6 +335,28 @@ final class WelcomeViewModel { WindowOpener.shared.openConnectionForm(editing: duplicate.id) } + // MARK: - Favorites + + func toggleFavorite(_ targets: [DatabaseConnection]) { + guard !targets.isEmpty else { return } + 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 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() + rebuildTree() + return + } + 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..2069f1ab4 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,35 @@ struct WelcomeConnectionRow: View { .accessibilityElement(children: .combine) .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) } + + favoriteButton + } + } + + private var favoriteButton: some View { + 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) } } @@ -95,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/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index ef9c5286c..747841afa 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(localized: "Remove from Favorites") + : String(localized: "Add to Favorites"), + 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..83b59f549 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,27 +294,52 @@ struct WelcomeWindowView: View { private var connectionList: some View { ScrollViewReader { proxy in List(selection: $vm.selectedConnectionIds) { - TreeRowsView(items: vm.treeItems, parentGroupId: nil, vm: vm) { conn in - connectionRow(for: conn) + 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 !vm.linkedConnections.isEmpty, LicenseManager.shared.isFeatureAvailable(.linkedFolders) { + if showsFavoritesSection { + Section { + ForEach(vm.favoriteConnections) { conn in + connectionRow(for: conn) + } + } header: { + sourceListSectionHeader(String(localized: "Favorites")) + } + } + + 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 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 @@ -372,13 +397,22 @@ 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)) .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) { @@ -399,8 +433,6 @@ struct WelcomeWindowView: View { } } .tag(linked.id) - .padding(.vertical, 4) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .contentShape(Rectangle()) .listRowSeparator(.hidden) } @@ -479,6 +511,7 @@ private struct TreeRowsView: View { } label: { groupLabel(for: group) } + .listRowSeparator(.hidden) } } .onMove(perform: allConnections ? { from, to in diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index cb03c2bbe..22f13b0aa 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -12,11 +12,13 @@ import Testing @MainActor struct ConnectionStoragePersistenceTests { private let storage: ConnectionStorage + private let syncTracker: SyncChangeTracker + 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( @@ -27,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 ) } @@ -63,4 +65,134 @@ 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) + } + + @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 = """ + [{ + "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 c1d251490..cde26ce68 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. Connections you've excluded from iCloud sync keep their favorite status on the local device only. + ## Quick Connection Switching Switch connections from the toolbar: